做项目时经常会碰上这种需求:角色或者道具被扫描的时候,模型表面有一条光带从下往上扫过,光带本身带着蓝紫渐变,扫过的地方还留着暗色余晖,整个模型的边缘再勾一圈发光的轮廓线。这个效果在打击反馈、试炼副本、场景探测里都很常见,做好了能给画面质感拉高一大截。
Unity Shader 要做到位,其实核心就三件事:描边、颜色插值、扫描驱动。描边负责轮廓,颜色插值负责流光渐变,扫描驱动负责让光带动起来。这篇博文我会直接给出一套可跑的方案,从技术选型讲到具体代码,再讲驱动调试和性能优化。适合理过 Shader 但还在摸索阶段的开发者,也适合需要快速出扫描特效的 TA 或美术。
先说结论:这个效果不需要 C# 每帧改网格,也不需要依赖后处理,单个材质、两个 Pass 就能跑,移动端也能很流畅。
1. 效果拆解与技术选型
1.1 描边方案的取舍
描边技术大概分三类:基于法线外扩的背面渲染、基于屏幕后处理的边缘检测、基于几何着色器生成边线。屏幕后处理适合整屏统一描边,但没法单独对某个模型做“扫描时亮边”的局部效果;几何着色器方案在移动端兼容性差,而且引入额外几何管线很麻烦。
这个效果最终选了“法线外扩 + Cull Front”的经典方案:额外开一个 Pass,把模型背面的顶点沿法线方向往外推一圈,然后只画背面。由于原始模型会盖住内部,外扩后露出来的部分就形成一圈轮廓。
这套方案的优势很明显:独立作用在单个物体上,不受场景其他物体影响;描边颜色可以随时改,也能参与扫描因子插值;几乎没有额外贴图采样开销,适合移动端。缺点是依赖模型封闭性,半透明网格或片状模型效果会打折扣,这个后面问题部分会说。
1.2 扫描驱动为什么要用世界坐标
扫描光带的移动方向和控制轴是需要提前定死的,我建议直接用世界坐标 Y 轴。原因很简单:如果用模型的局部坐标 Y 轴,模型旋转 90 度后扫描线就变成横向扫了,两个朝向不同的角色站在一起视觉会很乱。如果用 UV 坐标,遇到 UV 不连续或图集化处理的模型,扫描线会在接缝处跳变,效果直接崩坏。
用世界坐标 Y 轴时,不管模型怎么旋转摆放,光带永远从下往上走,观众能直观地理解为“这个物体正在被探测”。跨多个物体同步扫描时也只需要共享同一个扫描进度值,坐标统一,省心。
2. 从零搭建描边 Shader
2.1 Shader 整体框架与 Pass 规划
先规划整体脉络。这个 Shader 不分光照,按自发光思路处理,否则扫描线的亮度会被场景光照干扰,颜色不可控。结构上就两个 Pass:
描边 Pass:Cull Front,顶点外扩,渲染背面轮廓。 主 Pass:Cull 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 后处理。这样做能让扫描光带比普通漫反射颜色更亮,画面更有能量感。
排序一下各参数的作用:
| 参数 | 推荐范围 | 作用 |
|---|---|---|
_ScanWidth | 0.8 - 1.5 | 扫描光带宽度,世界单位 |
_ScanSpeed | 0.2 - 0.5 | 扫描线在 C# 驱动下的移动速度 |
_ScanIntensity | 0.8 - 1.5 | 扫描色与主色混合强度 |
_TrailFalloff | 1.5 - 3.0 | 拖尾衰减速度,越大边缘越窄越锐 |
_GlowStrength | 0.5 - 1.5 | 核心线高光强度,超过 1 可配合 Bloom |
_OutlineWidth | 0.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 统一控制扫描时间轴,方向很多,但核心的描边、颜色插值、扫描因子这套骨架已经足够你去搭各种变体了。