Unity Shader实现彩虹泡泡:薄膜干涉与透明材质渲染技术详解
1. 项目概述从“好看”到“好实现”的彩虹泡泡最近在做一个偏休闲风格的3D项目里面需要一些梦幻、卡通的元素来点缀场景。策划同学丢过来一张参考图上面是漂浮的、表面流转着彩虹光泽的透明泡泡问我“这个用Unity的Shader能搞吗要那种五彩斑斓但又很通透的感觉。”我一看这需求挺典型就是基于物理的光学效果加上一点艺术化的色彩处理。一个“彩虹泡泡”着色器核心在于模拟两层关键现象一是肥皂泡薄膜的薄膜干涉产生的彩虹色二是球体表面的高光反射与透射带来的通透质感。这不仅仅是贴一张彩虹贴图那么简单它涉及到光线与透明薄膜交互的物理模拟以及如何在Shader中高效且美观地实现它。这个Shader非常适合用来深入学习Unity Shader Graph或者手写HLSL中关于光线、法线、菲涅尔效应和颜色混合的高级技巧。无论你是想为你的独立游戏增添一抹亮色还是希望深入理解基于物理的渲染PBR在特殊材质上的应用这个案例都能提供一条清晰的路径。接下来我会拆解整个实现思路、核心节点网络并分享在调试过程中遇到的坑和解决方案。2. 核心原理拆解泡泡的彩虹从哪里来在动手写代码或连节点之前我们必须搞清楚要模拟的物理现象是什么。一个真实的肥皂泡之所以呈现彩虹色主要原因是一种叫薄膜干涉的光学现象。2.1 薄膜干涉的简化模型泡泡壁是一层极薄的水膜。当光线照射到薄膜上时一部分在薄膜上表面直接反射另一部分进入薄膜在下表面反射后再从上表面射出。这两束反射光相遇时会因为光程差发生干涉。光程差取决于薄膜厚度和光线入射角。对于特定波长的光当光程差满足相长干涉条件时该颜色的光就会被强化满足相消干涉条件时该颜色光就会被减弱。由于泡泡表面的厚度并不均匀通常顶部最薄底部因重力稍厚且曲率导致入射角连续变化我们就会看到连续变化的彩虹色条纹。在Shader中我们无法实时精确计算这种复杂的物理干涉但可以用一个经典的、艺术化的近似公式来模拟其视觉效果RainbowColor tex2D(_RainbowGradient, float2(fresnel, thickness))这里fresnel菲涅尔因子模拟了因视角变化导致的颜色强度变化thickness厚度因子模拟了泡泡表面不同区域的厚度变化。我们用一个彩虹渐变纹理来查询最终颜色。2.2 泡泡质感的三要素除了彩虹色一个可信的泡泡还需要以下质感高透明度与透射泡泡是高度透明的我们能透过它看到背后的景物并且光线穿过泡泡时会发生折射。明亮的高光泡泡表面光滑会有非常锐利、明亮的高光点这是菲涅尔效应在掠射角视线几乎与表面平行时反射率接近100%的表现。动态与扭曲真实的泡泡表面会因气流轻微波动彩虹色也会缓缓流动。我们的Shader将围绕这三个要素构建。在资源使用上我们优先保证效果采用一张小尺寸如256x1或512x1的一维彩虹渐变图作为核心数据源配合屏幕空间折射和顶点动画来实现动态效果确保在移动端也能有不错的性能表现。3. 使用Shader Graph构建彩虹泡泡网络我强烈推荐使用Shader Graph进行原型开发和效果调试它直观的节点化界面能让我们更专注于视觉效果逻辑。以下是构建该着色器的核心步骤。3.1 基础设置与属性定义首先创建一个新的Unlit Graph因为我们要自己控制所有光照模型或PBR Graph如果想更好地与环境光融合。关键属性Properties如下_BaseColor(Color)泡泡的基础色调通常设置为非常浅的青色或粉色用于混合。_RainbowGradient(Texture2D)一维彩虹渐变纹理。这里有个关键技巧在导入设置中将其Wrap Mode设置为Repeat这样我们才能让彩虹色无缝循环流动。_RainbowSpeed(Vector1)控制彩虹色在表面流动的速度。_RainbowIntensity(Vector1)控制彩虹色的显示强度。_ThicknessNoise(Texture2D)一张噪波图用于模拟泡泡表面不均匀的厚度。使用3D噪波纹理如Simple Noise效果更佳。_ThicknessScale(Vector1)厚度噪波的缩放系数值越大厚度变化越“粗糙”。_ThicknessSpeed(Vector1)厚度噪波流动的速度模拟泡泡表面物质的缓慢流动。_Smoothness(Vector1)控制高光的锐利程度镜面反射的粗糙度。_FresnelPower(Vector1)菲涅尔效应的强度。值越大边缘掠射角的反射/彩虹色越强中心越弱。_RefractionStrength(Vector1)折射扭曲的强度。3.2 构建彩虹色核心网络这是最核心的部分我们需要计算一个UV用于从_RainbowGradient中采样颜色。计算菲涅尔因子使用Fresnel Effect节点。将Normal世界空间或视图空间法线和View Direction输入并通过_FresnelPower属性控制其指数。输出一个在表面中心接近0、在边缘接近1的值。计算厚度因子使用Time节点乘以_ThicknessSpeed得到流动的时间因子。将模型的世界位置Position节点选择World空间的XY或XZ分量取决于你希望流动的方向乘以_ThicknessScale然后加上时间因子构建出UV。用这个UV去采样_ThicknessNoise纹理得到一个0-1的随机值作为基础厚度。为了模拟泡泡顶部薄、底部厚可以加上一个基于世界空间或物体空间Position.y的渐变使用Remap节点将其混合进厚度因子。合成彩虹UV将计算好的菲涅尔因子作为U坐标厚度因子作为V坐标组合成一个二维向量。注意我们的彩虹纹理是一维的高度为1所以V坐标始终可以设为0或者用厚度因子来轻微影响彩虹的“波段”位置实现更复杂的变化。更常见的做法是RainbowUV float2(fresnel time * _RainbowSpeed, thickness)。这样彩虹色会随着时间沿菲涅尔方向流动。采样彩虹颜色用上一步合成的UV对_RainbowGradient进行采样。然后使用_RainbowIntensity来控制其与基础色的混合强度。通常使用Lerp线性插值节点在_BaseColor和采样到的RainbowColor之间进行混合。注意菲涅尔因子的计算非常关键。直接使用Fresnel Effect节点可能强度不够。我常用的一个强化公式是float fresnel pow(1.0 - saturate(dot(normal, viewDir)), _FresnelPower);。在Shader Graph中你可以用Dot Product节点和Power节点自己搭建这样控制更精细。3.3 实现透明度、折射与高光透明度将材质的Surface Type设置为TransparentBlend Mode设置为Alpha普通透明或Premultiply对于发光效果更好。我们最终输出的Alpha通道可以是一个固定值如0.3也可以由菲涅尔因子控制边缘更不透明。屏幕空间折射使用Scene Color节点获取背景颜色。使用Normal节点建议使用切线空间法线扰动效果更自然乘以_RefractionStrength得到一个偏移向量。使用Screen Position节点选择Raw模式的XY分量加上这个偏移向量构建出扭曲后的屏幕UV。用这个扭曲后的UV去采样Scene Color得到折射后的背景色。将折射背景色与前面计算出的泡泡颜色彩虹色基础色进行混合。混合方式可以是简单的Lerp也可以使用菲涅尔因子作为权重float3 finalColor lerp(refractionColor, bubbleColor, fresnel);。高光泡泡的高光非常锐利。我们可以使用一个简单的Blinn-Phong模型或直接使用Specular相关节点。计算高光强度float spec pow(saturate(dot(normalize(viewDir lightDir), normal)), _Gloss * 128.0);。其中_Gloss对应_Smoothness。高光颜色通常为纯白色。将高光强度乘以一个系数后直接加到最终颜色上Add操作。注意高光部分通常不需要参与透明度混合它应该是纯粹的加法。3.4 最终合成与输出将上述所有部分连接起来Base Color端口连接最终合成后的颜色折射背景色、泡泡体色、高光的混合结果。Alpha端口连接计算好的透明度值。Smoothness端口连接_Smoothness属性影响环境反射的清晰度如果使用PBR Graph。Normal端口可以连接一个轻微扰动的法线由厚度噪波生成让表面有细微的凹凸感增强质感。至此一个基础的彩虹泡泡Shader Graph就搭建完成了。你可以通过调整属性面板上的参数实时观察泡泡效果的变化。4. 代码实现要点与HLSL核心函数对于追求更高性能或更特殊效果的开发者手写HLSL代码是必经之路。下面给出一个URPUniversal Render Pipeline下片段着色器Fragment Shader的核心函数逻辑。// 在Properties中定义属性与Shader Graph中对应 _RainbowGradient(“Rainbow Map”, 2D) “white” {} _ThicknessNoise(“Thickness Noise”, 2D) “gray” {} // ... 其他属性 TEXTURE2D(_RainbowGradient); SAMPLER(sampler_RainbowGradient); TEXTURE2D(_ThicknessNoise); SAMPLER(sampler_ThicknessNoise); // ... 其他变量声明 void RainbowBubble_half( float3 normalWS, float3 viewDirWS, float3 positionWS, float2 uv, float4 baseColor, float rainbowSpeed, float rainbowIntensity, float thicknessScale, float thicknessSpeed, float fresnelPower, float gloss, out float3 outColor, out float outAlpha) { // 1. 计算菲涅尔因子 float NdotV saturate(dot(normalize(normalWS), normalize(viewDirWS))); float fresnel pow(1.0 - NdotV, fresnelPower); // 2. 计算厚度因子使用世界位置和噪波图 float2 thicknessUV positionWS.xz * thicknessScale _Time.y * thicknessSpeed; float thickness SAMPLE_TEXTURE2D(_ThicknessNoise, sampler_ThicknessNoise, thicknessUV).r; // 叠加一个从顶到底的厚度变化 thickness positionWS.y * 0.1; // 3. 采样彩虹颜色 float2 rainbowUV float2(fresnel _Time.y * rainbowSpeed, thickness); float3 rainbowColor SAMPLE_TEXTURE2D(_RainbowGradient, sampler_RainbowGradient, rainbowUV).rgb; // 4. 混合基础色与彩虹色 float3 bubbleColor lerp(baseColor.rgb, rainbowColor, rainbowIntensity); // 5. 计算高光 (简化的Blinn-Phong) float3 lightDir _MainLightPosition.xyz; // 假设主光方向 float3 halfDir normalize(viewDirWS lightDir); float NdotH saturate(dot(normalize(normalWS), halfDir)); float specular pow(NdotH, gloss * 128.0) * 0.5; // 6. 输出 outColor bubbleColor specular.xxx; // 加上高光 outAlpha baseColor.a * (1 - fresnel * 0.5); // 边缘透明度更高 }这个自定义函数可以在Shader Graph中通过Custom Function节点调用也可以在URP的Unlit或Lit Shader中直接嵌入实现更复杂的控制逻辑。5. 效果优化与性能调优实战一个好看的Shader还需要在性能上过关尤其是在移动平台或需要大量实例化的场景中。5.1 纹理与计算优化彩虹纹理务必使用一维纹理如128x1或256x1并启用Mipmaps。在采样时确保UV的tiling和offset设置正确避免不必要的纹理寻址开销。可以考虑将彩虹梯度编码到一张很小的RGBA32格式的纹理中甚至用数学函数如正弦波组合来实时生成彩虹色彻底省去纹理采样但这会牺牲一些艺术可控性。噪波纹理对于厚度噪波如果使用纹理同样要小尺寸如64x64。更好的选择是使用程序化3D噪波函数如Perlin Noise或Simplex Noise它只需要世界坐标和时间输入无需纹理采样性能更高且无缝循环效果更好。Unity的Shader Graph提供了Simple Noise节点就是很好的选择。菲涅尔计算pow运算相对昂贵。对于移动端如果效果要求不高可以用1.0 - NdotV的线性或二次方来近似或者使用一个Smoothstep函数来模拟。折射效果屏幕空间折射Scene Color是一个相对昂贵的操作因为它需要额外的屏幕纹理采样。在移动端如果性能吃紧可以考虑降级方案用环境立方体贴图Cubemap反射来模拟。虽然物理上不准确折射和反射是不同现象但一个模糊的、带有彩虹色的环境反射同样能营造出泡泡的通透感和光泽感且性能代价小很多。5.2 渲染状态与混合模式设置正确的渲染状态是透明效果的基础。// 在SubShader或Pass中设置 Tags { “Queue” “Transparent” “RenderType” “Transparent” “IgnoreProjector” “True” } Blend SrcAlpha OneMinusSrcAlpha // 标准Alpha混合 ZWrite Off // 关闭深度写入防止透明物体相互遮挡出错 Cull Off // 双面渲染因为泡泡内外都能看到深度写入ZWrite Off必须关闭。如果开启先渲染的泡泡会挡住后面的泡泡即使它本身是透明的这会导致渲染顺序错乱。渲染队列Queue设置为Transparent确保它在所有不透明物体之后渲染。剔除Cull设置为Off双面渲染或Back只渲染背面。因为泡泡是极薄的膜从外部看是它的外表面从内部看比如镜头穿进去是内表面双面渲染视觉效果更完整。5.3 动态效果的增强静态的彩虹泡泡已经不错但加上动态效果会大大提升真实感。表面流动我们已经通过_Time变量让彩虹和厚度噪波流动起来了。调整_RainbowSpeed和_ThicknessSpeed可以控制流动快慢。可以让这两个速度略有不同产生更自然、不规则的动态。顶点动画模拟晃动在顶点着色器中根据世界位置采样一个低频噪波随时间变化轻微地偏移顶点位置沿法线方向。这能模拟泡泡在空气中轻微晃动的感觉。注意幅度一定要小否则会破坏球形。// 顶点着色器示例 v.vertex.xyz v.normal * (sin(_Time.w positionWS.x * 5.0) * 0.01);交互反馈可以通过脚本将外部影响如风力、玩家靠近传递到Shader。例如定义一个_WindVector在厚度计算UV中加上这个向量就能让彩虹色的流动受风影响。6. 常见问题排查与调试心得在开发过程中我遇到了不少问题这里总结一下希望能帮你避坑。6.1 彩虹色不显示或显示错误检查纹理导入设置确保_RainbowGradient的Wrap Mode是RepeatFilter Mode是Bilinear或Trilinear。如果是Clamp当UV超过1时颜色会卡在边缘色上。检查UV范围用Debug节点或输出中间值到颜色查看你计算的rainbowUV是否在预期的范围内比如0-1或更大。如果菲涅尔因子始终接近0或1颜色变化范围就会很窄。检查混合强度_RainbowIntensity是否为0彩虹色是否被基础色完全覆盖了6.2 泡泡看起来像实心球不透检查Alpha通道和混合模式确保最终输出的Alpha值小于1并且材质的Blend模式设置正确。在Scene视图下开启Alpha Channel显示看看透明度信息是否正确。检查折射效果如果折射强度_RefractionStrength为0或者Scene Color节点没有正确连接泡泡就会缺少“透过它看东西”的扭曲感显得像实心彩色玻璃。菲涅尔效应太弱泡泡的透明度和颜色强度应该随着视角变化。中心看几乎全透边缘看色彩浓烈且稍显不透明。加大_FresnelPower值强化边缘效果。6.3 性能问题帧率下降Profile工具定位使用Unity的Profiler查看GPU时间。重点关注RenderTexture相关的操作特别是使用了Scene Color时和复杂的片段着色器计算。简化方案如前所述尝试用Cubemap反射替代屏幕空间折射降低噪波纹理分辨率或改用程序化噪波简化菲涅尔和高光计算模型。批处理中断确保使用该Shader的材质球尽可能少并且模型的Scale不要有负值会导致翻转法线破坏动态批处理。对于大量泡泡考虑使用GPU Instancing并在Shader中支持INSTANCING_ON。6.4 在移动设备上效果差或闪烁精度问题移动端GPU尤其是低端浮点精度较低。避免在片段着色器中进行大量pow、sin等高开销运算。将一些计算移到顶点着色器通过插值传递到片段着色器。抗锯齿透明物体的边缘容易闪烁。确保项目开启了合适的抗锯齿MSAA。在Shader中可以对透明度进行一点dithering抖动处理来平滑边缘。纹理压缩确保所有自定义纹理使用了合适的压缩格式如ASTC减少内存带宽占用。调试Shader是一个“观察-假设-验证”的过程。善用Shader Graph的Master Preview和Blackboard中的调试视图或者手写Shader时将中间变量输出为颜色如return float4(fresnel.xxx, 1.0);是快速定位问题最有效的方法。这个彩虹泡泡着色器从物理原理出发通过艺术化的简化和高效的Shader编程最终实现了既美观又实用的效果。掌握它你就能举一反三创造出更多基于光学原理的特殊材质。