拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Unity Shader流光效果全解析:从UV偏移到性能优化

Unity Shader流光效果全解析:从UV偏移到性能优化

1. 先弄清楚一件事:流光效果到底在算什么东西

做了几年Unity Shader,我越来越觉得很多新手一上来就搜“流光效果Shader代码”,然后把代码一贴,效果出来了,但完全不知道自己在写什么。这样一旦遇到问题,根本无从下手。所以在给代码之前,我想先花点篇幅讲清楚流光的本质。

流光效果,说穿了就是让某张贴图沿着模型表面“动”起来。这个“动”不是模型在动,而是贴图的采样坐标在动。我们在Shader里对纹理采样时,用的是UV坐标——也就是一张贴图上的横纵位置。正常情况下,UV坐标是固定的,所以贴图纹丝不动。但如果你给UV坐标加上一个随时间变化的偏移量,那么同一帧画面里,物体表面上每个片元采样的就不再是贴图原来的位置,而是偏移后的位置。偏移量持续变化,纹理看起来就在流动。

这就好比你在看一扇贴着海报的玻璃窗,玻璃本身没动,但如果海报在窗户后面匀速往左拉,你透过固定位置的窗框看到的图案就会不断变化。UV偏移就是那只“拉海报的手”。

这个思路延伸出去,就派生出了三种最常见的流光实现方式:第一种是直接让整张贴图滚动,适合水流、传送带这类大范围流动;第二种是只让某个高亮区域沿着表面扫过,适合刀剑的剑气光晕、UI按钮的扫光;第三种是叠加多层不同方向、不同速度的流动,做出火焰、能量涌动这种更复杂的视觉效果。这三种方式的核心都是UV随时间偏移,区别只在于“怎么偏移”以及“偏移的结果怎么和原始颜色混合”。

理解了这一点,你再看网上那些流光Shader代码,会发现骨架其实高度相似:一个_MainTex主纹理,一个_FlowTex或者直接用主纹理本身,然后一个_FlowSpeed控制速度,frag函数里做一次UV加法,完事。真正的差异和价值,全在混合方式和遮罩处理上。下面我从最简单的情况开始,一层层往上加东西。

2. 第一版:让UV动起来,一个最朴素的滚动流光

我们先从最基础的情况开始。假设你手上有一张带Alpha通道的贴图,比如一件武器的贴图,你想让它表面的纹路整体流动起来。这个版本不需要额外的流光贴图,直接用主纹理的UV做偏移就行。

2.1 逐行拆解基础Shader代码

我直接给出一个可用的Unity Shader,用的表面着色器写法(Surface Shader),因为它对新手最友好,光照、阴影这些由Unity帮我们处理:

Shader "Custom/BasicFlowShader" { Properties { _MainTex ("主纹理", 2D) = "white" {} _FlowSpeed ("流动速度", Vector) = (0.1, 0, 0, 0) _Brightness ("亮度", Float) = 1.0 } SubShader { Tags { "RenderType"="Opaque" } LOD 200 CGPROGRAM #pragma surface surf Standard fullforwardshadows #pragma target 3.0 sampler2D _MainTex; float4 _MainTex_ST; float4 _FlowSpeed; float _Brightness; struct Input { float2 uv_MainTex; }; void surf (Input IN, inout SurfaceOutputStandard o) { // 用时间乘以速度,得到一个持续的偏移量 float2 flowOffset = _FlowSpeed.xy * _Time.y; // 关键一步:采样坐标加上偏移量 float2 flowUV = IN.uv_MainTex + flowOffset; // 用偏移后的UV去采样纹理 fixed4 c = tex2D (_MainTex, flowUV) * _Brightness; o.Albedo = c.rgb; o.Alpha = c.a; } ENDCG } FallBack "Diffuse" }

先看_FlowSpeed这个属性,我特意声明成了Vector类型而不是Float。因为在实际项目里,我们经常需要让纹理沿着不同方向流动,用一个二维向量可以同时控制X和Y两个方向的流速。比如(0.1, 0, 0, 0)表示每秒往右偏移0.1个UV单位,也就是10%的贴图宽度;如果要让流动方向变成斜着的,就填(0.05, 0.05, 0, 0),两个方向同时偏移即可。

_Time.y是Unity内置的时间变量,单位是秒。这里有个新手容易忽略的细节:_Time是float4类型,_Time.x = t / 20,_Time.y = t,_Time.z = t * 2,_Time.w = t * 3。最早的时候有很多教程让人用_Time.x甚至_Time.z,但如果你想让流动速度严格等于属性里填的值,应该用_Time.y,因为只有它才是未经缩放的原始秒数。用_Time.x等于速度被除以20,用_Time.z等于速度直接翻倍。

2.2 为什么这张贴图会“穿帮”,以及Tiling对流速的坑

把这个Shader拖到一个球体上,你会发现贴图整体在滚动,但有两个问题很快就暴露出来:第一,如果贴图不是无缝贴图,滚动到边缘处会有明显的接缝跳变;第二,你可能会发现实际滚动速度和你填的数值对不上。

第二个问题很普遍。看这行代码:

float2 flowUV = IN.uv_MainTex + flowOffset;

这里的IN.uv_MainTex是模型UV坐标,它的范围是0到1。但是Unity材质面板上还有一组Tiling和Offset参数,它们对应的是_MainTex_ST这个变量里存储的缩放和偏移。如果美术把Tiling设成了2,那么实际采样坐标应该是uv * 2 + offset,而你直接用的uv相当于Tiling=1的坐标。同样的速度向量,作用在Tiling=2的贴图上,视觉上的滚动速度会慢一半,因为纹理被放大了一倍,单位时间内滚过去的“图案周期”变少了。

正确的写法应该是在偏移前先应用Tiling:

float2 flowUV = IN.uv_MainTex * _MainTex_ST.xy + _MainTex_ST.zw + flowOffset;

或者更偷懒一点,直接依赖Unity的TRANSFORM_TEX宏,它会自动帮你处理这个事:

float2 flowUV = TRANSFORM_TEX(IN.uv_MainTex, _MainTex) + flowOffset;

这个细节我觉得值得所有做Shader的人记住,因为它隐藏得特别深。整个材质看起来一切正常,就是流速不对,很多人调了半天速度参数都发现没用,其实问题出在Tiling上——你调的每秒钟偏移0.1个UV单位,在Tiling=2的贴图上只偏移了0.05个贴图周期。

再补一个性能相关的点:这个基础版本的采样是tex2D,如果贴图开了Mipmap,远距离观察时GPU会自动选择更小的Mip层级,滚动时会出现纹理“呼吸”或者闪烁。想要避免,可以把采样改成tex2Dlod手动指定Mip层级,或者干脆在贴图导入设置里关掉Mipmap——但关掉Mipmap会牺牲远处的高频细节。实际项目里我一般保留Mipmap,只有在UI上做流光时才关掉,因为UI是正交视角,不存在缩放,Mipmap完全没用还浪费内存。

3. 加一个Mask,让流光只亮相在刀刃上

基础版流动效果很容易被吐槽“太假”,因为整张贴图都在动,看起来像贴图没贴牢、在模型表面滑移。实际游戏里我们看到的那些流光,比如剑刃上的光晕、枪械表面的能量纹路,往往不是整张贴图动,而是某一个局部高亮区域在动。要做到这个效果,核心手段是加入一张Mask贴图(也叫遮罩贴图)。

3.1 Mask的原理:用一张灰度图当“门卫”

你可以把Mask想象成一个过滤器——一张灰度图,白色部分意味着“完全放行”,黑色部分意味着“完全挡住”,灰色则是不同程度地放行。

具体到Shader里,流程变成这样:先用带UV偏移的方式采样一张流光贴图,得到一个亮色值;再采样一张Mask贴图,得到一个遮罩值;最后把两个值相乘。这样一来,流光的亮色只会在Mask是白色的区域显现出来,Mask是黑色的地方,流光就算偏移过去了也被乘以0,完全看不见。

举个最典型的例子。一把剑的贴图,剑刃部分是白色,护手和剑柄部分是黑色,把这当Mask用。那么流光贴图的光晕扫过来时,只有扫到剑刃才会亮起来,扫到剑柄处就直接被掐掉——看起来就像有一道光在剑刃上滑过,而不是整把剑都在发光。

3.2 完整实现:边缘光扫过效果的Shader代码

这里我给出一个完整的、可以在项目里直接用的版本。同时我用的是屏幕空间扫光的思路,让一道光从模型的一侧扫到另一侧,而不是贴图本身自带的光斑:

Shader "Custom/ScanFlowShader" { Properties { _MainTex ("基础贴图", 2D) = "white" {} _MaskTex ("流光遮罩", 2D) = "white" {} _FlowColor ("流光颜色", Color) = (1, 0.8, 0.2, 1) _FlowSpeed ("流光速度", Float) = 1.0 _FlowWidth ("流光宽度", Range(0.01, 1)) = 0.2 _FlowIntensity ("流光强度", Range(0, 5)) = 1.5 } SubShader { Tags { "RenderType"="Transparent" "Queue"="Transparent" } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off CGPROGRAM #pragma surface surf Lambert alpha:fade #pragma target 3.0 sampler2D _MainTex; sampler2D _MaskTex; float4 _MainTex_ST; fixed4 _FlowColor; float _FlowSpeed; float _FlowWidth; float _FlowIntensity; struct Input { float2 uv_MainTex; float2 uv_MaskTex; }; void surf (Input IN, inout SurfaceOutput o) { fixed4 baseTex = tex2D (_MainTex, TRANSFORM_TEX(IN.uv_MainTex, _MainTex)); fixed4 maskTex = tex2D (_MaskTex, IN.uv_MaskTex); // 核心:用UV.x作为扫光的位置基准 // 把UV.x加上时间偏移,再用frac取小数,得到一个0~1循环变化的值 float scanPos = frac(IN.uv_MainTex.x + _Time.y * _FlowSpeed); // 当前片元的UV.x和扫光位置的距离 float dist = abs(IN.uv_MainTex.x - scanPos); // 做一个平滑的脉冲:距离越近越亮,越远越暗 float scanMask = smoothstep(_FlowWidth, 0, dist); // 乘上遮罩贴图,让流光只出现在Mask的白色区域 float finalMask = scanMask * maskTex.r; // 混合流光颜色到基础颜色上 fixed3 finalRGB = baseTex.rgb + _FlowColor.rgb * finalMask * _FlowIntensity; fixed finalAlpha = baseTex.a; o.Albedo = finalRGB; o.Alpha = finalAlpha; } ENDCG } FallBack "Diffuse" }

先解释这个Shader的数学逻辑,因为它和第一个版本有本质区别。第一个版本是“整张纹理滚动”,相当于把贴图当成一条传送带。而这个版本是“一道扫光从左边滑到右边”,核心是scanPos那个值——它是uv.x加时间偏移后取小数,结果始终在0到1之间循环。这个值可以理解成一把“在模型表面水平移动的尺子”,尺子的位置随时间变化。

然后dist = abs(uv.x - scanPos),计算的是当前片元在水平方向离尺子有多远。smoothstep(_FlowWidth, 0, dist)的作用是:当dist等于0时返回1,当dist大于_FlowWidth时返回0,中间是平滑过渡。简单说就是让扫光带有一个柔和的边缘,而不是一把锋利的刀。

最后乘上遮罩值,这样就算扫光的位置在剑柄上,因为Mask那里是黑的,光也透不出来。

3.3 为什么我在这个版本里加了Transparent和ZWrite Off

你肯定注意到了,这个Shader的SubShader标签和第一个版本不一样:RenderType改成了Transparent,Queue改成了Transparent,还加了Blend SrcAlpha OneMinusSrcAlpha和ZWrite Off。

这是为了处理带有透明通道的贴图。武器、角色特效部件这类物体,贴图往往有半透明边缘,如果你还用不透明渲染,半透明的区域会被直接丢弃或者产生黑边。而ZWrite Off是因为半透明物体按从远到近的顺序绘制,如果还写深度缓冲,后面的半透明物体会被前面的挡掉,显示不出来。

不过这里要提醒一句:如果你的流光效果是加在完全实心的模型上,比如一个石头雕塑表面的能量纹路,那这个版本反而是错的——Transparent队列的绘制顺序靠后,在某些角度看会穿到别的物体前面,出现排序错误。这种情况下,应该把Tags改回"RenderType"="Opaque",去掉Blend和ZWrite,然后把流光颜色直接加到Albedo上,走不透明渲染管线。我的经验是:先确认这个物体在游戏里是不是有半透明需求,再决定用哪套渲染设置。Shader里的每一行都不是随手写的,都是有使用场景的。

4. 针对2D Sprite和UI的流光:你可能不需要模型UV

前面两个版本假定你有一块带UV的3D模型。但在实际项目里,流光效果用在2D Sprite上的频率非常高——抽卡界面里卡牌边框的流光、按钮图标的扫光、道具图标的动态高光。这时候很多人直接把3D版Shader搬过来,结果发现完全不对:流光要么不显示,要么整个Sprite都在闪。问题出在2D和UI的渲染方式和3D模型有本质差异。

4.1 SpriteRenderer的UV隐藏陷阱

Unity的SpriteRenderer用了一张SpriteAtlas图集——也就是说,一张图集上排列了许多个不同的Sprite,每个Sprite在Shader里拿到的uv不是整张贴图的完整坐标,而是它自己那块区域在图集里的坐标。如果你在Shader里直接用uv.x + 偏移,那你只是在Sprite自己的局部坐标里移动采样点,当偏移超过自己的图集范围时,就会采样到隔壁Sprite的图案,流光看起来完全错乱。

解决思路有两个。

第一种是用世界坐标代替UV坐标。在顶点着色器里把顶点的世界位置传进去,在片元着色器里用世界坐标的某个分量来当扫光位置。这样扫光效果是“贴在世界空间里的”,不管Sprite怎么移动,光带都固定在屏幕上某个位置扫过。适合那种武器掉落时的发光扫过效果。

第二种是精确计算Sprite在图集中的UV范围。你可以在C#脚本里拿到Sprite.textureRect,然后算出UV的起点和宽高,把它作为参数传给Shader。Shader里采样时先做一次坐标映射,把局部UV映射到完整图集纹理上:

float2 atlasUV = IN.uv_MainTex * _AtlasScale + _AtlasOffset;

这个做法更通用,但需要C#配合,实时性要求高的场合容易踩坑,因为Sprite的图集打包时机和运行时动态加载会改变textureRect。

4.2 UI上的流光:用UI Shader还是偷懒用粒子

UI的流光,我个人的建议是:如果你用的是UGUI,先别急着写自定义Shader。UGUI默认用的是UI/Default这个Shader,它处理了图集切片的顶点数据。你直接换上自定义Shader,有很高概率遇到裁切失效、图集UV错乱、顶点色丢失这类问题。

一个绕开Shader的偷懒方案:用一张带渐变透明度的长条图片,放在UI界面上,然后用DoTween或iTween控制它的anchoredPosition从左边平移到右边,在动画结束前把透明度从0渐显再渐隐。这本质上就是一个“伪流光”,代码量少,效果也够用。很多游戏的登录界面按钮就是这种做法。

如果必须要写真正的Shader流光,那要看UI的渲染管线。Unity的UGUI默认用的是CanvasRenderer,它支持自定义材质,但你得在材质上设置好_MainTex和_ClipRect,否则UI的矩形裁切会失效。核心的Shader模板应该是这样:

Shader "Custom/UI_ScanFlow" { Properties { [PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" {} _Color ("Tint", Color) = (1,1,1,1) _FlowColor ("流光颜色", Color) = (1, 1, 1, 1) _FlowSpeed ("流光速度", Float) = 1 _FlowWidth ("流光宽度", Float) = 0.3 _StencilComp ("Stencil Comparison", Float) = 8 _Stencil ("Stencil ID", Float) = 0 _StencilOp ("Stencil Operation", Float) = 0 _StencilWriteMask ("Stencil Write Mask", Float) = 255 _StencilReadMask ("Stencil Read Mask", Float) = 255 _ColorMask ("Color Mask", Float) = 15 } SubShader { Tags { "Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" "PreviewType"="Plane" } Stencil { Ref [_Stencil] Comp [_StencilComp] Pass [_StencilOp] ReadMask [_StencilReadMask] WriteMask [_StencilWriteMask] } Cull Off Lighting Off ZWrite Off ZTest [unity_GUIZTestMode] Blend SrcAlpha OneMinusSrcAlpha ColorMask [_ColorMask] Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata_t { float4 vertex : POSITION; float4 color : COLOR; float2 uv : TEXCOORD0; }; struct v2f { float4 vertex : SV_POSITION; fixed4 color : COLOR; float2 uv : TEXCOORD0; }; sampler2D _MainTex; fixed4 _Color; fixed4 _FlowColor; float _FlowSpeed; float _FlowWidth; v2f vert (appdata_t v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.color = v.color * _Color; o.uv = v.uv; return o; } fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv) * i.color; // 这里用uv.y方向做扫光,扫光方向根据实际UI布局调整 float scanPos = frac(_Time.y * _FlowSpeed); float scanMask = 1.0 - smoothstep(_FlowWidth, 0.0, abs(i.uv.y - scanPos)); col.rgb += _FlowColor.rgb * scanMask * col.a; return col; } ENDCG } } }

这里有个很关键的细节,在frag最后一行:col.rgb += _FlowColor.rgb * scanMask * col.a。为什么最后要乘col.a?因为UI的Sprite如果本身有透明的区域(比如一个圆角按钮,四角是透明的),扫光扫过透明角落时如果不乘Alpha,会看到一束光从“空的地方”穿过去,特别难看。乘以Alpha之后,扫光只会在像素可见的地方显现,圆角边缘的光效会跟着按钮的形状一起被裁掉,观感自然得多。

5. 更进一步:纹理采样型流光和程序化流光,按需选择

扫光效果虽然经典,但它只覆盖很短的一段区域,所以视觉上是“一道光冲过去”。有很多场景需要的是“贴图里的纹路自己在发光流动”,比如岩浆表面的纹路、能量法阵上旋转的符文、传送门的漩涡。这种效果用扫光就不合适了,得用专门的流光贴图配合UV旋转或UV偏移来做。

5.1 用噪声贴图做出“能量涌动”的感觉

我做过一个传送门的Shader,需求是让门里的能量纹路持续流动,同时伴随着闪烁和颜色渐变。当时的做法是用两张噪声贴图,用不同的速度和方向做UV偏移叠加,然后对它们做混合。核心代码长这样:

fixed4 texA = tex2D(_NoiseTexA, IN.uv_MainTex * _TilingA + _Time.y * _SpeedA); fixed4 texB = tex2D(_NoiseTexB, IN.uv_MainTex * _TilingB - _Time.y * _SpeedB * 0.5); // 两种噪声做差值混合,制造“纹路在交织流动”的效果 fixed flowMask = texA.r * texB.g; fixed3 energyColor = _EnergyColor.rgb * flowMask * _Intensity; o.Emission = energyColor;

用两张噪声贴图的道理其实很简单:单张噪声流动久了,人眼会捕捉到它的重复周期,显得单调。两张噪声速度不同、方向不同,叠加后重复周期变长,视觉上丰富得多。如果你只有一张噪声贴图,那退一步的做法是让_Tiling改成非整数,比如1.7、2.3这种,这也能有效破坏视觉上的周期性重复。

还有一个提升质感的细节:让流光颜色跟着时间做渐变。我习惯在C#里传一个_TimeColor参数,或者直接在Shader里用lerp搭配_Time.y正弦变化,让流光在红色和蓝色之间慢慢过渡,避免一直是一个颜色看着疲劳。

5.2 纯数学流光:Shader中只用UV算数也能出效果

不是所有流光都需要贴图。比如那种“能量条里快速奔跑的光点”,或者“护盾上的六边形网格流光”,完全可以用数学函数生成。这样连贴图资源都省了。

举一个经典的光点奔跑效果:

// 输入:UV坐标 // 输出:亮度值 // 思路:把UV沿Y方向分成若干行,每一行移动速度略有不同 float2 uv = IN.uv_MainTex; float rowID = floor(uv.y * _RowCount); float speed = _Speed + rowID * 0.05; // 行号越高速度越快 float phase = frac(uv.x * _Density + _Time.y * speed); float pointMask = smoothstep(0.2, 0.0, abs(phase - uv.x));

这个效果用在能量护盾上很漂亮——一束束光点沿着护盾表面快速移动,不同高度的光点速度还不一样,有一种液体流动的质感。它的核心是把UV按行切分,每行是一个独立的一维流光。如果你把方向从X方向改成极坐标的旋转角,光点就会变成旋转的,适合传送门、法阵这类圆形效果。

纯数学流光的优势是不占贴图内存、不依赖美术资源、像素级精度,改参数就能实时看到效果。劣势也很明显:数学公式不够灵活,想要具体的不规则纹路(比如一条龙在表面上游动),纯数学就无能为力了,还是得老老实实用贴图。

我在项目里的习惯是——能用贴图的地方用贴图,因为美术可控性强;纯数学流光只用来做那些“形式化”的装饰性效果,比如光点、网格、扫描线。两种手段配合,效果和成本能做到比较平衡的状态。

6. 绕不开的性能账:带宽、Overdraw和移动端适配

Shader写出来能看只是第一步。我见过不少效果惊艳的流光Shader,一上真机帧率直接掉了七八帧,美术和策划一起过来“问罪”。流光的性能开销往往不在Shader本身的计算量,而在几个容易忽略的地方。

6.1 采样次数决定带宽占用

最基本的Shader里,每多一个贴图采样,GPU的纹理单元就被多占用一次。流光效果通常是额外加在基础贴图之上的,意味着你要在原来的采样基础上额外增加流光贴图采样和Mask贴图采样。三个采样同时进行,内存带宽压力成倍增长。

你以为最稳妥的做法是什么?用tex2D采样三次。实际上更聪明的做法是把流光贴图和Mask合并成一张贴图。怎么做?RGBA通道嘛,R通道存流光,A通道存Mask,Shader里只采样一次,然后分别取.r和.a。一顿操作下来,采样次数从三次减到两次,性能立刻上来。这个技巧在很多移动端项目里是标配。

具体的合并方法也很简单:在Photoshop或者SD里,把流光灰度图放到R通道,把遮罩灰度图放到A通道,导出时选择TGA或PNG(保留Alpha),就完事了。Shader那边对应改成:

fixed4 flowMaskTex = tex2D(_FlowMaskTex, flowUV); float flowValue = flowMaskTex.r; // 流光部分 float maskValue = flowMaskTex.a; // 遮罩部分

6.2 流光区域的Overdraw和半透明排序

前面提到过,如果流光效果附着在一个Transparent物体上,而这个物体面积又很大(比如一块全屏的传送门),半透明渲染的Overdraw会非常严重。特别是移动端,半透明的每个像素都要在管线里占一个pass,大面积半透明等于递给GPU一张高额账单。

解决方案有几个。如果流光效果可以不透明的形式实现,就尽量不要用Transparent。比如把流光作为自发光叠加到基础颜色上,整体走Opaque管线,性能好出一大截。很多能量武器其实没必要做成半透明,用Emissive自发光配合Bloom后处理,效果反而更“亮眼”。

如果真的必须要半透明,那就严格控制流光区域的大小。不要用一张巨大的全屏Quad去做流光动画,那是性能灾难。尽量把流光限制在模型的局部区域,配合Mask贴图把发光范围缩到最小。

移动端还有另一个特有问题——Tile-based GPU上的半透明性能。像PowerVR和Adreno的GPU,半透明混合需要在每个Tile内做一个read-modify-write操作,如果渲染顺序不对,GPU的隐式排序机制被破坏,代价会成倍增长。这不是Shader能解决的,得靠调整渲染队列和DrawCall顺序来缓解。说白了就是少用半透明,多用Opaque加自发光。

6.3 一个我踩过的坑:Shader变体和关键字膨胀

有段时间我做的项目里,流光效果需要同时支持“跑在角色上”和“跑在UI上”,我把两套逻辑写在一个Shader里,用关键字区分。结果变体数量直接翻倍,包体变大不说,加载时间也明显变长,更气人的是某些低端机型编译Shader缓存时直接卡死。

后来把UI版和角色版拆成两个独立的Shader文件,各管各的,变体数量立刻降下来,问题迎刃而解。做Unity Shader一定要有变体成本意识,尤其是用#pragma multi_compile和#pragma shader_feature的时候,每多一个关键字,变体数量是乘算的。流光这类简单的效果,尽可能不要引入复杂的关键字组合。

7. 排查流光Shader问题的完整思路:从黑屏到动态不对

写Shader最让人抓狂的不是代码报错,而是代码不报错、效果却不对。我把我遇到过的流光问题整理成了一张排查清单,按影响程度排序,你可以对照着查。

7.1 流光完全不显示

如果流光完全看不见,按这个顺序排查:

  • 检查Shader有没有编译报错。Unity的Shader编译器对语法很宽松,但你一旦写错了CGPROGRAM块里的某个函数名或者变量类型,它会编译失败。把Console面板打开,切换到“Console”窗口,看是否有红色报错。我见过最多的是变量名拼写不一致,比如属性里叫_FlowSpeed,CGPROGRAM里写成了_Flowspeed——Unity里变量名大小写敏感,直接GG。

  • 检查流光颜色是否被后续步骤“吃掉”了。很多Shader的后处理阶段会把颜色重新赋值,比如surf函数里如果最后有一行o.Emission = 0或者类似的重置操作,流光就被覆盖了。把最后输出的那行代码往上看,确认流光结果真的有写进输出结构里。

  • 检查Mask贴图是否全黑。如果Mask贴图在导入设置里压缩成了DXT1(不支持Alpha),或者本身就是全黑图,那么maskTex.r是0,Flow直接被掐灭。用Photoshop打开贴图看一眼灰度值,大部分情况都能当场发现。

7.2 流光会出现撕裂的闪烁

如果流光扫过边缘的时候出现一闪一闪的亮线,大概率是UV坐标在某个tiling边界处发生了跳变。回想前面说的那个原理——frac()函数把连续的值截断成了0到1的循环,在循环的那个临界点(比如从0.99跳到0),数值跳变会产生一条极亮的线。

解决方案很简单:用fwidth配合smoothstep做抗锯齿,或者在计算距离时不要用abs,而是用圆形距离(把两个点放在一个循环空间里求最短距离):

float circleDist = min(abs(a - b), 1.0 - abs(a - b));

这样在边界处距离是平滑过渡的,不会产生撕裂。这个min(abs...), 1 - abs...)的写法在很多类似的循环性效果里都适用,比如旋转的圆环流光、重复的网格光效,值得记下来。

7.3 流光方向和预期不一致

如果你想让流光从左到右扫,结果从右到左了,把_Time.y * _FlowSpeed改成- _Time.y * _FlowSpeed就行。但这个方向问题也经常出在UI上——UI的坐标系原点在左下角,Y轴向上,如果你在UGUI上做上下扫光的流光,需要确认正方向和预期一致,否则光是从下往上扫还是从上往下扫,调一下符号就行。

7.4 一个容易被忽略的坑:Gamma空间 vs 线性空间

Unity 5.6之后默认启用线性空间渲染。在线性空间下,Shader的数值运算在线性空间进行,而贴图在采样时会被自动解码到线性空间。这会导致一个问题:你的流光贴图如果在Photoshop里调得稍微暗一点,在线性空间下会看起来更暗,因为线性空间的亮度感知和Gamma空间不一样。

这个问题的典型症状是:流光效果在编辑器里看着挺好,打包到真机上颜色变淡或者变暗,怎么调强度参数都感觉不对。解决的办法:一是把流光贴图的导入设置里勾选sRGB(Texture Type选Default,sRGB默认勾选),保证Unity采样时正确解码;二是流光颜色最好直接在线性空间下设定,不要在Inspector里调完再看,容易有偏差。

8. 收个尾:在我项目里沉淀下来的几条经验

做流光Shader做到后面,我最大的感悟是——技术方案要和场景绑定。同样是“流光”两个字,放在武器上、放到UI上、放到传送门上,解法完全不同。武器上我优先用Mask扫光,走自发光不透明管线;UI上我优先用Tween做假流光,只有在需要复杂图案时才上自定义Shader;传送门这类大面积的,我用噪声贴图叠加,但严格控制半透明区域大小。

另外一条经验是关于Shader参数设计的。写Shader时尽量把可调参数都暴露到Properties里,但也不要暴露太多。我见过有些同事把一个流光Shader做出十几个参数,美术调了一下午,最后效果还不如默认参数好。我的建议是:速度、宽度、强度、颜色这四个参数足够覆盖绝大多数场景需求,其他参数写死在Shader里就好。参数每多一个,美术的认知成本就高一分。

最后分享一个小技巧:调试Shader时,不要直接在最终场景里调,做一个只包含一个球体或者一个Cube的纯色场景,把流光Shader赋上去,然后用材质面板的滑动条快速试参数。这样排除场景光、反射、后处理这些干扰,你能很清楚地看到Shader本身长什么样。等效果对了,再放回实际场景里做微调。这一步能省掉一晚上你瞪着眼睛找问题的痛苦。

返回列表