Unity Shader 实战:扫描光带与流光描边效果实现 做项目时经常会碰上这种需求角色或者道具被扫描的时候模型表面有一条光带从下往上扫过光带本身带着蓝紫渐变扫过的地方还留着暗色余晖整个模型的边缘再勾一圈发光的轮廓线。这个效果在打击反馈、试炼副本、场景探测里都很常见做好了能给画面质感拉高一大截。Unity Shader 要做到位其实核心就三件事描边、颜色插值、扫描驱动。描边负责轮廓颜色插值负责流光渐变扫描驱动负责让光带动起来。这篇博文我会直接给出一套可跑的方案从技术选型讲到具体代码再讲驱动调试和性能优化。适合理过 Shader 但还在摸索阶段的开发者也适合需要快速出扫描特效的 TA 或美术。先说结论这个效果不需要 C# 每帧改网格也不需要依赖后处理单个材质、两个 Pass 就能跑移动端也能很流畅。1. 效果拆解与技术选型1.1 描边方案的取舍描边技术大概分三类基于法线外扩的背面渲染、基于屏幕后处理的边缘检测、基于几何着色器生成边线。屏幕后处理适合整屏统一描边但没法单独对某个模型做“扫描时亮边”的局部效果几何着色器方案在移动端兼容性差而且引入额外几何管线很麻烦。这个效果最终选了“法线外扩 Cull Front”的经典方案额外开一个 Pass把模型背面的顶点沿法线方向往外推一圈然后只画背面。由于原始模型会盖住内部外扩后露出来的部分就形成一圈轮廓。这套方案的优势很明显独立作用在单个物体上不受场景其他物体影响描边颜色可以随时改也能参与扫描因子插值几乎没有额外贴图采样开销适合移动端。缺点是依赖模型封闭性半透明网格或片状模型效果会打折扣这个后面问题部分会说。1.2 扫描驱动为什么要用世界坐标扫描光带的移动方向和控制轴是需要提前定死的我建议直接用世界坐标 Y 轴。原因很简单如果用模型的局部坐标 Y 轴模型旋转 90 度后扫描线就变成横向扫了两个朝向不同的角色站在一起视觉会很乱。如果用 UV 坐标遇到 UV 不连续或图集化处理的模型扫描线会在接缝处跳变效果直接崩坏。用世界坐标 Y 轴时不管模型怎么旋转摆放光带永远从下往上走观众能直观地理解为“这个物体正在被探测”。跨多个物体同步扫描时也只需要共享同一个扫描进度值坐标统一省心。2. 从零搭建描边 Shader2.1 Shader 整体框架与 Pass 规划先规划整体脉络。这个 Shader 不分光照按自发光思路处理否则扫描线的亮度会被场景光照干扰颜色不可控。结构上就两个 Pass描边 PassCull Front顶点外扩渲染背面轮廓。 主 PassCull Back正常渲染模型叠加扫描流光。NOTE因为主 Pass 会渲染模型正面描边 Pass 在背面外扩的一圈会被正面覆盖露出来的只有轮廓边。所以两个 Pass 的深度写入和深度测试也要配好不然会出现描边盖住正面的问题。2.2 描边 Pass 的写法与法线外扩原理先给描边 Pass 的核心代码。这个 Pass 要做三件事把顶点变换到裁剪空间、沿法线方向外扩、只渲染背面。Pass { Name OUTLINE Cull Front ZWrite Off HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc float _OutlineWidth; float4 _OutlineColor; struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; }; struct v2f { float4 pos : SV_POSITION; }; v2f vert(appdata v) { v2f o; float4 clipPos UnityObjectToClipPos(v.vertex); float3 viewNormal mul((float3x3)UNITY_MATRIX_IT_MV, v.normal); // 关键修正让法线方向的 z 分量变薄避免近大远小导致的描边粗细异常 viewNormal.z -0.5; // 按裁剪空间的 w 缩放外扩量保证透视投影下描边宽度相对稳定 clipPos.xy viewNormal.xy * _OutlineWidth * clipPos.w; o.pos clipPos; return o; } fixed4 frag(v2f i) : SV_Target { return _OutlineColor; } ENDHLSL }这里有个细节容易踩坑直接把法线从对象空间变到视角空间然后往屏幕方向硬挤近处物体描边会变得特别粗远处细到看不见。解决办法是把外扩量乘以裁剪坐标的 w 分量因为 w 本身包含透视深度信息乘上去之后描边的视觉宽度在不同距离下基本一致。为什么viewNormal.z -0.5因为视角空间的法线 z 分量控制顶点沿深度方向的外扩程度如果不修正模型侧面会出现大面积翻边。手动压低 z 值外扩方向更贴近屏幕平面描边看起来更“薄”更干净。2.3 描边颜色与扫描进度的联动插值传统描边通常是固定颜色但这个效果我希望描边也能“响应”扫描进度扫描线扫到的地方边缘更亮没扫到的地方边缘保持低调暗色。这样整个模型的轮廓会跟着光带一起呼吸视觉层次更统一。实现也不难描边 Pass 的片元着色器里拿到世界坐标 Y跟扫描进度做一个颜色插值float _ScanOffset; float _ScanWidth; float4 _OutlineActiveColor; float _ScanIntensity; v2f vert(appdata v) { v2f o; float4 clipPos UnityObjectToClipPos(v.vertex); float3 worldPos mul(unity_ObjectToWorld, v.vertex).xyz; float3 viewNormal mul((float3x3)UNITY_MATRIX_IT_MV, v.normal); viewNormal.z -0.5; clipPos.xy viewNormal.xy * _OutlineWidth * clipPos.w; o.pos clipPos; o.worldY worldPos.y; return o; } fixed4 frag(v2f i) : SV_Target { float delta i.worldY - _ScanOffset; float scanFactor saturate(1.0 - abs(delta) / _ScanWidth); float4 outlineColor lerp(_OutlineColor, _OutlineActiveColor, scanFactor * _ScanIntensity); return outlineColor; }简单说当一个片元的国内 Y 坐标离扫描线当前位置越近scanFactor 越接近 1描边颜色就越亮。这种做法就是标题里说的“颜色插值”从基础描边色平滑过渡到高亮扫描色。3. 流光扫描与颜色插值的核心实现3.1 扫描因子的计算saturate、smoothstep 与 Pow 的组合扫描流光能不能好看很大程度取决于扫描因子的曲线形状。直接给一个硬切边界的矩形波会很生硬像是一块色板糊在模型上但过渡太均匀又缺少“光带”的脉冲感。实际操作中我习惯组合三样东西saturate 控制范围、smoothstep 做软边、pow 调浓度。先看核心公式float3 worldPos mul(unity_ObjectToWorld, v.vertex).xyz; float delta worldPos.y - _ScanOffset; float dist abs(delta) / _ScanWidth; float line 1.0 - smoothstep(0.0, 1.0, dist); float trail pow(line, _TrailFalloff);smoothstep(0.0, 1.0, dist)的作用是让扫描带从中心向两边逐渐衰减。line 越接近 1说明离扫描线越近。pow 函数进一步压缩中间灰阶让扫描带中心显得更锐利边缘更快衰退视觉上就有“一条光带扫过”的感觉。如果不需要对称衰减想让前缘锐利、后缘拖尾更长可以改成不对称公式float delta worldPos.y - _ScanOffset; float front 1.0 - smoothstep(0.0, _ScanWidth, delta); float tail pow(saturate(1.0 delta / _ScanWidth), _TrailFalloff); float scanMask max(front, tail * _TrailIntensity);这个变体更适合“探测波”的观感光带前边沿清楚利落扫过之后的余辉慢慢淡出。3.2 扫描色的渐变与主色混合拿到扫描因子之后下一步就是颜色插值。流光效果不是说扫描带一种颜色就完了而是让扫描带本身从色 A 渐变到色 B扫过的区域又可以叠加主色。我用两层 lerp 解决问题float grad saturate((worldPos.y - _ScanOffset _ScanWidth) / (_ScanWidth * 2.0)); half3 scanGradient lerp(_ScanColorA, _ScanColorB, grad); half3 mixedColor lerp(_BaseColor.rgb, scanGradient, scanMask * _ScanIntensity);先根据位置把扫描色从 A 插到 B得到随扫描进度动态变化的渐变色再用 scanMask 控制扫描色和主色的混合比例。扫描带越强扫描色占比越高。这样颜色过渡是连续的不会出现色阶断层。NOTE如果你接的是线性空间项目记得保证 _BaseColor 和两个扫描色的值都是经过线性空间校正的。很多项目把颜色调来看去总觉得“脏”不一定是 Shader 逻辑问题而是 sRGB 和线性空间混用导致颜色偏灰。3.3 主 Pass 完整代码与参数解析主 Pass 的完整代码可以这么写片元阶段把扫描因子、流光渐变色、高亮描边整合到一起Pass { Name SCAN_MAIN Cull Back ZWrite On HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc float4 _BaseColor; float4 _ScanColorA; float4 _ScanColorB; float _ScanWidth; float _ScanOffset; float _ScanIntensity; float _TrailFalloff; float _GlowStrength; struct appdata { float4 vertex : POSITION; }; struct v2f { float4 pos : SV_POSITION; float3 worldPos : TEXCOORD0; }; v2f vert(appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.worldPos mul(unity_ObjectToWorld, v.vertex).xyz; return o; } half4 frag(v2f i) : SV_Target { float delta i.worldPos.y - _ScanOffset; float dist abs(delta) / max(_ScanWidth, 0.001); float line 1.0 - smoothstep(0.0, 1.0, dist); float trail pow(line, _TrailFalloff); float grad saturate((delta _ScanWidth) / (_ScanWidth * 2.0)); half3 scanGradient lerp(_ScanColorA.rgb, _ScanColorB.rgb, grad); half3 finalColor lerp(_BaseColor.rgb, scanGradient, trail * _ScanIntensity); finalColor scanGradient * line * _GlowStrength; return half4(finalColor, 1.0); } ENDHLSL }颜色插值的核心逻辑全在这一段里。_GlowStrength 负责给扫描核心线加亮当这个值超过 1 时输出颜色会高于纯白亮度方便配合 Bloom 后处理。这样做能让扫描光带比普通漫反射颜色更亮画面更有能量感。排序一下各参数的作用参数推荐范围作用_ScanWidth0.8 - 1.5扫描光带宽度世界单位_ScanSpeed0.2 - 0.5扫描线在 C# 驱动下的移动速度_ScanIntensity0.8 - 1.5扫描色与主色混合强度_TrailFalloff1.5 - 3.0拖尾衰减速度越大边缘越窄越锐_GlowStrength0.5 - 1.5核心线高光强度超过 1 可配合 Bloom_OutlineWidth0.02 - 0.06描边粗细跟模型比例相关4. 驱动、调试与性能优化4.1 C# 脚本控制扫描进度Shader 结构定了接下来要让扫描线动起来。我的做法是在脚本里维护一个扫描偏移值用MaterialPropertyBlock或者直接设置材质属性传入 Shader。using UnityEngine; public class ScanEffectController : MonoBehaviour { public Material targetMaterial; public float range 5f; public float speed 0.4f; private float offset; void Update() { offset speed * Time.deltaTime; if (offset range) { offset - range; } targetMaterial.SetFloat(_ScanOffset, offset); } }这个脚本放在摄像机上所有使用同一个材质实例的模型就能同步扫描。给每个物体独立扫描相位就需要每个物体挂脚本并设置各自的材质实例。还有一个小技巧如果场景里有十来个物体共用同一个材质直接修改sharedMaterial可以批量同步扫描但要留意必须保证它们真的共用同一个材质对象而不是各创建了一份。各建一份会让每帧材质 SetFloat 产生额外开销。4.2 调试 Shader 的技巧与视觉检查调试这种带动态参数的 Shader我强烈建议先把中间结果直接输出到屏幕。比如想确认扫描因子到底长什么样就临时把片元颜色改成return half4(trail, trail, trail, 1.0)。这样能立刻看到扫描带在模型上的分布、过渡是否均匀、是否有断层。还有一点扫描线出现块状感或者抖动优先检查_ScanOffset是否在很远的范围里累计了数值。浮点数精度在大数值下会出问题扫描位置越界后可能会看到线条抖动。把偏移用取模限制在有效范围内问题基本就消失了。另外移动端调试时可以观察 Overdraw。描边 Pass 和主 Pass 合起来每个物体的绘制次数会翻倍确认目标机器上 Performance 面板的 Batch 数量是否符合预期。4.3 性能与合批注意事项这个效果本身是纯颜色计算加坐标运算没有额外贴图采样片元开销很低。真正的成本在 Pass 数量上——一个物体画两遍Draw Call 会翻倍。对于大量小物件的扫描场景这个成本要提前评估。如果碰到性能瓶颈有几个方向可以优化如果能接受简化可以把描边 Pass 和主 Pass 合并进同一个 VERTEX 阶段处理用顶点色或者 UV 通道控制是否外扩缺点是描边效果没那么精致。 描边 Pass 只写颜色不写深度能减少一部分带宽消耗但前提是不会和其他半透明物体混合出奇怪的排序问题。 URP 项目里想兼容 SRP Batcher需要把 Shader 的属性抽到CBUFFER_START(UnityPerMaterial)里不然每次 SetFloat 都会打断合批。5. 常见问题与扩展玩法5.1 常见问题速查表现象原因解决方案描边宽度随相机距离忽大忽小没有乘裁剪坐标 w用clipPos.xy viewNormal.xy * _OutlineWidth * clipPos.w模型旋转后描边翻边、穿模法线方向不连续或背面扩张太猛压低viewNormal.z检查模型法线是否平滑扫描带出现明显块状断层扫描宽度太窄或 float 精度不够调大_ScanWidth限制_ScanOffset范围多物体扫描不同步每个物体材质是独立实例统一用同一个 sharedMaterial 或同一脚本统一设值URP 里效果不显示Pass 的 LightMode 标签缺失增加Tags { LightMode UniversalForward }扫描颜色偏灰偏脏线性空间和伽马空间混用检查颜色值的空间转换或把颜色改成在 Properties 面板手动调整5.2 从一条扫描线到多线、全局扫描这套方案的核心是“扫描因子驱动颜色插值”所以换驱动变量就能扩展出很多玩法。把世界坐标 Y 换成模型 UV 的 V 通道就能实现贴图花边沿方向流动换成模型局部坐标 Y扫描线就会跟随模型自身朝向适合角色技能特效。想要多条扫描线交错扫可以在片元阶段循环计算多个扫描偏移值取 max 叠加亮度。想要定点扫描提示就以扫描点为圆心做世界坐标距离场用同样的 smoothstep 公式生成圆形扩散波。根据我个人经验做这类特效最重要的是把“mask”先调顺再谈颜色。我踩过一次坑一开始纠结扫描颜色怎么调都难看后来把扫描因子强行输出为灰度图才发现是 gamma 空间和线性空间混用导致颜色过饱和。先把 mask 的梯度曲线调到平滑自然再叠颜色插值和 Bloom整个效果一下就干净了。这个 Shader 后续还能继续扩展加噪音扰动扫描边缘、用顶点色控制扫描强度、接 VFX Graph 或者 Timeline 统一控制扫描时间轴方向很多但核心的描边、颜色插值、扫描因子这套骨架已经足够你去搭各种变体了。