ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity Shader实战:彩虹泡泡特效中的菲涅尔与HSV色相应用

Unity Shader实战:彩虹泡泡特效中的菲涅尔与HSV色相应用 这个彩虹泡泡着色器乍看是个花哨的小玩意儿但真把它拆开揉碎里面全是Shader入门到进阶最值钱的那几个知识点颜色模型的灵活运用、视角相关的边缘光计算、透明渲染的顺序陷阱还有跟粒子系统配合时的各种坑。我最初做这个效果是因为项目里需要一种“梦幻但不要太幼稚”的技能反馈特效翻遍Asset Store也没找到合适的索性自己写了一个。做完之后发现这个Shader的扩展性极强稍微改几个参数就能变成能量护盾、魔法结界、甚至水面上的油膜效果。这篇文章就把我的完整实现思路、关键代码、以及调参过程中踩过的坑一次说清楚。1. 效果拆解与核心设计思路1.1 一个看似简单的泡泡难点到底在哪先说说泡泡这东西为什么难画。现实里的肥皂泡本质上是一个极薄的球面水膜光打上去之后一部分在膜的外表面反射一部分穿透进去又在内表面反射两束光叠在一起发生干涉就形成了那种流动的七彩颜色。所以泡泡的视觉特征有三个边缘特别亮、颜色是渐变的彩虹而非单一颜色、颜色会随着观察角度和泡泡形状不断变化。很多新手做彩虹效果第一反应是贴一张彩虹渐变图或者用UV坐标去采样颜色。但这两种方式做出来的东西颜色是“死”的不会随着视角动看起来就像在球上贴了张塑料纸。我这里选择用“色相偏移”的思路让颜色跟着视角和法线走动态感一下子就出来了。具体做法一句话版本把法线方向和视线方向的夹角映射成颜色夹角不同色相就不同。1.2 为什么选HSV色相环作为颜色基础彩虹的七个颜色在RGB空间里其实很难用一个公式优雅地表达出来因为RGB是给机器看的不是给人看的。但HSV色相环就不一样H色相从0度转到360度正好就是红橙黄绿青蓝紫的渐变。所以在Shader里做彩虹效果最聪明的做法是用HSV生成颜色再转回RGB给屏幕代码只改一个H值就够了。这里贴一段我一直在用的HSV转RGB函数网上版本很多但下面这个是我实测下来在Unity里表现最稳定、没有色阶断裂的fixed3 hsv2rgb(fixed3 c) { fixed4 K fixed4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0); fixed3 p abs(frac(c.xxx K.xyz) * 6.0 - K.www); return c.z * lerp(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y); }这段代码的原理其实是把色相环切成六段每段60度RGB三个通道分别做线性插值。看不懂也没关系你只需要知道把x设为0到1之间的值y设为1z设为1就能得到一整圈饱和度亮度都拉满的彩虹色。1.3 整体方案选型Surface Shader还是Unlit Shader在动手写代码之前得先定个大方向用Surface Shader还是Unlit Shader。我的选择是Surface Shader主要有三个原因第一泡泡不应该完全不受光照影响。虽然彩虹色是主要视觉元素但一个纯自发光的泡泡放在场景里会显得很假尤其是当场景里有强方向光的时候泡泡的暗部应该有微微的明暗变化。第二Surface Shader的IN.viewDir等内置数据用起来太方便了省去手动从WorldSpaceViewDir计算的麻烦。第三URP环境下Surface Shader虽然有些限制但做这种非写实风格效果完全够用。当然如果你追求的是极致的性能或者想把这个Shader用在移动端低端机上那Unlit版本会更友好。我的建议是先看完这篇文章里的Surface Shader实现理解了原理之后再改成Unlit就是十分钟的事。2. 彩虹效果算法让颜色“活”起来的关键2.1 色相映射的核心公式泡泡上每个点的颜色应该由什么决定我最初试过用UV.x也就是贴图的横坐标效果是从左到右一条固定的彩虹带虽然也算彩虹但泡泡一转就露馅了因为颜色不跟着转动变化。后来我换成了“世界法线方向到视角方向”的映射这才算真正把泡泡做活了。核心公式是这样的先算出一个hue值它由两部分组成一部分是时间让颜色自己流动一部分是“法线和视线的夹角关系”让颜色随视角变化。float fresnel 1.0 - saturate(dot(normalize(IN.viewDir), o.Normal)); float hue _Time.y * _HueSpeed fresnel * _HueRange;这个fresnel值的物理含义很直观当你看泡泡正中心的时候视线和法线基本平行dot结果接近1fresnel就接近0当你看泡泡边缘的时候视线和法线几乎垂直dot结果接近0fresnel就接近1。所以边缘的色相和中心的色相天然就被拉开了颜色从中心到边缘形成一个动态渐变。2.2 为什么菲涅尔是泡泡的灵魂参数菲涅尔效应Fresnel Effect在图形学里是个老熟人它描述的是这样一个物理现象光斜着照到透明物体表面时反射率会比垂直照射时高很多。这就是为什么你在湖边看水面脚边很清澈能看到水底远处却像镜子一样反射天空。把菲涅尔用在泡泡上效果极其出彩。因为泡泡是球体边缘天然就是“视线擦过”的区域菲涅尔值最高。所以泡泡的边缘最亮、颜色最饱和中间反而透出背景颜色这正好符合真实泡泡的观感。实现上我用的是经验模型没有上精确的Schlick近似因为泡泡本身是风格化效果精确物理反而显得没那么重要。看代码float fresnelAmount pow(fresnel, _FresnelPower); fixed3 rainbowColor hsv2rgb(fixed3(hue, _Saturation, 1.0));_FresnelPower控制的是色相从中心到边缘变化的速度数值越小颜色变化越“陡”边缘和中心的色差越大数值越大整片颜色越均匀。我通常把它设在1.5到2.5之间。2.3 时间驱动的动态流动效果光随视角变化还不够泡泡上的彩虹应该是“活”的要像真的肥皂泡那样颜色在表面缓缓流动。这一步特别简单就是给hue加上一个时间项也就是前面代码里的_Time.y * _HueSpeed。_Time.y是Unity内置的时间变量单位是秒。_HueSpeed我一般设在0.1到0.3之间这个数值代表每秒钟色相环转过的比例。太快了会让人眼花缭乱太慢了又看不出流动感。如果你希望泡泡表面不同位置的流动速度不一样还可以在hue上叠加一个噪声项用tex2D(_NoiseTex, IN.uv_MainTex * _NoiseScale _Time.x * _NoiseSpeed).r来实现效果会更自然。这里有个小知识点之所以要控制流动速度在一个很低的范围是因为人眼对色相变化的感知不是线性的0.1的色相偏移在视觉上已经是很明显的变化了。我第一次调参的时候一上来就设了0.6结果泡泡像个迪斯科灯球晃得我自己都头晕。3. 泡泡质感营造透明、折射与高光的处理3.1 透明渲染的Queue和Blend设置做泡泡绕不开透明渲染而透明渲染在Unity里是个大坑。如果你只是把RenderType改成Transparent把Blend设成SrcAlpha OneMinusSrcAlpha渲染顺序大概率是错的泡泡会被挡住或者挡错东西。我的经验是泡泡必须放在半透明队列的最前面渲染所以Queue要写成Transparent50甚至更高。这是因为泡泡的渲染状态比较特殊它需要先渲染场景里所有不透明物体再渲染其他半透明物体最后渲染自己才能正确透出后面的内容。如果跟其他半透明物体混在一起两个透明面片会互相穿插出现那种非常难看的“你挡我一下我挡你一下”的闪烁。Blend模式这里我推荐Blend SrcAlpha One也就是加法混合。加法混合会忽略透明度直接叠加颜色亮的地方会更亮特别适合泡泡这种自带光源的视觉效果。缺点是暗部颜色会叠加变亮所以我在片元输出颜色之前会对最终颜色做一次乘以亮度的处理保证叠加之后不会一片惨白。3.2 假折射的实现技巧真实的折射需要把场景渲染到一张纹理上再根据法线偏移去采样这就是所谓的GrabPass方案。但GrabPass性能开销不小而且效果也没有想象中惊艳。所以我在这里用了“假折射”不做真正的采样而是直接偏移背景色模拟出“透过泡泡看到的东西扭曲了”的错觉。具体做法是在片元着色器里对UV做一个扰动float2 uvOffset o.Normal.xy * _RefractStrength; fixed3 bgColor tex2D(_MainTex, IN.uv_MainTex uvOffset);在粒子系统里_MainTex就是粒子用的贴图可以是背景图也可以是噪声纹理。这个方案的核心思想是我们不需要真的扭曲后面的世界只需要让泡泡表面的颜色有不规则的变化大脑就会自动脑补出折射的效果。3.3 高光点缀让泡泡“嘭”地一下弹出来泡泡如果没有高光就像一只没有瞳孔的卡通眼睛怎么看都缺了点灵魂。高光其实非常好加在片元里算一个Blinn-Phong高光就行关键是数值要精准。我用的方法是float3 halfDir normalize(lightDir viewDir); float spec pow(saturate(dot(normalize(o.Normal), halfDir)), _GlossPower); o.Albedo spec * _SpecColor;_GlossPower控制高光的大小数值越大高光点越小越集中我通常设在60到100之间。_SpecColor控制高光的颜色这里是泡泡就别用纯白了用一个淡淡的青蓝色比如RGB 0.7, 0.85, 1.0会让高光跟彩虹色更融合。另外一个细节是高光应该在泡沫的边缘附近更明显所以可以在高光计算时叠加菲涅尔系数spec * (1.0 - fresnel)这样泡泡边缘会有更明显的“光环”效果中心反而比较干净。这一步会让泡泡的立体感飙升一个档次。4. 完整Shader实现与参数讲解4.1 关键代码逐段解读说了这么多原理现在把完整的Shader贴出来。这个版本是我在URP环境下测过的但Surface Shader的写法在Built-in管线也一样能用大家按需取用。Shader Custom/RainbowBubble { Properties { _Tint (Tint Color, Color) (0.5, 0.5, 1.0, 1.0) _HueSpeed (Hue Flow Speed, Range(0.0, 0.5)) 0.15 _HueRange (Hue Range, Range(0.0, 2.0)) 1.0 _Saturation (Saturation, Range(0.0, 1.0)) 0.8 _FresnelPower (Fresnel Power, Range(0.5, 4.0)) 2.0 _RefractStrength (Refract Strength, Range(0.0, 0.1)) 0.02 _GlossPower (Gloss Power, Range(10.0, 200.0)) 80.0 _SpecColor (Specular Color, Color) (0.7, 0.85, 1.0, 1.0) [HideInInspector] _MainTex (Background Tex, 2D) white {} } SubShader { Tags { QueueTransparent50 RenderTypeTransparent IgnoreProjectorTrue } Pass { ZWrite On Blend SrcAlpha One Cull Off CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float3 normal : NORMAL; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; float3 worldNormal : TEXCOORD1; float3 viewDir : TEXCOORD2; }; sampler2D _MainTex; float4 _Tint; float _HueSpeed; float _HueRange; float _Saturation; float _FresnelPower; float _RefractStrength; float _GlossPower; fixed4 _SpecColor; fixed3 hsv2rgb(fixed3 c) { fixed4 K fixed4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0); fixed3 p abs(frac(c.xxx K.xyz) * 6.0 - K.www); return c.z * lerp(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y); } v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv v.uv; o.worldNormal UnityObjectToWorldNormal(v.normal); o.viewDir normalize(WorldSpaceViewDir(v.vertex)); return o; } fixed4 frag (v2f i) : SV_Target { float3 N normalize(i.worldNormal); float3 V normalize(i.viewDir); float fresnel 1.0 - saturate(dot(N, V)); float hue _Time.y * _HueSpeed fresnel * _HueRange; fixed3 rainbow hsv2rgb(fixed3(hue, _Saturation, 1.0)); float2 uvOffset N.xy * _RefractStrength; fixed4 bg tex2D(_MainTex, i.uv uvOffset); float3 lightDir normalize(_WorldSpaceLightPos0.xyz); float3 halfDir normalize(lightDir V); float spec pow(saturate(dot(N, halfDir)), _GlossPower); spec * (1.0 - fresnel * 0.5); fixed3 col rainbow * (1.0 - fresnel * 0.6) spec * _SpecColor.rgb; col * _Tint.rgb; return fixed4(col, 1.0); } ENDCG } } FallBack Diffuse }注意看几个关键点。第一Queue写在SubShader的Tags里不是Pass的Tags里。第二Blend用了One所以最后返回的Alpha其实无意义直接写1.0就行。第三Cull Off非常重要泡泡从背面看也是能看到的如果不开双面渲染粒子转到背后就消失了。4.2 参数面板调优说明这个Shader暴露了7个可调参数每个都有明确作用。我把它们整理成一张表方便大家在项目里照着调参数名作用推荐值说明_HueSpeed彩虹流动速度0.1~0.2数值越小越柔和适合氛围效果_HueRange色相环覆盖范围0.8~1.2当数值大于1时同一片区域会出现多轮彩虹_Saturation颜色饱和度0.6~0.9别拉满拉满会高饱和到刺眼_FresnelPower边缘色相变化速度1.8~2.5数值越大边缘颜色越鲜艳_RefractStrength背景扭曲强度0.01~0.05数值太大会有严重视觉噪声_GlossPower高光集中度60~100数值越大高光点越小_SpecColor高光颜色淡蓝/淡青别用纯白容易显得廉价さらに補足一点_HueRange参数如果设置为1.0那么从泡泡中心到边缘色相刚好转过完整的一圈。设置为2.0就是两圈边缘会有好几道彩虹。数值越大视觉上越“华丽”但也越容易让玩家觉得眩晕常规效果不建议超过1.5。4.3 从Surface Shader迁移到Unlit的思路如果你用的是URP管线又不想依赖灯光或者想要更硬核的控制力可以把这个Shader改成Unlit版本。改动核心就一处不需要再关心_WorldSpaceLightPos0把高光计算部分的lightDir固定成某个方向比如摄像机右上方45度或者干脆不要灯光只保留菲涅尔和彩虹色。这样改的好处是性能更好Shader跑起来没有任何光照开销特别适合移动端大批量粒子。坏处是泡泡缺少了明暗变化立体感弱一些。我的建议是如果是全屏特效或者背景氛围Unlit完全够用如果是玩家角色身上的护盾或者技能特效保留光照会更有质感。5. 粒子系统与Shader的配合实战5.1 粒子渲染模式的选择与坑Shader写好了还得有地方用。这个彩虹泡泡最自然的载体就是粒子系统。我见过很多人在这步翻车Shader没问题但粒子的Render Mode选得不对出来的效果跟编辑器预览完全是两回事。粒子系统Render Mode有三种Billboard、Mesh和Stretched Billboard。彩虹泡泡Shader是用模型空间的法线来计算菲涅尔和高光的所以必须用Mesh模式并且给粒子系统指定一个球体Sphere作为渲染网格。如果用默认的Billboard模式粒子是一张平面的四边形法线全是片元面对你的方向菲涅尔值永远为0彩虹根本出不来只会是一个没有变化的彩色方块。这里我踩过一次特别深的坑我在Mesh模式下给粒子指定了Unity内置的Sphere网格结果怎么调都感觉泡泡的边缘高光位置不对。后来检查发现粒子系统Praticle System里的Render Alignment默认是View会把球体自动对齐到摄像机方向导致球体被压扁了。需要改成World模式让球体保持三维形态。5.2 粒子参数配置的最佳实践粒子系统的参数配置直接决定泡泡最终在画面里的观感这里分享一份我实测过多次的组合方案Start Size0.2到1.5之间随机化区间拉大大泡泡小泡泡混着来层次感会强很多Start Color白色即可颜色交给Shader里的Hue去自由发挥。如果你在这里叠加了颜色会污染后面的色相计算导致颜色发灰Start Lifetime2到4秒太长会显得拖沓太短来不及展示流动的彩虹Gravity Modifier0。泡泡应该是缓慢漂浮的不要受到重力影响Emission Rate每秒钟生成1到3个粒子就够了数量多反而会让屏幕变得吵闹另一个关键点是粒子的碰撞行为。如果开启了碰撞检测泡泡会跟地面和其他物体碰撞模拟真实泡泡在空中飘然后破裂的效果。但碰撞检测性能开销不小而且粒子数量一多碰撞计算量就上来了。我的做法是近景精华的泡泡开启碰撞远景氛围的泡泡把碰撞关掉纯飘动画就能很好地糊弄过去。5.3 摄像机与场景环境设置建议最后简单说一下场景设置。由于我们的泡泡Shader用了加法混合后景色越亮泡泡就会越亮越透明。所以场景背面一定不能放高亮的大面积光源否则泡泡会变成一片白雾。最好把泡泡用在中等亮度的环境里背后有一片渐变色或者中等明度纹理的地方效果最出彩。摄像机这边有个小技巧关掉Directional Light的阴影投射。泡泡是半透明物体本身不投影也不需要接收阴影但灯光开启阴影渲染后会在计算_WorldSpaceLightPos0的时候产生阴影遮罩导致泡泡局部变暗出现很难看的黑斑。这个问题排查了我整整半小时才定位到。6. 常见问题排查与调优实录6.1 为什么泡泡看起来像一团高亮颜料这是最常见的问题基本可以断定是颜色混合模式的问题。加法混合Blend One One会把所有颜色叠在一起如果你在粒子系统里又叠了贴图的颜色两个颜色相加会变得刺眼的亮。排查顺序第一步把粒子的Start Color改成纯白排除贴图颜色干扰第二步检查Shader里是否对最终颜色做了col * _Tint.rgb如果Tint值设置得太大比如超过1颜色就会过曝第三步确认_Saturation没有设到接近1。如果还是亮得不行可以尝试从加法混合切到正常的透明度混合Blend SrcAlpha OneMinusSrcAlpha代价是彩虹的颜色会暗一些但更柔和耐看。6.2 彩虹颜色断层能看到明显的色带这个问题的根源大多在于hsv2rgb函数里的frac操作。frac会把数值整数部分丢掉只留小数如果H值跨过了整数边界颜色就会瞬间跳变而不是平滑过渡。解决办法把hue值用smoothstep或者smoothstep之后再传入hsv2rgbhue smoothstep(0.0, 1.0, frac(hue));这样处理之后色相过渡会变得丝滑很多几乎看不到任何色阶断层。如果还嫌不够顺滑可以把Shader里计算出来的saturation值从0.8降到0.7视觉上色带感会进一步减轻。6.3 泡泡被场景里的其他透明物体挡住这个问题特别隐蔽。虽然在Shader里把Queue设成了Transparent50但如果场景里还有其他Queue更高的透明物体它们照样会挡在泡泡前面。另外如果这些其他透明物体是ZWrite On的状态它们写入深度缓冲后泡泡的ZTest LEqual会被失败直接被裁掉。排查技巧在Frame Debugger里看Draw Call的执行顺序确认泡泡是不是真的排在场景透明物体之后。如果顺序反了看下其他透明材质的Queue值有没有设置异常的高。6.4 性能优化如何支撑上百颗泡泡同时存在如果你想让屏幕上有几十上百颗泡泡性能就是一个绕不开的坎。我的优化顺序是这样的第一个优化点尽量用一张小小的噪声纹理做背景扭曲而不是用全屏背景纹理。UV采样范围小纹理缓存命中率高会快很多。第二个优化点把_HueRange和_FresnelPower这些需要逐像素运算的参数能挪到顶点着色器计算的就挪到顶点着色器避免片元着色器里做多余运算。第三个优化点如果摄像头离泡泡很近贴图分辨率反而不用太高因为半透明物体叠加之后细节本来就看不清楚用128x128的贴图在屏幕上几乎分不出跟512x512的差别。我在测试机上试过以上优化做完之后同一台手机上粒子数量从30颗提升到了80颗还能稳住60帧收益很可观。结语这个效果之外还能怎么玩Shader写完之后我顺手给它加了两个变体这里也分享给大家作为扩展思路。第一个变体是把_HueRange改成负数颜色流动方向会反转变成从边缘向中心聚拢的效果用来做“吸收”类技能的特效非常合适。第二个变体是叠加一个噪音值到hue上同样是彩虹泡泡但表面会出现不规则的色斑像是被污染的气泡适合做毒素或者诅咒类效果。对我来说彩虹泡泡最让我惊喜的地方是它把Shader学习里最抽象的菲涅尔效应和颜色模型用最直观的方式呈现了出来。写完这个Shader再去理解水面、镜面、皮肤、车漆这些更复杂的渲染效果思路就清晰多了。希望这篇文章能帮你绕过我踩过的坑也期待看到你做出更有意思的变体。
返回列表