☰
手写Shader实现“验牌扫描”特效:从UV坐标到噪声混合
2026/10/14 23:53:17 网站建设 项目流程

我刷到那句魔性台词的时候已经不算早了。短视频平台里,“我要验牌”一出现,评论区就自动开始接龙,紧接着有人把它剪进各种画面里,配上一顿猛如虎的操作。说实话,我第一反应不是跟着乐,而是冒出一个图形工程师的职业病:这句话背后那种“机械扫描、死盯对象、屏幕外有人扛着仪器”的紧张感,能不能用Shader直接做出来?

于是就不务正业地花了两个晚上,写了三版不同风格的扫描验证效果。整个项目没有多复杂,核心就是一个片元着色器加一个控制脚本,但它牵涉到UV坐标、时间动画、边缘检测、噪声混合和移动端性能取舍这些东西。这篇文章就把完整过程、代码、参数以及我踩过的几个坑都记下来,想顺手复现的朋友可以直接抄作业。

1. 一句“我要验牌”是怎么变成Shader选题的

1.1 从玩梗到拆解需求,技术人眼里看到的是“验证”这个动作

我说句实话,大多数刷到这种网络热梗的人,注意力都在台词和剪辑节奏上,很少会去想画面的底层结构。但“验牌”这个动作在视觉上非常典型:一个主体被强行锁定,一条光带快速扫过,周围出现噪点和刻度,好像整个场景瞬间进入检测模式。讽刺也好,恶搞也好,这种“被机器审视”的戏剧感,本身就是很好的渲染练习题材。

正常画面进入这种模式时,观众感受到的是压迫感和科技感,而这种感受可拆成四样东西:

  • 一条往复运动的强光扫描带。
  • 画面主体在扫描瞬间被突然提亮、提饱和度。
  • 全局出现颗粒噪点,打破原本干净的图像。
  • 边缘细节被锐化,让人产生“正在被逐行检查”的错觉。

把这四样放进一个片元着色器里,就能复刻出那种“仪器验货”的视觉体验。我当时给自己定的目标也很简单:不动剪辑软件,不做多层素材叠加,用纯GPU渲染的方式,把一张普通图片变成正在被扫描验证的画面。

1.2 为什么不用PR和AE,偏要手写一组Shader

有朋友听到这问过我:这不就是加一层扫描线+调个LUT吗,剪辑软件几分钟就能搞完,干嘛绕远路去写Shader?

这个问题问得很合理。剪映、PR一类的工具确实有扫描线转场,甚至还有专门的“故障特效”包,直接拖到轨道上就能用。但剪辑软件有它解决不了的问题:第一,扫描位置和画面内容没有联动,你没法让高光线刚好停在被验证的物体上,再让画面跟着抖一下;第二,效果是离线渲染的,参数调节一次就要重新导出,改个扫描速度得等半天;第三,未来想把这套效果接到摄像头实时画面里,剪辑软件就完全无能为力了。

Shder的路径纯粹是实时的,GPU并行处理好每一帧,扫描进度可以绑定任意参数,还能与模型、灯光甚至音频互动。它天然适合做这类“效果原型”,验证想法跑得快,改参数也更灵活。换算到项目里,相当于帮你省下了一晚上改MOV素材的时间。

2. Shader开始前必须想清楚的三个设计决策

2.1 用UV坐标系来模拟“扫描仪做圆周运动”

扫描效果最底层的逻辑,是让一个高亮区域沿着屏幕或模型表面移动。实现这个动作前,得先理解UV坐标这个东西。你可以把UV理解成一张画布上的网格坐标,左下角是(0,0),右上角是(1,1),画面里每一个像素都对应一个独特的UV坐标值。我们要控制的就是这个值在时间维度上发生变化。

我用的方式是只动U轴或者V轴的一端。比如让扫描线从下往上走,核心一句就是:

float scanY = frac(_Time.y * _ScanSpeed);

_Time.y是Unity内置的时间变量,单位是秒,乘以速度以后取小数部分,scanY的值就会永远在0到1之间循环。这条线再配合一个带宽度的高光,就形成了一个扫描带。这个模型的优势在于不用关心屏幕分辨率,无论画面是横屏还是竖屏,它都永远按照整体高度百分之多少来移动。实际开发时,这句代码也会被复用在做页面上扫光、雷达扫描线、交互引导高亮这些需求上,几乎不用改。

2.2 视觉层级的优先级比“花哨”重要

写这种效果最容易翻车的地方,是让自己一次性把看到的几乎所有特效全部塞进去。颜色偏移、模糊、摇晃、噪点、网格、扫光、边缘破碎全上,结果就是一锅粥。我以前爱吃这个亏,后来自己总结了一条顺序规则:先保主体清晰,再做氛围,最后才加干扰。

此次实现里,我把层级排成四层:

  • 底层:原始纹理照常显示,但对比度和饱和度轻微上调。
  • 扫描层:一条带有羽化边沿的亮线,在线条中心处把亮度推到最高。
  • 环境层:全局噪声强度控制在15%以内,避免把画面弄脏。
  • 焦点层:扫描线附近追加边缘高亮,让“被验证物”的动态更突出。

这个顺序背后的原则是,观众第一时间要知道这个东西在哪里被扫描,然后才感受机械感。顺序一颠倒,效果就变成屏幕坏了而不是仪器在验东西。

2.3 让循环看起来不像GIF,关键在打破周期性

Shader里的效果大多是循环的,扫描线扫上去、扫下来、再扫上去。如果整个周期做得太顺滑,几秒钟后观众就会敏锐地抓住规律,效果立刻露馅。这里有一个小技巧:让扫描线的周期与噪点的周期完全不同步。

统一在一秒内循环,会被发现是机械重复;但让扫光每2.3秒循环一次,让噪点的变化每0.7秒换一个随机状态,再让边缘强度的波动周期变成扫描线的素数倍,观众就不会察觉这是循环。我们的感知系统对同时发生的低频重复非常敏感,对错相位的复合运动相对更迟钝。说得直白点,就是三个动画各跑各的,画面就会看起来“活着”。

在这个项目里,我用了一个简单的公式应付这种错位:

  • 扫描线时间:_Time.y * 0.37,这样2.7秒循环一次。
  • 噪点时间:floor(_Time.y * 23.0),每帧重新随机化。
  • 边缘强弱:用sin(_Time.y * 1.73)做一个缓慢摇摆。

这些数值没有玄学,就是拿素数做乘法,让它尽量错开。

3. Unity里手写一个“验牌扫描”Shader的完整过程

3.1 先跑通一个最小可用版本

写这类效果我习惯先做最小用例,能跑起来看效果再逐步叠加,不然一上来代码几百行,报错都不知道去哪查。首个版本只做一件事:显示原图,并让一条亮线从上往下扫。

下面这个版本的代码很短,适合拿来验证管线:

Shader "Custom/ScanBasic" { Properties { _MainTex ("Texture", 2D) = "white" {} _ScanSpeed ("Scan Speed", Range(0.1, 2.0)) = 0.5 _ScanWidth ("Scan Width", Range(0.01, 0.3)) = 0.08 _LineColor ("Line Color", Color) = (0.0, 1.0, 0.6, 1.0) } SubShader { Tags { "RenderType"="Opaque" } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; float _ScanSpeed; float _ScanWidth; float4 _LineColor; v2f vert(appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = v.uv; return o; } fixed4 frag(v2f i) : SV_Target { float scanY = frac(_Time.y * _ScanSpeed * 0.37); float dist = abs(i.uv.y - scanY); float band = 1.0 - saturate(dist / max(_ScanWidth, 0.001)); band = smoothstep(0.0, 0.6, band); fixed4 col = tex2D(_MainTex, i.uv); col.rgb = lerp(col.rgb, _LineColor.rgb, band * 0.85); return col; } ENDCG } } }

把这段Shader挂到一个平面材质上,给平面上贴上测试图的纹理,播放时就能看到绿色亮线上下扫动。这里的smoothstep是为了把扫描线边缘做成柔和的羽化过渡,不用硬切是因为硬边在低分辨率显示下会出现很丑的锯齿闪烁。

3.2 加入噪声、色调变化和边缘锐化

最小版本跑通以后,才是真正的乐趣。我在基础版本上追加了三个模块:随机噪点干扰、扫描线附近的色调偏移、一个轻量轮廓增强。直接上一个可以复制使用的完整版本:

Shader "Custom/YanPaiScanner" { Properties { _MainTex ("Texture", 2D) = "white" {} _ScanSpeed ("Scan Speed", Range(0.1, 2.0)) = 0.5 _ScanWidth ("Scan Width", Range(0.01, 0.3)) = 0.08 _NoiseStrength ("Noise Strength", Range(0.0, 0.5)) = 0.15 _EdgeStrength ("Edge Strength", Range(0.0, 2.0)) = 0.8 _LineColor ("Line Color", Color) = (0.0, 1.0, 0.6, 1.0) } SubShader { Tags { "RenderType"="Opaque" } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; float _ScanSpeed; float _ScanWidth; float _NoiseStrength; float _EdgeStrength; float4 _LineColor; float hash21(float2 p) { p = frac(p * float2(273.7, 218.3)); p += dot(p, p + 13.27); return frac(p.x * p.y); } v2f vert(appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = v.uv; return o; } fixed4 frag(v2f i) : SV_Target { float scanY = frac(_Time.y * _ScanSpeed * 0.37); float dist = abs(i.uv.y - scanY); float band = 1.0 - saturate(dist / max(_ScanWidth, 0.001)); band = smoothstep(0.0, 0.6, band); fixed4 col = tex2D(_MainTex, i.uv); // 扫描线区域内的色调偏移 col.rgb = lerp(col.rgb, col.rgb * float3(1.1, 1.2, 0.9), band * 0.5); col.rgb = lerp(col.rgb, _LineColor.rgb, band * 0.6); // 动态噪声,紧贴扫描线出现 float hashNoise = hash21(i.uv + floor(_Time.y * 23.0)); col.rgb = lerp(col.rgb, float3(1.0, 1.0, 1.0), hashNoise * _NoiseStrength * (0.3 + band)); // 简单轮廓增强:用相邻像素采样算亮度差 float2 off = float2(1.0, 1.0) * 0.0016; float lumaCenter = dot(col.rgb, float3(0.299, 0.587, 0.114)); float lumaRight = dot(tex2D(_MainTex, i.uv + float2(off.x, 0.0)).rgb, float3(0.299, 0.587, 0.114)); float lumaUp = dot(tex2D(_MainTex, i.uv + float2(0.0, off.y)).rgb, float3(0.299, 0.587, 0.114)); float edge = saturate(abs(lumaCenter - lumaRight) + abs(lumaCenter - lumaUp)); col.rgb += edge * _EdgeStrength * (band * 0.6 + 0.15); return col; } ENDCG } } }

这套代码的完整版已经有肉眼可见的“扫描验物”味道:亮线扫过的地方颜色偏绿、边缘发硬,扫描线周围有快速闪烁的颗粒,看起来像是某种传感器正在努力辨认这个画面里的东西。hash21这个函数的用处是生成稳定的随机噪点,靠传入坐标和时间的分层结果来保证每一帧的颗粒都不一样。轮廓增强用的是两方向亮度差的近似Sobel,比完整四方向的Sobel便宜一半,视觉效果在这个场景里已经足够。

3.3 三种接入画面的方式与选择建议

写好的Shader要真正“看得到”,还得选一个容器。对比下来,有三种常见接入方式,各自的差别如下:

接入方式适用场景优缺点
相机后处理全屏实时扫描优势是效果覆盖整个视口,适合录屏和直播;缺点是需要管理多个相机的触发顺序
UI材质只对某张RawImage生效优势是方便和UI按钮联动,适合做H5或应用内的功能卡片;缺点是效果局限在面板区域
模型材质扫描某个3D物体的表面优势是能配合镜头运动做出空间感;缺点是需要处理UV接缝和灯光关系

我这个项目选择的是UI材质的方式,把测试图贴在RawImage上,再挂上这层材质,这样可以直接在手机上点亮某个界面时触发扫描感觉。如果只是想在引擎里快速预览,相机后处理更方便,直接在摄像机上挂一个对应脚本即可。现阶段做原型,不用追求一版到位,先选你项目里最顺手的容器就好。

4. 调试实战:参数、性能与三个坑

4.1 关键参数应该怎么调

完整Shader写出来以后,真正决定效果自然度的不是代码逻辑,而是参数。我把调好的参数整理成一张表,方便各位照着起手:

参数数值范围推荐值调节思路
Scan Speed0.1 ~ 2.00.5数值调高会变成抽搐式扫描,适合搞怪
Scan Width0.01 ~ 0.30.08太窄像细线,太宽直接糊成一块色层
Noise Strength0.0 ~ 0.50.15超过0.3会有严重的画面污染感
Edge Strength0.0 ~ 2.00.8数值越高画面越有“锐利仪器”感
Line Color任意(0.0, 1.0, 0.6)绿色最贴合仪器感,青色和黄色也可选

这里面最值得玩的是Scan Width和Noise Strength。Scan Width决定了这个扫描动作是“仔细检查”还是“用吹风机扫了一下”,值越小,焦点越紧,压迫感越强;Noise Strength决定了机器的可信度,值太低没有检查氛围,太高又像是电视没信号。我做测试时先从推荐值开始,再按视频片段的情绪逐项加减,每次只改一个参数。

4.2 移动端与PC端渲染效果的兼容差异

同一个Shader在PC上看可能很正常,到了手机上就是另一副模样,原因通常是精度和动态分支。这部分我展开说说。

Unity里cg/hlsl默认的fixed类型是半精度,在PC上老显卡能处理得不错,但在移动GPU上经常出现颜色断层。扫描线的羽化过渡本来是一段连续变化的亮度,半精度下会被截断成肉眼可见的阶梯状条纹。解决办法很简单:涉及到UV位置和颜色计算的变量,尽量用float。

另一个大坑是动态分支。不少Shader里会出现:

if (band > 0.5) { // 做某些复杂操作 } else { // 做其他操作 }

这句代码写起来直观,但在移动端GPU上,分支判断会让相邻像素走不同的指令路径,最终导致边界处闪烁和性能下降。改法是用step和lerp把分支改成连续过渡:

float upperFactor = saturate(band * 10.0); col.rgb = lerp(col.rgb, processedColor, upperFactor);

4.3 我亲手踩过的三个坑

第一个坑是扫描线断裂。最开始我的代码用的是:

float scanY = _Time.y * _ScanSpeed % 1.0;

在GLSL里取模没问题,但在某些平台负值处理不一致,导致扫到边缘时高光线突然断开。换成frac()函数之后稳定了许多。

第二个坑是编辑器里点击暂停以后,Shader效果也一起冻住了。一开始我以为脚本出问题,后来反应过来_Time.y在编辑器暂停时不会继续更新。要测试某个特定扫描位置,直接动滑动条不好使,我加了一个_CustomTime属性作为手动覆盖参数,这样可以在编辑器静止状态下精确预览扫描线停在哪个坐标。

第三个坑是RawImage默认为半透明渲染,颜色总是叠加出奇怪的高亮。解决方法是在Shader的SubShader里加一句Blend One OneMinusSrcAlpha,或者把UI面板的Canvas Render Mode改成不透明模式。这个问题是UI材质和常规3D材质最大的习惯差异。

5. 从Demo到真实项目的一步

5.1 把扫描效果接到实时摄像头画面

有一版我把贴图换成了WebCamTexture,直接对摄像头画面做扫描效果。理论上就是把纹理采样源替换一下,代码几乎不变。真正麻烦的地方在于摄像头的画面方向:竖屏时手机摄像头输出的旋转角千奇百怪,有的机型需要旋转90度,有的需要镜像。建议在真机上先用最朴素的画面显示测试脚本,把旋转问题调好,再接Shader,不然排查起来半天都找不到方向。

摄像头原始画面的色彩偏灰偏淡,为了让扫描效果有点电子仪器的味道,我在Shader里进一步提高了对比度,又把去色之后的图像和原始图像做了Lerp,相当于让被扫描区域的颜色浓度局部提升。这样扫描线扫过人脸或者物体时,视觉上会有一个“正在识别”的过程,而不是简单的一层绿光盖上去。

5.2 用扫描进度反推镜头动画

扫描线不只是用来显示效果,它的进度完全可以作为整个画面的节拍器。我第二个版本里把扫描进度从Shader里回传给C#脚本,让摄像机跟着扫描线做小幅推拉,扫描到画面中心时镜头拉近,离开画面后回位。视觉上就像那个仪器真的在对准目标物。

回传的方式不需要写复杂的Rendering Abstraction,简单粗暴在Update里读取材质属性:

using UnityEngine; public class ScannerController : MonoBehaviour { public Material scanMaterial; public float currentProgress; void Update() { currentProgress = Mathf.Repeat(Time.time * 0.37f, 1f); scanMaterial.SetFloat("_CustomTime", currentProgress); float shake = Mathf.Sin(currentProgress * Mathf.PI * 4f) * 0.02f; transform.localPosition = new Vector3( Mathf.Sin(currentProgress * 30f) * 0.015f, shake, 0f ); } }

这样做出来的画面比单纯Shader扫描多了一层动态关系:扫描线扫到哪里,镜头就有一点支撑感。用这招还能把扫描进度接到音频播放、UI文字跳转或者粒子发射器上,整体交互会非常统一。注意这里的Mathf.Repeat只是为了跟Shader内部的周期保持一致,真要在音画同步场景里,还是用一个共同的量化驱动变量最稳。

5.3 技术层面之外的一点体会

整个项目最值钱的部分,我觉得不是那几段Shader代码,而是“把一个流行梗转化成自己练习方向”的思考过程。你不需要为每个热点写完整工具,但完全可以把里面最吸引你的视觉切片拆出来,做成可以复用的渲染能力。下一次产品要做AR试戴、扫描开箱或者镜头锁定识别,这些代码和思路都能直接平移过去。

我也提醒一句,网上热梗素材的版权边界比较模糊,做练习Demo自娱自乐没问题,真要发布上线,建议换素材或彻底重绘视觉元素,别把原视频内容直接嵌进商业项目里。这既是版权问题,也是职业习惯问题,做图形的人对素材来源更容易疏忽,我一开始也差点忘了。

最后分享一个我调整效果时最喜欢用的顺序:先关掉噪声和边缘增强,只看扫描线本身;确认线条的运动节奏对了以后,再一层一层打开其他效果。这样每次只引入一个变量,屏幕上出现了什么异常你都能马上定位到是哪个模块的问题。这个习惯帮我节省了很多调试时间,也让我写出来的Shader给别人维护时不至于变成一团乱麻。

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

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

立即咨询