Unity Shader彩虹泡泡:薄膜干涉+菲涅尔半透明特效全解析
做游戏渲染这么多年我几乎每年都会接到“泡泡、护盾、魔法结界、能量罩”这类需求。这些效果你乍一看觉得不难无非是个半透明的球但一旦把材质拖上去你会发现默认的透明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/HLSL2.1 两种方式的取舍新手很容易在网上看到一堆Shader Graph截图跟着连一连就出效果。但如果你要的是一个能进正式项目、能在不同Unity版本里稳定复现、能针对手机性能去压的“彩虹泡泡”我更建议你至少先把手写的版本吃透。原因很简单Shader Graph里连几十个节点看起来很漂亮但每个节点展开后都可能对应多条指令最后变体膨胀和性能开销都不可控而手写一个Unlit Shader通常不超过150行性能怎么样一目了然。当然如果项目本身已经大量使用URP的Shader Graph而且效果只是用在个别UI或福利特效上用Graph会更快可以缩短开发时间。你在动手前先做个判断这是“一次性的过场特效”还是“会被多个玩法频繁调用的核心表现”前者随便后者建议手写或者写成一个通用Shader再封装。2.2 渲染状态必须先定好泡泡是半透明物体Shader的渲染状态一定要在最开始就定清楚。常见的坑包括透明物体没有关闭深度写入导致排序混乱、只开了正面剔除导致从背面看泡泡直接被穿帮、混合模式不对导致两个泡泡叠在一起颜色发黑。我的习惯参数是渲染队列放在TransparentRenderType设置成TransparentZWrite关掉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 { QueueTransparent RenderTypeTransparent IgnoreProjectorTrue } 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改成TransparentBlend选择AlphaRender Face选择BothDepth 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.9Value给0.8到1.0Hue就用噪声输出。最后乘上菲涅尔和透明度连到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改成TransformObjectToHClipUnityObjectToWorldNormal改成TransformObjectToWorldNormalWorldSpaceViewDir可以用GetWorldSpaceNormalizeViewDir。如果不想迁移也可以继续用兼容模式但性能和维护性都不好。我的建议是新项目直接用URP方案老项目如果只是临时做一个气泡效果那就继续用内置管线的Shader别为了它去迁移整个项目。最后再分享一个我踩过的小坑有一次我做一个展示用的泡泡特效怎么调都不够通透后来发现是模型本身的UV拉伸太严重导致厚度场的条纹扭曲成了大大的漩涡。我花了一小时去调Shader参数最后把资产检查了一遍原来是美术导出的球体UV scale有问题。所以当你发现Shader逻辑没问题但效果很怪的时候先看一眼模型网格和UV很多时候问题不在代码里。后来我干脆在Shader里改用世界坐标作为厚度场的输入绕开UV不一致的问题效果反而稳定很多。你可以把这个技巧当作“彩虹泡泡Shader”的最后一个扩展点在片元里用世界坐标的Y轴和X轴叠加生成厚度场UV只用来做低频率扰动这样即便模型UV不规范也能得到平滑的彩虹流动感。