Unity Shader Graph实现场景扫描光效果:从原理到实战 1. 项目概述从“扫描光”到视觉叙事在游戏和交互式体验中视觉引导是无声的叙事者。玩家或用户的视线焦点、情绪氛围乃至对场景重要性的认知常常被精心设计的视觉效果所牵引。其中“场景扫描光”就是一种极具表现力和功能性的视觉语言。它模拟了一道高亮、锐利的光束或光波在场景表面快速扫过常用于揭示隐藏信息、标记关键路径、触发机关动画或是单纯地营造科幻、悬疑的氛围。回想一下那些经典场景主角使用扫描仪侦测隐形敌人飞船的雷达波扫过未知星域或是解密游戏中一道光揭示墙上的暗纹——其核心视觉表现往往就是这种“扫描光”效果。过去这类效果的实现重度依赖程序员编写复杂的片段着色器代码涉及射线步进、深度比较、屏幕空间坐标变换等概念门槛较高。而 Unity3D 的 Shader Graph 可视化着色器编辑器的出现彻底改变了这一局面。它让美术师和 TA技术美术能够通过节点连线的方式“绘制”出复杂的着色器逻辑无需深入 HLSL 代码。本项目要实现的正是利用 Shader Graph构建一个高效、灵活、可高度定制的场景扫描光效果。我们将不仅实现基础的光束扫过还会深入探讨如何控制扫描形状、响应深度、与场景物体交互并最终封装成一个易于复用的 Shader Graph 子图Sub-graph或自定义函数Custom Function供项目中的各种扫描需求调用。这个教程适合有一定 Unity 和 Shader Graph 基础的开发者、技术美术以及对程序化特效感兴趣的美术同学。即使你是着色器新手只要跟着步骤一步步来也能掌握其核心思想并实现效果。我们将从原理分析开始到节点搭建、参数优化最后分享一些实战中提升性能和表现力的技巧。2. 核心原理与设计思路拆解在动手连接节点之前我们必须先想清楚“扫描光”在渲染管线中究竟是如何被计算出来的。这决定了我们 Shader Graph 的整体架构和节点选型。2.1 效果的本质空间中的距离场与时间函数抛开“光”这个具象概念扫描效果的核心可以抽象为两个关键要素空间信息我们需要知道场景中每一个像素片元在世界空间或物体空间中的位置。扫描波函数我们需要一个随时间变化的函数来定义“扫描前沿”在空间中的位置。最简单的是一个沿着某个方向比如X轴移动的平面。效果实现的基本公式可以概括为效果强度 f( 空间坐标, 时间, 扫描参数 )。当像素位于扫描波函数定义的“前沿”附近时我们就让它发光。在 Shader Graph 中我们通常使用Position 节点来获取空间坐标。这里有三种常见选择Object Space相对于模型自身原点。优点是效果会随着模型移动/旋转而移动适合附着在特定物体上的扫描如扫描一个宝箱。World Space相对于世界坐标系原点。优点是效果在场景中是全局固定的不会随单个物体移动适合环境扫描如一道从天而降扫描地面的光。View Space相对于摄像机。常用于一些屏幕空间特效但本次扫描光更推荐前两者。对于扫描波函数最常用的是平面波。假设我们让光沿世界空间 Z 轴正方向扫描那么扫描前沿就是一个平行于 X-Y 平面且 Z 坐标随时间增加的平面。对于任意一个像素的世界坐标(x, y, z)我们计算其 Z 坐标与一个随时间变化的扫描平面Z值ScanPlaneZ的差值。当z非常接近ScanPlaneZ时该像素就应该被点亮。2.2 关键技术点软化边缘与深度测试直接使用“等于”来判断会让光带变成一条无限细的硬线不真实。因此我们需要引入软化Softening。这里通常使用Smoothstep节点。Smoothstep(a, b, x)会在x处于a和b之间时返回一个从0到1平滑过渡的值。我们可以这样应用计算distance z - ScanPlaneZ距离。定义width光带宽度和feather羽化范围。使用Smoothstep(-width/2 - feather, -width/2, -distance)来生成光带前沿的平滑衰减用Smoothstep(width/2, width/2 feather, distance)生成后沿衰减。将两者相乘或相减就能得到一个中间亮、两边平滑衰减的光带。另一个至关重要的点是深度测试。如果不处理深度我们的扫描光会穿透所有物体看起来像是在空气中绘制而非在物体表面扫描。我们需要让光只出现在“当前被渲染的物体表面”上。这需要用到Scene Depth节点。通过比较当前像素的屏幕空间深度与从深度纹理中采样得到的场景深度我们可以判断该像素是否在物体表面。只有两者足够接近时才应用扫描光效果。这是实现“表面附着感”的关键。2.3 整体架构设计基于以上分析我们的 Shader Graph 主图将包含以下几个功能模块输入模块暴露给材质球 Inspector 的参数如扫描速度、方向、宽度、颜色、强度等。空间与时间计算模块获取世界位置结合时间计算当前的扫描平面位置。核心光带生成模块利用Smoothstep生成基础光带遮罩Mask。深度测试模块使用 Scene Depth 对光带遮罩进行裁剪确保光只出现在物体表面。输出与合成模块将生成的光颜色与物体原本的颜色Albedo进行混合如 Additive 叠加并输出到片元着色器。我们将采用Unlit Shader Graph作为起点因为它结构清晰专注于自定义光照计算更适合这种后处理或自发光效果。当然你也可以在 Lit Shader 中通过发射Emission通道来实现。3. Shader Graph 节点搭建全流程现在我们进入 Unity Editor开始实际的节点搭建。请创建一个新的 Unlit Graph并确保你的项目设置了可访问深度纹理通常在 URP 渲染管线的 Asset 中勾选“Depth Texture”。3.1 创建参数与基础设置首先创建我们需要的参数Properties它们最终会显示在材质球面板上ScanSpeed(Vector1): 扫描速度。建议默认值 1。ScanWidth(Vector1): 光带基础宽度。默认值 0.5。ScanFeather(Vector1): 光带羽化程度。默认值 0.2。ScanColor(Color): 扫描光颜色。默认亮蓝色或绿色。ScanIntensity(Vector1): 颜色强度乘数。默认值 2。ScanDirection(Vector3): 扫描方向向量。默认 (0, 0, 1) 表示沿世界Z轴正方向。注意这个向量需要是归一化的单位向量。在 Master Stack 的Vertex阶段目前不需要特殊操作。在Fragment阶段清空默认连接我们将从头构建。3.2 计算扫描平面位置与距离获取世界位置与时间添加Position节点将其Space设置为World。添加Time节点使用其Time输出口代表自游戏开始的总秒数。计算扫描平面扫描平面的位置可以表示为ScanPlaneOrigin ScanDirection * (Time * ScanSpeed)。其中ScanPlaneOrigin是一个可以参数化的起点为了简单我们可以先设为世界原点(0,0,0)。所以计算流程是Multiply节点Time乘以ScanSpeed参数得到当前扫描进度scanProgress。Multiply节点ScanDirection参数单位向量乘以scanProgress得到一个方向上的位移向量。可选Add节点将位移向量与一个ScanStartPosition参数相加实现扫描起始点的控制。计算点到平面的距离点P(世界位置) 到沿法线N(扫描方向) 的平面D(平面常数) 的距离公式为dot(N, P) - D。其中D是平面到原点的有符号距离。在我们的模型中D就是上一步计算出的位移向量在扫描方向上的投影长度即scanProgress因为方向是单位向量。所以Dot Product节点计算ScanDirection和World Position的点积得到该位置在扫描方向上的投影坐标projCoord。Subtract节点计算distanceToPlane projCoord - scanProgress。这个值就是当前像素到移动中的扫描平面的有符号距离。当distanceToPlane接近 0 时该点就在扫描平面上。注意这里是一个关键理解点。我们把扫描方向当作平面的法线scanProgress当作平面常数D。这样计算出的距离正值表示点在平面正方向一侧负值表示在另一侧。光带将出现在distanceToPlane接近 0 的区域。3.3 生成平滑光带遮罩定义光带范围我们希望光带以扫描平面为中心总视觉宽度约为ScanWidth并向两侧羽化ScanFeather。光带“核心亮区”的前沿在-ScanWidth/2后沿在ScanWidth/2以distanceToPlane0为原点。完全衰减的外沿在前沿的-ScanWidth/2 - ScanFeather和后沿的ScanWidth/2 ScanFeather。应用 Smoothstep对于前沿衰减Smoothstep(-ScanWidth/2 - ScanFeather, -ScanWidth/2, -distanceToPlane)。注意这里对distanceToPlane取了负号因为当点在平面负方向distanceToPlane为负且绝对值较小时它位于前沿附近。对于后沿衰减Smoothstep(ScanWidth/2, ScanWidth/2 ScanFeather, distanceToPlane)。核心技巧为了得到一个中间亮、两边平滑衰减的完整光带我们需要将两个衰减因子“合并”。最简单的方法是用1减去这两个值然后相乘mask (1 - frontFeather) * (1 - backFeather)。这样只有在两个衰减因子都为0即位于两个衰减区间之间的区域mask才为1。节点连接创建Divide节点计算halfWidth ScanWidth / 2。创建Add和Subtract节点计算前沿的min/max值frontMin -halfWidth - ScanFeather,frontMax -halfWidth。同理计算后沿的min/maxbackMin halfWidth,backMax halfWidth ScanFeather。分别创建两个Smoothstep节点按上述公式连接。创建两个One Minus节点即1减去输入分别处理两个Smoothstep的输出。用Multiply节点将两个One Minus的结果相乘得到最终的rawMask原始遮罩范围 0~1。至此你应该能看到一个随着时间移动的平滑光带遮罩。但它在场景视图中会显示在所有物体上包括天空盒看起来不真实。3.4 深度测试实现表面附着这是让效果从“空中绘图”变为“表面扫描”的魔法步骤。获取场景深度添加Scene Depth节点。它会输出当前像素对应的场景深度值通常是非线性的范围0-11为远裁剪面。获取当前像素深度添加Screen Position节点将其Mode设置为Raw。其输出的.z分量或.w分量取决于平台和渲染管线设置在URP中通常是.z就是当前被渲染物体的深度值。更可靠的方法是使用Position节点Space设置为View然后取其.z的绝对值再经过投影矩阵变换。但作为简化我们可以先用Screen Position的.z进行测试。深度比较直接比较两个深度值是否完全相等几乎不可能因为浮点数精度问题。我们需要一个容差depthTolerance。Subtract节点计算depthDiff abs(sceneDepth - pixelDepth)。Smoothstep节点depthMask 1 - Smoothstep(0, depthTolerance, depthDiff)。这样当深度差小于容差时depthMask接近1大于容差时平滑过渡到0。depthTolerance可以设为一个很小的值如 0.001。应用深度遮罩将上一步得到的rawMask与depthMask相乘finalMask rawMask * depthMask。这样只有那些既在扫描光带内又位于物体表面的像素遮罩值才高。实操心得深度测试是性能消耗相对较大的操作因为它需要采样深度纹理。如果效果用于移动平台或低端设备可以考虑将其作为一个可开关的选项。另外depthTolerance的值需要根据场景尺度调整。对于大型开放世界这个值可能需要调大否则远处的物体可能因为深度精度问题无法显示扫描光。3.5 颜色合成与最终输出生成光颜色将ScanColor参数与ScanIntensity相乘得到基础光颜色baseGlow。应用遮罩将baseGlow与finalMask相乘得到最终要添加的光照贡献glowContrib。与表面颜色混合我们需要获取物体原本的颜色。在 Unlit Graph 中你可以添加一个Sample Texture 2D节点连接到主纹理或者直接使用一个Color参数作为基础色。假设我们有一个BaseColor。混合模式选择扫描光通常是叠加在原有材质之上的发光效果因此使用Additive相加混合最为合适。即FinalColor BaseColor glowContrib。在 Shader Graph 中这意味着将BaseColor与glowContrib用Add节点相加。连接至 Master Stack将Add节点的输出连接到 Master Stack 的Color输入端口。确保Surface Type设置为Transparent如果希望背景透明或Opaque如果用于不透明物体并根据需要调整Blend Mode对于 Additive 效果通常使用OneOne混合。现在将这个 Shader Graph 保存并创建一个新的材质球使用它。将其赋给场景中的一个物体如一个 Plane 或复杂模型调整参数你应该能看到一道平滑的、只附着在物体表面的光带随时间扫过。4. 高级效果扩展与优化基础效果实现后我们可以让它变得更加强大和美观。4.1 形状控制从平面到球面波平面波扫描是最简单的但我们可以修改距离计算函数来创造其他形状。例如实现一个从某点发出的球面扩散扫描波添加扫描原点参数ScanOrigin(Vector3)。计算球面距离Distance节点计算World Position与ScanOrigin的距离得到r半径。修改扫描波函数此时的“扫描前沿”是一个半径随时间增大的球面。计算distanceToWave r - (scanProgress * WaveSpeed)。当distanceToWave接近0时该点位于球面波上。复用光带生成逻辑将distanceToWave代入之前Smoothstep的distanceToPlane部分即可生成球面波光带。4.2 添加噪声与扭曲纯平滑的光带有时显得过于“CG感”。我们可以引入噪声来打破规则性模拟光能的不稳定性或扫描设备的干扰。采样噪声纹理添加Sample Texture 2D节点使用一张Noise纹理如 Perlin Noise。为了让它随时间变化可以将World Position的XY分量与Time相加后再作为UV输入。扰动扫描距离将噪声值映射到例如[-0.1, 0.1]的范围加到distanceToPlane上distortedDistance distanceToPlane (noise * noiseStrength)。扰动光带宽度也可以用噪声值来动态调制ScanWidth让光带不同部位的宽度略有变化。4.3 性能优化与参数封装将核心功能封装为 Sub-graph我们的扫描光逻辑可能会在项目的多个Shader中复用。在 Shader Graph 中选中所有相关节点右键选择Convert To Sub-graph。这会将核心逻辑打包成一个带有输入输出端口的独立节点。之后在任何其他 Shader Graph 中像使用普通节点一样拖入这个 Sub-graph 即可。这极大地提升了可维护性和复用性。参数范围限制与提示在参数属性中可以使用[Range(min, max)]或[HDR]等特性。虽然 Shader Graph 属性面板不支持所有属性标签但良好的命名和默认值设置很重要。例如将ScanIntensity的默认值设为1并提醒用户可能需要根据 HDR 管线调整。条件编译与功能开关对于深度测试、噪声等可选功能可以通过Branch节点和布尔参数来控制。更高级的做法是使用Custom Function节点和预编译指令但这需要一些代码知识。一个简单的开关可以用Branch节点实现Branch(IsDepthTestOn, depthMask, 1.0)然后将结果与rawMask相乘。5. 常见问题与实战调试技巧即使按照教程搭建在实际项目中仍可能遇到各种问题。这里记录一些常见坑点和解决方法。5.1 效果不显示或显示异常检查深度纹理确保你的 URP/HDRP 渲染管线资源Render Pipeline Asset中启用了Depth Texture。没有深度纹理Scene Depth节点采样会返回错误结果。检查渲染队列Render Queue如果扫描光材质用于透明物体且需要 behind 其他物体显示需要正确设置渲染队列。通常透明物体在Transparent(3000) 队列。确保你的 Shader Graph 中Surface Type和Blend Mode设置正确。检查坐标空间确认Position节点使用的空间World/Object与你的扫描方向计算逻辑匹配。如果你希望光在世界中固定方向扫描却用了 Object Space 位置那么物体会带着光一起移动旋转。检查参数范围ScanSpeed过大可能导致光带一闪而过看不清。ScanWidth过小可能导致光带太细。ScanFeather过大可能导致光带整体模糊。建议从默认值开始微调。5.2 深度测试导致的边缘闪烁或断裂现象光带在物体边缘处闪烁、断裂或不连续。原因深度缓冲和深度纹理的精度限制特别是在物体边缘深度不连续处以及半透明物体的渲染顺序问题。解决方案调整depthTolerance适当增大容差值可以缓解因精度问题导致的边缘丢失。但过大会导致光“漂浮”在表面前。使用线性深度Scene Depth节点默认输出非线性深度透视投影后的。可以尝试先将其转换为线性深度使用Linear01Depth函数可通过Custom Function节点调用再进行对比。线性深度在近处精度更高可能改善近处物体的效果。避免用于复杂半透明物体深度测试对于渲染顺序依赖很强的半透明物体如粒子、毛玻璃效果不佳。这类物体更适合使用屏幕后处理Post-processing方式实现全局扫描光。5.3 性能分析与优化建议性能热点Scene Depth采样和复杂的节点计算如噪声、多个Smoothstep是主要性能消耗点。优化策略LOD 控制为使用扫描光材质的物体设置细节层次LOD在远处使用简化版Shader或直接关闭效果。简化计算如果不需要噪声就关闭它。如果扫描方向固定可以预先计算一些值。批次合并Batching确保使用相同扫描光材质的物体尽可能静态合批或动态合批减少 Draw Call。后处理方案如果场景中需要全局性的、统一的扫描光而非每个物体独立将其作为全屏后处理效果来实现性能可能更好因为它只渲染一次。但这需要编写后处理 Shader复杂度更高。5.4 与其他系统的交互与 Timeline 或动画系统联动你可以不直接用Time节点而是暴露一个ScanProgress(0~1) 参数然后在 C# 脚本中通过Material.SetFloat来驱动它。这样就可以用 Animation Clip 或 Timeline 精确控制扫描的启动、停止和循环。碰撞触发通过脚本获取扫描光的世界位置和方向用 Physics.Raycast 或触发体来检测扫描光是否“扫过”了某个物体从而触发游戏逻辑如打开门、解锁道具。最后一个实用的调试技巧是在 Shader Graph 中你可以临时将finalMask或depthMask等中间变量直接连接到 Master Stack 的Color输出以可视化这些遮罩帮助你精准定位计算错误发生在哪个环节。例如将depthMask输出为灰度图你可以清晰地看到哪些区域通过了深度测试。

本月热点