资讯动态

手写Shader实现“验牌扫描”特效:从UV坐标到噪声混合

发布时间:2026/10/14 23:53:23 来源:尧图企业网站定制
我刷到那句魔性台词的时候已经不算早了。短视频平台里“我要验牌”一出现评论区就自动开始接龙紧接着有人把它剪进各种画面里配上一顿猛如虎的操作。说实话我第一反应不是跟着乐而是冒出一个图形工程师的职业病这句话背后那种“机械扫描、死盯对象、屏幕外有人扛着仪器”的紧张感能不能用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 { RenderTypeOpaque } 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 { RenderTypeOpaque } 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给别人维护时不至于变成一团乱麻。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑