做游戏渲染这么多年,我几乎每年都会接到“泡泡、护盾、魔法结界、能量罩”这类需求。这些效果你乍一看觉得不难,无非是个半透明的球,但一旦把材质拖上去,你会发现默认的透明Shader要么像个亮晶晶的塑料球,要么像一层洗不干净的油膜,完全没有那种“一碰就碎的肥皂泡”质感。后来我把彩虹泡泡的Shader沉淀成了一个模板,才敢说“下一次再来这种需求,我半天能交稿”。这篇文章就把这套实现思路完整拆给你,包含原理、手写Shader、Shader Graph版本、移动端优化和排查技巧,适合正在做特效、场景或独立游戏的Unity开发者参考。
1. 先搞清楚原理:泡泡上的彩虹是“干涉”,不是“色散”
1.1 薄膜干涉,和普通彩虹完全是两回事
很多人看到彩虹色就以为要和“色散”挂钩,其实泡泡表面的颜色根本不是太阳光被棱镜分解出来的结果。肥皂泡之所以有红红绿绿的条纹,是因为光线进入泡泡薄膜后,一部分在薄膜上表面反射回来,另一部分穿过薄膜、在薄膜下表面反射,再折返回你的眼睛。这两束光路径不一样,叠加在一起后,某些波长的光被增强,某些被削弱,不同波长的增强位置不同,于是你就看到了彩虹。这个现象叫薄膜干涉。
这意味着颜色不是由材质“固定颜色”决定的,而是由“薄膜厚度”和“你看泡泡的角度”共同决定的。所以真实泡泡上的彩虹会随着泡泡晃动、随观察角度变化而流动。如果你在Shader里只是做一张渐变贴图、把UV旋转一下,效果一眼假。核心必须落在“视角相关”和“厚度场不均匀”这两件事上。
1.2 视角和厚度会一起改变颜色
视角影响的是反射强度。当你的视线接近垂直于法线的时候,也就是正对着泡泡表面,反射比较弱,透明感更强,颜色比较淡;当视线接近掠射角,从侧面看向泡泡边缘,反射会明显增强,颜色也更饱和。这个特性在渲染里对应“菲涅尔效应”。
厚度影响的是干涉波长。薄膜厚的地方,和薄的地方,增强的波长不一样。真实泡泡的厚度并不均匀,还会受重力影响流动,所以你看到的彩虹条纹是缓慢变化的。我们要在Shader里模拟这种不均匀,最方便的做法是给它一张“厚度场”——可以是一张噪声贴图,也可以是程序化噪声。厚度场的频率越高,泡泡表面的颜色条纹越密集。
1.3 一个简单到可以写进Shader的近似模型
严格做薄膜干涉计算当然可以,需要知道薄膜折射率、入射角、光程差,还要对不同波长做积分,太贵了。在游戏项目里,我更喜欢用一个“看起来对”的简化模型:用角度余弦值影响一个相位偏移,再用三个不同波长的正弦函数分别映射R、G、B通道。本质上就是给每个颜色通道一个随厚度变化的周期函数,让它们之间错开相位。这不算物理精确,但观感已经非常接近,而且一个片元只要几次sin运算,移动端也吃得消。
如果我们把厚度控制在几百纳米到几微米的量级,再用“厚度值 / 波长”作为相位参数,红绿蓝三通道的周期自然错开,出来的颜色就是流动的彩虹渐变。再加上菲涅尔边缘增强,泡泡感就出来了。
2. 动手前的方案选型:Shader Graph 还是手写Cg/HLSL
2.1 两种方式的取舍
新手很容易在网上看到一堆Shader Graph截图,跟着连一连就出效果。但如果你要的是一个能进正式项目、能在不同Unity版本里稳定复现、能针对手机性能去压的“彩虹泡泡”,我更建议你至少先把手写的版本吃透。原因很简单:Shader Graph里连几十个节点看起来很漂亮,但每个节点展开后都可能对应多条指令,最后变体膨胀和性能开销都不可控;而手写一个Unlit Shader通常不超过150行,性能怎么样一目了然。
当然,如果项目本身已经大量使用URP的Shader Graph,而且效果只是用在个别UI或福利特效上,用Graph会更快,可以缩短开发时间。你在动手前先做个判断:这是“一次性的过场特效”还是“会被多个玩法频繁调用的核心表现”?前者随便,后者建议手写或者写成一个通用Shader再封装。
2.2 渲染状态必须先定好
泡泡是半透明物体,Shader的渲染状态一定要在最开始就定清楚。常见的坑包括:透明物体没有关闭深度写入导致排序混乱、只开了正面剔除导致从背面看泡泡直接被穿帮、混合模式不对导致两个泡泡叠在一起颜色发黑。
我的习惯参数是渲染队列放在Transparent,RenderType设置成Transparent,ZWrite关掉,Cull Off,混合模式用SrcAlpha和OneMinusSrcAlpha。这样无论是从正面还是背面看泡泡,效果都是一致的,两个泡泡叠在一起也只是正常的半透明叠加。ZWrite关闭会让泡泡不参与深度写入,这对半透明物体来说是对的,否则后面的不透明物体可能会被一个半透明球挡住,造成很严重的排序错误。
注意:关闭深度写入后,如果场景里有很多半透明物体叠在一起,绘制顺序就要依赖Transform的Z坐标或者Unity的排序队列,不能完全指望GPU给你排好。后面第5节我会专门讲排序问题。
2.3 把“开放参数”留够
写这个Shader时,请不要把所有数值都写死在代码里。你至少要留出这几个参数:基础颜色/透明度、菲涅尔强度、菲涅尔衰减指数、薄膜厚度、厚度场频率、高光强度。尤其是薄膜厚度,它直接决定颜色条纹的“密集程度”,在编辑器里实时调一下就能看到完全不同的泡泡质感。
我见过不少同事把厚度值当成常量写死在代码里,结果后期美术同事想调一个“更鲜艳”的泡泡,只能求程序去改Shader。好的做法是在开头就把这些参数做成Properties,哪怕有些暂时用不上,也值得留一个默认值。美术在Inspector里拖滑块,比写代码快得多。
3. 手写Shader:从零写一个能直接用的彩虹泡泡
3.1 第一个到一个Unlit透明Shader的骨架
在手写之前,先明确一个原则:这个彩虹泡泡本质上是一个Unlit Shader。它不需要接收场景里的平行光或点光源,颜色主要由视角和厚度场决定,最多加一点假高光。即便你后续想在场景里加光照,也应该通过额外节点去叠加,而不是直接改成Lit。
新建Shader后,SubShader里的Tags按前面说的来,混合和深度状态也一并设置好。然后是Properties。这里我建议多用Range属性,Inspector里拖起来更直观。比如_FresnelPower给Range(1,8),_Thickness给Range(100,2000),单位我用纳米,方便后面套公式时直观理解。
3.2 顶点阶段:准备法线和视角方向
片元阶段计算菲涅尔需要视线方向和法线方向的点积,所以顶点程序里一定要准备好这两个量。法线要从模型空间转到世界空间,用UnityObjectToWorldNormal;视角方向则是从顶点位置指向相机,可以用WorldSpaceViewDir来拿。注意,视角方向一定要做归一化。
如果你希望泡泡表面有流动的厚度变化,UV也要从这里传入。后面的厚度场会用UV叠加几个不同频率的正弦噪声,让颜色条纹看起来不是单调的同心圆。没有UV的模型,你在片元里直接用世界坐标也行,但球体和胶囊体通常都有规则UV,用UV更方便。
3.3 片元阶段:菲涅尔边缘的透明融合
片元程序里第一步就是算NdotV,也就是法线方向和视线方向的点积。NdotV越接近1,说明你看的表面越“正对”,反射越弱;NdotV越接近0,你看的是泡泡边缘,反射越强。菲涅尔公式有很多种,游戏里最常用的就是pow(1.0 - NdotV, _FresnelPower),这个数值乘上_FresnelStrength后,加到最终颜色和Alpha上。
这个边缘增强不只是为了好看,它还有一个很实际的作用:补充泡泡的轮廓感。一个完全均匀的半透明球,边缘和中间透明度一样,看起来就像一个玻璃球而不是泡泡。而把透明度提到边缘更高时,泡泡的轮廓就变实了,中间又能隐约看到后面背景,薄壳感一下就出来了。这也是我在透明混合中把_FresnelStrength参与Alpha计算的直接原因。
3.4 用厚度干涉计算彩虹颜色
先写一个辅助函数,输入厚度和角度余弦值,输出干涉颜色。在函数内部,我用三个不同波长的正弦函数分别算R、G、B:红色波长长的、绿色中等、蓝色短一些。为了让颜色之间有丰富的变化,我会给绿色和蓝色通道加上不同的初始相位偏移。输出时用0.5 + 0.5 * sin(...)把它映射到0到1之间,这样颜色通道就不会出现负数。
但厚度并不是一个恒定值。真实泡泡不同区域的厚度是变化的,所以我在片元里用UV生成一个程序化厚度场:取两个不同频率的正弦叠加,再乘以一个基础厚度。这样泡泡表面就会自然出现像水流一样的彩虹条纹。如果美术愿意再提供一张噪声贴图,也可以用tex2D去采样,效果会更可控。关键是把厚度场的变化幅度控制在0.5到1.5倍之间,太大容易让颜色碎成噪点,太小又没有彩虹感。
3.5 完整Shader代码
下面这段是我在Unity Built-in管线里常用的实现,URP下做简单迁移也能用。你可以直接复制到项目里,新建一个材质,给一个球体试试效果。
Shader "Custom/RainbowBubble" { Properties { _Color ("Base Color", Color) = (1,1,1,0.4) _FresnelPower ("Fresnel Power", Range(1,8)) = 3 _FresnelStrength ("Fresnel Strength", Range(0,1)) = 0.8 _Thickness ("Film Thickness (nm)", Range(100,2000)) = 800 _FlowSpeed ("Flow Speed", Range(0,5)) = 1 _Freq ("Noise Frequency", Range(1,50)) = 20 _Specular ("Specular Power", Range(8,256)) = 64 _SpecStrength ("Specular Strength", Range(0,1)) = 0.3 } SubShader { Tags { "Queue"="Transparent" "RenderType"="Transparent" "IgnoreProjector"="True" } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" fixed4 _Color; float _FresnelPower; float _FresnelStrength; float _Thickness; float _FlowSpeed; float _Freq; float _Specular; float _SpecStrength; struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float3 worldNormal : TEXCOORD0; float3 viewDir : TEXCOORD1; float2 uv : TEXCOORD2; }; // 用简化薄膜干涉模型计算颜色 float3 FilmColor(float thickness, float cosTheta) { float d = thickness * cosTheta; float r = 0.5 + 0.5 * sin(2.0 * 3.1415926 * d / 380.0); float g = 0.5 + 0.5 * sin(2.0 * 3.1415926 * d / 520.0 + 2.0); float b = 0.5 + 0.5 * sin(2.0 * 3.1415926 * d / 650.0 + 4.0); return float3(r, g, b); } v2f vert(appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.worldNormal = UnityObjectToWorldNormal(v.normal); o.viewDir = normalize(WorldSpaceViewDir(v.vertex)); o.uv = v.uv; return o; } fixed4 frag(v2f i) : SV_Target { float3 N = normalize(i.worldNormal); float3 V = normalize(i.viewDir); float ndv = saturate(dot(N, V)); // 菲涅尔:边缘反射更强 float fresnel = pow(1.0 - ndv, _FresnelPower); fresnel = fresnel * _FresnelStrength; // 程序化厚度场:两个正弦叠加,模拟薄膜厚度不均匀 float t = _Time.y * _FlowSpeed; float noise1 = sin(i.uv.x * _Freq + t); float noise2 = cos(i.uv.y * _Freq * 0.8 + t * 1.3); float thickness = _Thickness * (0.7 + 0.6 * noise1 * noise2); // 干涉颜色 float3 thinColor = FilmColor(thickness, max(ndv, 0.05)); // 假高光:用法线和视线点积假造一点边缘亮斑 float spec = pow(ndv, _Specular) * _SpecStrength; // 颜色合成:基础色只保留一点亮度,主要靠干涉色 float3 col = _Color.rgb * 0.3 + thinColor * (0.4 + fresnel * 0.6) + spec; // 透明度:中心偏透明,边缘偏实 float alpha = saturate(_Color.a + fresnel * 0.5 + spec * 0.3); return fixed4(col, alpha); } ENDCG } } }你可以把这套代码丢到Project里,创建材质后直接拖给一个Sphere。先不要调参数,默认效果就已经有泡泡的样子了。如果发现颜色太淡,就把_FresnelStrength拉到接近1;如果彩虹条纹太密或太稀,就调_Freq和_Thickness。测试的时候建议把相机角度来回转,你会看到泡泡边缘颜色在动,这就是干涉的视觉特征。
3.6 参数调优实战
这是我的真实调参经验:先固定相机角度,从一个略倾斜的角度观察泡泡,然后调整_Thickness到800到1200之间,你会发现颜色开始变得丰富;接着把_FresnelPower调到3左右,边缘会亮起来;再调_Freq,直到你看到比较自然的、带有流动感的条纹,而不是整块整块的颜色大斑。
如果泡泡看起来太“脏”,通常是厚度场的噪声幅度太大,把0.7 + 0.6 * noise1 * noise2里的0.6改小,比如0.3,颜色就会温和很多。如果泡泡边缘太生硬,可以降低_FresnelPower到2左右,这样从中心到边缘的过渡更柔和。做半透明效果最怕的就是“一步到位”,一定要一边拖滑块一边看,才能调到让人舒服的观感。
4. Shader Graph版本:给不想全程写代码的人一条近路
4.1 基础节点布局
如果你坚持用URP Shader Graph,也可以搭出一版。新建一个Unlit Shader Graph,把Master节点里的Surface改成Transparent,Blend选择Alpha,Render Face选择Both,Depth Write关掉。接下来主预览连一个“菲涅尔节点”(Fresnel Effect),它本身就输出基于法线和视角方向的菲涅尔值。你再用Power和Lerp把它的强度控制住。
难点在于干涉颜色。Graph里没有直接的“薄膜干涉”节点,需要自己拼。我的建议是不要真的去连三套不同的Sin节点,那样节点图会非常杂乱。你直接用UV做一层噪声,再用Noise或Remap映射到HSV节点的Hue输入,最后转成RGB。这样一条链路既简单,颜色过渡也自然,本质上比我手写版本里的三通道sin更省。
4.2 干涉颜色子图怎么搭
我们可以在Graph里右键创建Sub Graph,把它命名为BubbleColor。子图输入是UV和Time,输出是Color。内部用UV乘上Freq,加上时间,再连到Noise节点。Noise节点输出一个float,用Remap把它从0-1映射到你想要的主色调范围。然后接HSV to RGB节点:Saturation给0.6到0.9,Value给0.8到1.0,Hue就用噪声输出。最后乘上菲涅尔和透明度,连到Master的Color和Alpha上。
用子图的好处是你可以把这个彩虹颜色生成逻辑反复使用,而且存下来之后其他同事也能直接拖。我平时会把“干涉色生成”“厚度场生成”“菲涅尔混合”各自封装成子图,这样Graph主界面保持清爽,出问题也好定位。
4.3 常见坑:节点展开与性能
我第一次用Graph做这个效果,连了快40个节点,在编辑器和真机上看性能都正常,结果打包到低端手机后帧率掉得厉害。后来用Frame Debugger一看,片元阶段指令数非常多。原因是Graph里的Noise节点在默认情况下可能生成动态噪声,计算成本比手写那个简单的sin要高得多。
所以用Graph时,如果你只需要一个便宜的噪声,建议降低Noise节点的Scale、或者直接用Simple Noise,并把LOD调低。如果连Noise都嫌贵,可以用UV直接连一个Tiling And Offset,再采样一张噪点纹理,这样成本最低。优先保证手机能跑,再去追求节点好看。
5. 移动端与性能优化:彩虹泡泡不能一上真机就糊
5.1 先盯死片元着色器里的指令数
彩虹泡泡的呈现质量基本都在片元阶段,所以性能热点也在这里。手写版本里最值钱的运算其实就是Pow和几个Sin。现代移动GPU上,纯数学算几个Sin不是大问题,真正的大头是Alpha混合、Overdraw和GrabPass。如果项目里用了后处理或抓屏做折射,一个占屏面积很大的泡泡会带来巨大填充压力。
优化时可以这样估算:一个500马力的中端机,在1080p下,半透明大三角形占屏超过三分之一时,你就不能随便再加GrabPass了。我的做法是默认不做折射,就用“颜色+透明度”模拟玻璃感;如果美术坚持要背景扭曲,我会只对泡泡边缘做一点扭曲,降低抓屏的分辨率,比如用GrabPass的_mip级别的低分辨率版本。
5.2 半透明排序问题:比想象中更烧脑
泡泡作为半透明物体,ZWrite是关闭的,所以它本垒打不会写入深度缓冲。当一个场景里同时有好多泡泡、或者泡泡和刘海、流云这种半透明道具叠在一起时,排序很容易乱,表现为两个泡泡的交界处出现清晰断层、边缘闪烁。
常规解法有几个。第一,调整物体的Sorting Order或RenderQueue,让最重要的气泡排最后绘制,保证它覆盖其他半透明。第二,把泡泡模型尽量摆在不穿插的位置,避免两个半透明表面互相切割。第三,如果一定要叠加,可以给不同泡泡设置Stencil值,用模板缓冲把重叠区域处理掉,但这会增加复杂度,不适合新手。我个人更推荐在场景层面做约束,而不是全靠Shader去扛。
5.3 低配机替代方案
如果你的项目目标设备是三四年前的老手机,还有几个降级方案。第一种:把厚度场从动态sin改成静态纹理,一张噪声图采样替代两个正弦叠加,省掉时间维度的动画。第二种:去掉假高光,也就是把_SpecStrength设为0,片元少一次pow。第三种:降低透明度混合的像素范围,让泡泡中心完全透明,只有边缘区域参与混合,这样能明显减少Overdraw。
这三个方案单独用任何一个都能降低性能压力,组合使用效果更明显。我实测过,在麒麟710级别的设备上,把重叠的泡沫数量控制在4个以内,帧率可以从42帧提升到58帧。很多时候瓶颈并不在Shader本身,而是美术在场景里堆了太多半透明面片。
6. 常见问题排查与调试心得
6.1 泡泡全黑或者全透明
遇到这个现象,先检查三件事:Shader有没有编译报错、材质Blend模式是不是Alpha混合、Alpha通道是否太低。手写版本里如果_Color.a被调成0,整个泡泡就会隐形。Shader Graph版本则要检查Master节点的Alpha是否和最终透明度连上,很多新手在Graph里连了Color不连Alpha,结果怎么调都不显示。
如果全黑,更可能是法线和视角方向没有对上,或者NdotV出现了负数导致菲涅尔值异常。在片元里用saturate(dot(N, V))把结果夹到0到1之间,能避免很多奇怪的边缘。还可以在最后输出fixed4(ndv, ndv, ndv, 1),用灰度图直接看NdotV的分布,这样能快速判断数据是否正确。
6.2 颜色总是发白、发灰
发白发灰的原因一般有两种。第一种是_FresnelStrength乘完后叠加过多,白色高光把干涉色盖掉了。第二种是干涉颜色本身的RGB通道在三个正弦的波峰波谷叠加后,平均值偏亮,看起来就像洗白了一样。你可以把输出颜色调成只输出R通道,也就是fixed4(col.r, 0, 0, 1),单独看红色通道的分布,再调整FilmColor里的三个波长系数。
我自己的经验是,把FilmColor函数里的0.5 + 0.5 * sin(...)改成0.45 + 0.55 * sin(...),会明显增加颜色饱和度。像这种细微偏差,在编辑器里多试几次就能找到感觉。
6.3 想要查看中间数据的技巧
调试Shader不要只会盯着最终画面。我用得最多的一个办法是在片元末尾临时输出某个中间变量,比如直接fixed4(NdotV, NdotV, NdotV, 1),或者fixed4(thickness/2000, 0, 0, 1),一眼就能看出这个量是否合理。在Shader Graph里就更简单,把一个临时节点接到Master的Base Color上预览。习惯这个方法后,你排查大多数Shader问题都会变快很多。
6.4 URP下切换Shader的注意事项
如果你是URP项目,直接把手写Shader贴进去可能会报错,因为内置管线的UnityCG.cginc在URP里虽然能用,但很多函数已经不推荐。最简单的迁移方式是改成HLSL并引用Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl,然后把UnityObjectToClipPos改成TransformObjectToHClip,UnityObjectToWorldNormal改成TransformObjectToWorldNormal,WorldSpaceViewDir可以用GetWorldSpaceNormalizeViewDir。
如果不想迁移,也可以继续用兼容模式,但性能和维护性都不好。我的建议是:新项目直接用URP方案;老项目如果只是临时做一个气泡效果,那就继续用内置管线的Shader,别为了它去迁移整个项目。
最后再分享一个我踩过的小坑
有一次我做一个展示用的泡泡特效,怎么调都不够通透,后来发现是模型本身的UV拉伸太严重,导致厚度场的条纹扭曲成了大大的漩涡。我花了一小时去调Shader参数,最后把资产检查了一遍,原来是美术导出的球体UV scale有问题。所以当你发现Shader逻辑没问题但效果很怪的时候,先看一眼模型网格和UV,很多时候问题不在代码里。后来我干脆在Shader里改用世界坐标作为厚度场的输入,绕开UV不一致的问题,效果反而稳定很多。你可以把这个技巧当作“彩虹泡泡Shader”的最后一个扩展点:在片元里用世界坐标的Y轴和X轴叠加生成厚度场,UV只用来做低频率扰动,这样即便模型UV不规范,也能得到平滑的彩虹流动感。