☰
Unity流光Shader实战:从UV/世界空间到移动端性能优化
2026/10/15 22:59:48 网站建设 项目流程

1. 流光效果在Unity里的真实定位

很多做Unity项目的人第一次接触“模型流光”这个需求,往往来自两个场景:一是角色释放技能时武器或身体表面需要有一道高亮光带扫过,二是UI或场景道具需要一种“呼吸感”的科技氛围。这两个场景看起来都是“光在动”,但底层实现思路差别很大。前者通常要求光带贴合模型表面、有明确的方向和宽度,后者往往只需要在屏幕空间或UV空间做周期性偏移。如果你把这两类需求混在一起用同一套Shader去套,大概率会在某个项目里翻车。

流光Shader的本质,是在模型表面叠加一层随时间变化的高亮区域。这个高亮区域的位置由时间变量驱动,形状由模型自身的UV或顶点数据决定,颜色和强度则由材质参数控制。听起来简单,但真正落地时会遇到几个绕不开的问题:光带边缘太硬像贴纸、光带在曲面上的宽度不均匀、多个流光叠加时互相干扰、以及最头疼的——在移动端上性能直接崩掉。

我见过不少项目组在这件事上走弯路。有人直接用一张带Alpha通道的贴图做UV滚动,结果模型UV一旦有接缝,光带就断成几截;有人用顶点颜色做遮罩,结果美术改一次模型就要重新刷一遍顶点色,流程极其痛苦。所以这篇文章不打算只给你一段能跑的代码,而是把流光Shader从需求拆解到参数调优的完整链路讲清楚,让你拿到手就能改、改完就能用。

注意:本文所有代码基于Unity的Built-in渲染管线编写,URP和HDRP的写法在核心逻辑上一致,但ShaderLab的语法结构有差异,我会在关键位置标注出来。

2. 先想清楚光带走哪条路:UV空间还是世界空间

2.1 UV空间流光的适用边界

UV空间流光是最容易上手的方案。核心思路是拿模型的UV坐标,取其中一个分量(通常是U或V),加上时间变量后做frac或saturate运算,得到一个0到1之间的动态值,再用这个值去采样一张渐变贴图或者直接做平滑步进。这种方案的好处是计算量极小,一个片元着色器里几行代码就能搞定,而且在大多数标准模型上效果稳定。

但它有一个硬伤:光带的宽度和方向完全依赖模型的UV展开。如果美术在展UV时把接缝放在了模型正面,那光带扫过接缝时就会突然断裂。更麻烦的是,如果模型UV的密度不均匀,比如脸部UV给得很大、手臂UV给得很小,那同样的流光参数在脸上和手臂上的视觉宽度会差好几倍。我遇到过最极端的情况是一个角色模型,背部UV被压缩到只有正面的一半,结果流光扫到背部时直接变成一条细线,美术看了直摇头。

所以UV空间流光适合什么场景?适合UV展开规整、接缝藏在视觉盲区的模型,比如武器、道具、机械零件。对于角色这种UV复杂、接缝多的模型,要么让美术专门为流光效果做一套UV,要么就得换方案。

2.2 世界空间流光的计算逻辑

世界空间流光不依赖UV,而是用模型顶点的世界坐标或者模型局部坐标来计算流光位置。最常见的做法是取世界坐标的某一个轴(比如Y轴或X轴),加上时间偏移后做同样的frac运算。这样光带就是一个垂直于该轴的平面在空间中扫过,不管模型UV怎么展,光带宽度都是均匀的。

这种方案的代价是计算量稍大,因为每个片元都要做一次世界坐标变换。但在现代移动端GPU上,这点开销完全可以接受。更重要的是,世界空间流光的光带方向是全局统一的,多个模型同时播放流光时,光带会像一道真正的光墙扫过整个场景,视觉一致性非常好。

不过世界空间流光也有自己的坑。如果模型是动态旋转的,比如角色在跑动中转身,那世界空间的光带方向会跟着角色一起转,看起来就像光带粘在角色身上而不是从环境中扫过。这时候就需要把计算基准从世界空间换成模型局部空间,用模型自身的坐标轴来定义光带方向。具体选哪个,取决于你想要的是“环境光扫过角色”还是“角色自身发光扫过”。

2.3 两种方案的参数对照

对比维度UV空间流光世界空间流光
计算开销极低中等
光带宽度均匀性依赖UV密度全局均匀
接缝断裂风险高无
动态旋转适应性好需切换局部空间
适合模型类型武器、道具、机械角色、场景、特效
移动端性能优秀良好

这张表不是让你二选一,而是让你在接到需求时先判断:这个模型会不会动?UV展得规不规范?光带要不要跟场景其他元素对齐?把这几个问题答完,方案自然就定了。

3. 从零写一个可调参的流光Shader

3.1 属性面板的参数设计

写Shader的第一步不是写代码,而是想清楚美术需要调什么。根据我多个项目的经验,流光效果最少需要暴露这几个参数:流光颜色、流光宽度、流光速度、流光方向、以及一个控制流光强度的全局乘数。如果要做多段流光,还需要一个“流光间隔”参数。

在ShaderLab的Properties块里,我通常会这样定义:

Properties { _MainTex ("基础贴图", 2D) = "white" {} _BaseColor ("基础颜色", Color) = (1,1,1,1) _FlowColor ("流光颜色", Color) = (1,1,1,1) _FlowWidth ("流光宽度", Range(0.01, 0.5)) = 0.1 _FlowSpeed ("流光速度", Range(-5, 5)) = 1.0 _FlowDirection ("流光方向", Vector) = (1,0,0,0) _FlowIntensity ("流光强度", Range(0, 5)) = 1.0 _FlowInterval ("流光间隔", Range(0, 1)) = 0.0 }

这里有几个细节值得展开。_FlowWidth的范围我限制在0.01到0.5之间,因为超过0.5之后光带会宽到覆盖整个模型,失去“扫过”的感觉。_FlowSpeed允许负值,这样美术可以直接在面板上反转流光方向,不用去改代码。_FlowDirection用Vector而不是Float,是为了支持斜向流光,比如45度角扫过。

_FlowInterval这个参数是给多段流光用的。当它大于0时,流光会在一个周期内出现多次,形成“条纹”效果。实现方式是在计算流光位置时乘以一个系数,比如(1 + _FlowInterval * 3),这样原本一个周期内只有一条光带,现在会变成多条。

3.2 顶点着色器里要做的坐标转换

顶点着色器的任务很明确:把模型顶点从对象空间转到裁剪空间,同时把计算流光需要的坐标传下去。如果用的是世界空间方案,这里就要把世界坐标算好;如果用UV方案,直接把UV传下去就行。

v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; o.worldNormal = UnityObjectToWorldNormal(v.normal); return o; }

这里我多传了一个worldNormal,虽然基础流光效果用不到,但如果你后面想加边缘光或者根据法线方向调整流光强度,这个数据就派上用场了。提前传好,省得后面改结构体。

提示:在URP里,UnityObjectToClipPos要换成TransformObjectToHClip,unity_ObjectToWorld要换成GetObjectToWorldMatrix()。如果你在URP项目里直接用Built-in的写法,编译会报错。

3.3 片元着色器里的流光核心算法

片元着色器是流光效果真正发生的地方。我先把完整代码贴出来,然后逐段解释。

fixed4 frag (v2f i) : SV_Target { fixed4 baseCol = tex2D(_MainTex, i.uv) * _BaseColor; // 计算流光位置 float flowPos = dot(i.worldPos, normalize(_FlowDirection.xyz)); flowPos = flowPos * (1 + _FlowInterval * 3) - _Time.y * _FlowSpeed; flowPos = frac(flowPos); // 生成光带 float flowMask = smoothstep(0.5 - _FlowWidth, 0.5, flowPos) * smoothstep(0.5 + _FlowWidth, 0.5, flowPos); // 叠加流光 fixed3 finalCol = baseCol.rgb + _FlowColor.rgb * flowMask * _FlowIntensity; return fixed4(finalCol, baseCol.a); }

这段代码的核心在flowPos的计算。dot(i.worldPos, normalize(_FlowDirection.xyz))把三维世界坐标投影到流光方向上,得到一个标量。这个标量的物理意义是:顶点在流光方向上的投影距离。然后乘以间隔系数、减去时间乘以速度,再做frac,就得到了一个在0到1之间循环的值。

smoothstep那两行是生成光带的关键。第一个smoothstep在flowPos从0.5 - _FlowWidth到0.5的区间内从0平滑过渡到1,第二个smoothstep在0.5到0.5 + _FlowWidth的区间内从1平滑过渡到0。两者相乘,就得到一个以0.5为中心、宽度为_FlowWidth的平滑光带。

这里有个容易踩的坑:smoothstep的第二个参数如果小于第一个参数,结果会反过来。我见过有人写成smoothstep(0.5, 0.5 - _FlowWidth, flowPos),结果光带变成了一条黑缝。记住smoothstep(edge0, edge1, x)的规则是:x小于edge0返回0,x大于edge1返回1,中间平滑过渡。所以edge0必须小于edge1。

3.4 让光带边缘更自然的两种微调

上面代码生成的光带边缘已经是平滑的了,但如果你想要更“锐利”或者更“柔和”的效果,可以调整smoothstep的区间。把_FlowWidth调小,光带变窄变锐;调大,光带变宽变柔。但单纯调宽度有个问题:光带中心始终在0.5的位置,如果想让光带偏向一侧,就需要引入一个偏移参数。

另一个微调是给流光加一点“拖尾”效果。做法是在光带后面再叠加一条更宽、更暗的光带,位置稍微偏移一点。代码上就是再算一次flowMask,但把flowPos减去一个小的偏移量,然后乘以一个较低的强度。这样光带看起来就像有方向性,而不是一个对称的亮条。

float trailPos = frac(flowPos - 0.05); float trailMask = smoothstep(0.5 - _FlowWidth * 1.5, 0.5, trailPos) * smoothstep(0.5 + _FlowWidth * 1.5, 0.5, trailPos); finalCol += _FlowColor.rgb * trailMask * _FlowIntensity * 0.3;

这个拖尾的偏移量和强度系数需要根据实际效果微调,没有固定值。我的经验是偏移量取光带宽度的0.3到0.5倍比较自然,强度取主光带的0.2到0.4倍。

4. 多段流光与遮罩控制的进阶玩法

4.1 用间隔参数做出条纹流光

前面提到的_FlowInterval参数,实现条纹流光的原理很简单:把flowPos乘以一个大于1的系数,frac之后就会在一个周期内出现多个循环。比如乘以3,原本0到1的区间被压缩成0到1/3、1/3到2/3、2/3到1三段,每段里都有一个0.5的位置,也就对应三条光带。

但这里有个细节:乘以系数后,光带的实际宽度会变窄。因为smoothstep的区间还是基于0.5附近的_FlowWidth,但flowPos的变化速度变快了,所以视觉上光带变细了。如果你想让条纹流光的光带宽度和单条时一致,需要把_FlowWidth也乘以相同的系数。

float intervalFactor = 1 + _FlowInterval * 3; flowPos = flowPos * intervalFactor - _Time.y * _FlowSpeed; flowPos = frac(flowPos); float adjustedWidth = _FlowWidth * intervalFactor; float flowMask = smoothstep(0.5 - adjustedWidth, 0.5, flowPos) * smoothstep(0.5 + adjustedWidth, 0.5, flowPos);

这样调整后,不管间隔参数怎么变,光带的视觉宽度都保持一致。这个细节很多教程不会讲,但不处理的话,美术调间隔参数时就会发现光带越来越细,最后只能靠手动补宽度,非常别扭。

4.2 用顶点颜色或贴图做局部遮罩

有时候你不想让流光扫过整个模型,而是只在特定区域出现。比如角色的武器只有刀刃部分发光,剑柄不发光。这时候就需要一个遮罩来控制流光的显示范围。

最简单的遮罩是顶点颜色。让美术在建模时把需要流光的区域顶点色Alpha通道刷成1,其他区域刷成0。Shader里把顶点色传下来,和flowMask相乘即可。

// 顶点着色器里 o.vertexColor = v.color; // 片元着色器里 flowMask *= i.vertexColor.a;

顶点色遮罩的优点是零额外开销,缺点是精度受限于顶点密度。如果模型面数低,遮罩边缘会呈锯齿状。这时候可以改用一张遮罩贴图,用UV采样,精度更高但多一次纹理采样。

还有一种更灵活的方式是用模型的空间位置做遮罩。比如只让Y坐标大于某个值的区域发光,或者只让距离某个点一定范围内的区域发光。这种方式的遮罩边缘是程序化生成的,可以做得非常平滑,而且不依赖美术额外工作。代价是遮罩形状比较规则,做不了复杂图案。

4.3 流光与基础光照的叠加顺序

流光效果最终是要叠加到模型的基础颜色上的。这里有一个容易被忽略的问题:叠加顺序和混合模式。如果你用的是不透明材质,直接相加就行。但如果模型本身是半透明的,或者你想让流光有发光溢出感,就需要考虑混合模式。

我通常的做法是:基础颜色用正常的Opaque渲染,流光部分单独用一个Pass做Additive混合。这样流光可以超出模型边缘,形成光晕效果。但多一个Pass意味着多一次Draw Call,移动端上要谨慎使用。

如果不想加Pass,也可以在同一个Pass里把流光颜色乘以一个大于1的系数,然后让最终颜色超过1,配合HDR和Bloom后处理,同样能得到发光效果。这种方式不需要额外Pass,但要求项目开启了HDR和Bloom。

注意:在移动端上,HDR和Bloom的开销不小。如果项目对性能敏感,建议还是用单独的Additive Pass,并且把流光Pass的渲染队列放在基础Pass之后,避免不必要的Overdraw。

5. 性能优化与移动端适配的实战经验

5.1 把计算从片元挪到顶点

流光效果的计算量主要集中在片元着色器里,因为每个像素都要算一次flowPos和smoothstep。如果你的模型面数不高,但屏幕占比很大,比如一个近景的角色,那片元计算量会非常可观。

一个有效的优化是把流光位置的计算挪到顶点着色器里。因为flowPos是基于世界坐标的,而世界坐标在顶点阶段就能算出来。把flowPos和flowMask的计算放到顶点着色器,然后通过插值传给片元着色器,片元阶段只需要做一次纹理采样和颜色叠加。

// 顶点着色器里 float flowPos = dot(o.worldPos, normalize(_FlowDirection.xyz)); flowPos = frac(flowPos * (1 + _FlowInterval * 3) - _Time.y * _FlowSpeed); o.flowMask = smoothstep(0.5 - _FlowWidth, 0.5, flowPos) * smoothstep(0.5 + _FlowWidth, 0.5, flowPos);

这样做的前提是模型面数足够,插值后的光带边缘不会出现明显的棱角。如果模型面数太低,比如一个简单的立方体,那顶点插值出来的光带会变成三角形渐变,效果很差。所以这个优化适合高面数模型,低面数模型还是老老实实在片元里算。

5.2 避免在片元里做normalize

normalize(_FlowDirection.xyz)这个操作如果在片元着色器里做,每个像素都要算一次平方根,非常浪费。正确的做法是在顶点着色器里算好,或者干脆在Properties面板里就要求美术填单位向量。

但美术不一定懂什么是单位向量。所以更稳妥的做法是在Shader的C#脚本里,通过Material.SetVector设置方向时自动做一次normalize。这样Shader里就可以直接用,不用再算。

Vector3 dir = new Vector3(1, 0.5f, 0); material.SetVector("_FlowDirection", dir.normalized);

如果不想写脚本,也可以在Shader里用一个float3常量预先算好,但那样就失去了运行时调整方向的能力。权衡下来,我倾向于在脚本里做normalize,既保证了性能,又保留了灵活性。

5.3 移动端上的精度取舍

移动端GPU对浮点精度比较敏感。在片元着色器里,fixed和half精度比float快很多。流光效果里,颜色计算可以用fixed,flowPos的计算可以用half,只有世界坐标的传递需要float。

但要注意,half精度在有些移动GPU上只有10位尾数,做frac运算时如果数值很大,精度损失会导致光带抖动。解决办法是在做frac之前先把flowPos减去一个整数部分,把它拉回0附近。或者直接用float,现代移动GPU对float的支持已经好很多了,性能差距没有想象中那么大。

我在一个中低端安卓机上做过测试:用half计算flowPos时,光带在模型边缘会出现明显的阶梯状抖动;换成float后抖动消失,帧率只掉了不到2帧。所以如果你的项目对视觉质量有要求,不要为了省那一点性能而牺牲精度。

6. 那些年我踩过的流光Shader坑

6.1 光带在模型接缝处断裂

这是UV空间流光最常见的坑。模型UV在接缝处不连续,frac运算后光带直接断开。解决办法有两个:一是改用世界空间流光,从根上避免UV依赖;二是让美术把接缝藏在视觉盲区,比如角色背部中线、武器握把内侧。

但有时候接缝避不开,比如一个环形模型,UV必然有接缝。这时候可以用三平面映射(Triplanar Mapping)来生成流光坐标,用世界坐标在三个轴向上的投影混合,完全绕开UV。代价是三次纹理采样和混合计算,性能开销较大,适合PC端或高端移动设备。

6.2 流光速度受帧率影响

_Time.y是Unity提供的全局时间,它本身是受帧率影响的。如果你在Update里用Time.deltaTime累加时间,那流光速度是稳定的。但如果你直接用_Time.y乘以速度,在帧率波动时流光速度会看起来忽快忽慢。

解决办法是在C#脚本里自己维护一个时间变量,用Time.unscaledDeltaTime累加,然后通过Material.SetFloat传给Shader。这样即使游戏暂停或时间缩放,流光速度也是恒定的。

private float flowTime = 0; void Update() { flowTime += Time.unscaledDeltaTime * speed; material.SetFloat("_FlowTime", flowTime); }

Shader里把_Time.y换成_FlowTime即可。这个改动很小,但能解决一个很影响体验的问题。

6.3 多个流光材质互相干扰

如果你在场景里放了多个使用同一个Shader的模型,但每个模型的流光参数不同,那必须确保每个模型用的是独立的Material实例。如果共用同一个Material,改一个模型的参数会影响所有模型。

在Unity里,直接拖拽Material到模型上,多个模型会共享同一个Material。要避免这个问题,可以在脚本里用renderer.material而不是renderer.sharedMaterial,这样Unity会自动为每个Renderer创建一个Material副本。但要注意,renderer.material会在每次访问时创建副本,如果在Update里频繁访问,会造成大量内存分配。正确的做法是在Start里缓存一次。

private Material mat; void Start() { mat = GetComponent<Renderer>().material; mat.SetColor("_FlowColor", Color.red); }

6.4 流光颜色在Gamma空间下偏暗

Unity默认的颜色空间是Gamma,但很多现代项目会切换到Linear。在Linear空间下,Shader里计算出的颜色需要经过Gamma校正才能正确显示。如果你发现流光颜色在Linear空间下比在Gamma空间下暗很多,那就是缺少了校正。

解决办法是在片元着色器的最后,对最终颜色做一次LinearToGammaSpace转换。或者更简单的方式是,在Properties面板里定义的颜色本身就是Linear空间的,Unity会自动处理。但如果你在代码里用SetColor设置颜色,那传入的颜色需要是Linear空间的。

// 在Linear空间下 finalCol = LinearToGammaSpace(finalCol);

这个坑很隐蔽,因为Gamma和Linear的切换在编辑器里是一个项目设置,很多人改了之后忘了Shader里也要对应调整。

7. 从流光延伸出去的几个实用变体

7.1 呼吸光效:让流光在原地脉动

把流光速度设为0,然后让流光强度随时间做正弦变化,就得到了呼吸光效。这个效果在UI和场景道具上非常常用,实现起来也简单。

float breath = sin(_Time.y * _BreathSpeed) * 0.5 + 0.5; finalCol += _FlowColor.rgb * breath * _FlowIntensity;

呼吸光效的关键是频率要慢,一般0.5到2赫兹比较合适。太快了像闪烁,太慢了又感觉不到。另外呼吸的波形用正弦比用三角波更自然,因为正弦的加减速是平滑的。

7.2 扫描线:用流光做科技感UI

把流光方向固定为屏幕的Y轴,宽度调得很窄,速度调快,就得到了扫描线效果。这个在科幻风格的UI上很常见。如果配合一个半透明的背景,扫描线扫过时会有一种“正在扫描”的科技感。

扫描线的难点在于要和UI的布局对齐。因为UI元素通常是在屏幕空间渲染的,世界坐标流光在UI上不适用。这时候需要改用屏幕空间的UV来计算流光位置,用i.screenPos或者ComputeScreenPos来获取屏幕坐标。

7.3 边缘流光:只在模型轮廓上发光

边缘流光结合了菲涅尔效应和流光效果。菲涅尔让模型边缘更亮,流光让这个亮部随时间移动。实现方式是在计算流光遮罩时,乘以一个菲涅尔因子。

float fresnel = 1 - saturate(dot(normalize(i.worldNormal), normalize(i.viewDir))); flowMask *= fresnel;

这样流光就只会在模型边缘出现,中心区域不受影响。这个效果适合做护盾、能量罩之类的视觉表现。菲涅尔的强度可以通过一个参数控制,让美术可以调整边缘光的范围。

8. 关于参数调优的一些个人习惯

调流光参数这件事,我一般不会在编辑器里盲调,而是先想清楚三个问题:光带要多宽?扫过多快?颜色要多亮?这三个问题对应_FlowWidth、_FlowSpeed和_FlowIntensity。

宽度方面,我的经验值是:角色身上的流光宽度在0.05到0.15之间比较合适,武器上的可以窄一点,0.03到0.08。速度方面,角色流光的速度在1到3之间,武器可以快一点到5。强度方面,如果项目开了Bloom,强度1到2就够了;没开Bloom的话,强度要拉到3以上才能看出明显的发光感。

还有一个容易被忽略的参数是流光的起始位置。默认情况下,frac运算让光带从0开始扫。如果你想让光带从模型的一端开始,而不是从中间开始,可以在flowPos上再加一个偏移量。这个偏移量可以通过脚本在播放流光时动态设置,实现“从剑柄扫到剑尖”的效果。

最后说一个流程上的建议:流光Shader最好做成一个独立的Shader文件,不要和基础材质混在一起。这样美术可以在不改动基础材质的情况下,单独调整流光效果。如果项目里多个模型共用同一个基础材质但需要不同的流光参数,那就用MaterialPropertyBlock来传参,避免创建大量Material实例。

MaterialPropertyBlock的用法很简单:

private MaterialPropertyBlock props; void Start() { props = new MaterialPropertyBlock(); props.SetColor("_FlowColor", Color.cyan); GetComponent<Renderer>().SetPropertyBlock(props); }

这样多个模型可以共用同一个Material,但每个模型有自己的流光颜色。性能上比创建Material副本好很多,尤其是在有大量同类模型的时候。

流光Shader这个需求,说难不难,说简单也不简单。核心算法就那几行,但要把效果调好、性能调优、适配不同模型,需要的是对渲染管线和项目流程的全面理解。我见过太多人拿着一段能跑的代码就往上套,结果在真机上效果惨不忍睹。希望这篇内容能帮你少走一些弯路,把流光效果真正做成项目里的加分项,而不是性能瓶颈。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询