news 2026/10/16 10:05:18

Unity Shader 实战:扫描光带与流光描边效果实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Unity Shader 实战:扫描光带与流光描边效果实现

做项目时经常会碰上这种需求:角色或者道具被扫描的时候,模型表面有一条光带从下往上扫过,光带本身带着蓝紫渐变,扫过的地方还留着暗色余晖,整个模型的边缘再勾一圈发光的轮廓线。这个效果在打击反馈、试炼副本、场景探测里都很常见,做好了能给画面质感拉高一大截。

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 后处理。这样做能让扫描光带比普通漫反射颜色更亮,画面更有能量感。

排序一下各参数的作用:

参数推荐范围作用
_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 统一控制扫描时间轴,方向很多,但核心的描边、颜色插值、扫描因子这套骨架已经足够你去搭各种变体了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 10:03:21

面对“作业7”式残缺任务,如何用五步闭环实现高质量交付

看到“作业7,。。,”这个标题时,我第一反应是愣了两秒。没有正文、没有关键词、没有摘要,整个需求列表里就躺着这么一串字符。换成刚入行那会儿,我肯定会截个图发到群里说“这不完整,没法做”&a…

作者头像 李华
网站建设 2026/10/16 10:01:33

音素与三音素:语音识别建模单元从理论到工程落地

1. 从音素到三音素:语音识别里的建模单元演进搞语音识别这行的,绕不开一个最基础的问题:到底拿什么当建模单元。你不可能把一整句话当成一个原子去识别,那样数据稀疏到没法训练;你也不能细到每个采样点,那样…

作者头像 李华
网站建设 2026/10/16 9:59:51

Axure RP9免安装版部署全解析:运行库检测、元件库随行与配置迁移

简介:Axure RP 9免安装版资源包专为产品经理、UI/UX设计师、前端开发及需要快速产出交互原型的团队准备,覆盖从低保真线框到高保真可点击原型的常用工作流。压缩包共474个文件,大小约26.25MB,内部以可执行主程序、运行依赖&#x…

作者头像 李华
网站建设 2026/10/16 9:58:28

业务建模与GitHub Copilot协同:用BMAD v6契约驱动高质量AI代码生成

1. 为什么需要把业务建模和 AI 编程助手放在一起 先说一个我观察到的现象:最近两年,团队里用 GitHub Copilot 的人越来越多,但同样是用了 AI 辅助编程,产出质量的差距却大到离谱。有的人用 Copilot 一个下午能把一个带校验、带事务…

作者头像 李华
网站建设 2026/10/16 9:57:13

VC6.0直连三菱PLC的MC协议上位机通信系统

简介:本资源是一套基于Visual C 6.0开发的三菱PLC上位机编程软件完整源码工程,面向工业自动化领域初/中级C开发者、工控软件学习者及MFC实践者,解决PLC通信协议解析、上位机界面设计与调试功能集成等核心问题。压缩包共60个文件,含…

作者头像 李华
网站建设 2026/10/16 9:57:08

SSL证书怎么选?DV、OV、EV与免费证书的区别与部署避坑指南

前阵子一个做独立站的朋友半夜给我打电话,说他的网站在浏览器里弹了一整屏的“您的连接不是私密连接”,用户全跑了。我远程登上去一看,证书过期三天,他完全没注意到。他问我:当初买证书的时候选的是最便宜的&#xff0…

作者头像 李华