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 { RenderTypeOpaque } 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相当于Tiling1的坐标。同样的速度向量作用在Tiling2的贴图上视觉上的滚动速度会慢一半因为纹理被放大了一倍单位时间内滚过去的“图案周期”变少了。正确的写法应该是在偏移前先应用Tilingfloat2 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单位在Tiling2的贴图上只偏移了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 { RenderTypeTransparent QueueTransparent } 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改成了TransparentQueue改成了Transparent还加了Blend SrcAlpha OneMinusSrcAlpha和ZWrite Off。这是为了处理带有透明通道的贴图。武器、角色特效部件这类物体贴图往往有半透明边缘如果你还用不透明渲染半透明的区域会被直接丢弃或者产生黑边。而ZWrite Off是因为半透明物体按从远到近的顺序绘制如果还写深度缓冲后面的半透明物体会被前面的挡掉显示不出来。不过这里要提醒一句如果你的流光效果是加在完全实心的模型上比如一个石头雕塑表面的能量纹路那这个版本反而是错的——Transparent队列的绘制顺序靠后在某些角度看会穿到别的物体前面出现排序错误。这种情况下应该把Tags改回RenderTypeOpaque去掉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 { QueueTransparent IgnoreProjectorTrue RenderTypeTransparent PreviewTypePlane } 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通道存MaskShader里只采样一次然后分别取.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”窗口看是否有红色报错。我见过最多的是变量名拼写不一致比如属性里叫_FlowSpeedCGPROGRAM里写成了_Flowspeed——Unity里变量名大小写敏感直接GG。检查流光颜色是否被后续步骤“吃掉”了。很多Shader的后处理阶段会把颜色重新赋值比如surf函数里如果最后有一行o.Emission 0或者类似的重置操作流光就被覆盖了。把最后输出的那行代码往上看确认流光结果真的有写进输出结构里。检查Mask贴图是否全黑。如果Mask贴图在导入设置里压缩成了DXT1不支持Alpha或者本身就是全黑图那么maskTex.r是0Flow直接被掐灭。用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空间不一样。这个问题的典型症状是流光效果在编辑器里看着挺好打包到真机上颜色变淡或者变暗怎么调强度参数都感觉不对。解决的办法一是把流光贴图的导入设置里勾选sRGBTexture Type选DefaultsRGB默认勾选保证Unity采样时正确解码二是流光颜色最好直接在线性空间下设定不要在Inspector里调完再看容易有偏差。8. 收个尾在我项目里沉淀下来的几条经验做流光Shader做到后面我最大的感悟是——技术方案要和场景绑定。同样是“流光”两个字放在武器上、放到UI上、放到传送门上解法完全不同。武器上我优先用Mask扫光走自发光不透明管线UI上我优先用Tween做假流光只有在需要复杂图案时才上自定义Shader传送门这类大面积的我用噪声贴图叠加但严格控制半透明区域大小。另外一条经验是关于Shader参数设计的。写Shader时尽量把可调参数都暴露到Properties里但也不要暴露太多。我见过有些同事把一个流光Shader做出十几个参数美术调了一下午最后效果还不如默认参数好。我的建议是速度、宽度、强度、颜色这四个参数足够覆盖绝大多数场景需求其他参数写死在Shader里就好。参数每多一个美术的认知成本就高一分。最后分享一个小技巧调试Shader时不要直接在最终场景里调做一个只包含一个球体或者一个Cube的纯色场景把流光Shader赋上去然后用材质面板的滑动条快速试参数。这样排除场景光、反射、后处理这些干扰你能很清楚地看到Shader本身长什么样。等效果对了再放回实际场景里做微调。这一步能省掉一晚上你瞪着眼睛找问题的痛苦。