
1. 为什么贴花值得用 Shader Graph 自己做在一款射击游戏里子弹打到墙上要留弹孔赛车游戏里轮胎需要划出刹车痕开放世界项目里地面涂鸦、井盖标识、角色脚下的血迹几乎全都是“事后贴上去”的。如果这些效果全部由美术画进场景模型里那么模型变体、贴图数量、内存占用和包体大小都会迅速失控。更合理的做法是运行时把一张贴图“投影”到任意物体表面。这套技术的通用名字叫 Decal也就是贴花。很多人以为贴花的难点是“把纹理贴到模型上”其实真正的难点是“把一张平面纹理在不规则表面上看起来像长在表面上的”。贴花技术要解决的核心问题有三个如何拿到表面位置、如何把纹理从正确的方向投影上去、如何避免在棱角和曲面上出现明显拉伸。我打算用 Unity Shader Graph 从零做一套自定义贴花方案。整个方案不依赖额外插件不写复杂的 C# 渲染代码核心是“深度重建 盒式投影”这两个图形学技巧。读完这篇文章你会掌握贴花的原理是什么为什么不能用普通 Mesh UV 解决如何在 URP 中从深度纹理重建世界坐标如何用 Shader Graph 节点实现贴花以及如何升级为可复制的 HLSL 自定义函数贴花出现闪烁、拉伸、贴不到半透明物体时应该往哪几个方向排查这套方案适合什么场景不适合什么场景。结论先放在这里用 Shader Graph 做贴花完全可行而且对大多数游戏场景足够用但它是“世界空间盒式投影”不是真正的曲面参数化贴花。理解了这个边界你才知道什么时候该自己写什么时候应该用 Unity 自带的 Decal Renderer Feature。2. Shader Graph 贴花的原理从深度纹理到世界坐标2.1 没有额外 UV怎么把贴图放到世界表面上普通材质采样贴图靠的是模型自带 UV 坐标。模型在建模软件里已经展好了 UV把贴图的左上角映射到模型表面的某个点。但这个流程对动态贴花是行不通的弹孔在哪个位置出现玩家不知道美术提前也不知道。如果给整面墙展一套“额外 UV”那就相当于在设计阶段把所有可能被贴花的位置全部预留出来这显然不现实。所以运行时贴花必须换一个思路不依赖模型 UV而是把整个贴花当成一个“投影仪”。这个投影仪处在一个固定位置朝一个固定方向打光把贴图投射到场景表面上。表面是什么网格并不重要重要的是贴图以投影关系覆盖了那个区域的深度。这就是贴花和普通纹理采样最根本的区别普通材质告诉你“这个贴图在这个 Mesh 的哪里”贴花告诉你“这个贴图在世界空间的这个长方体范围内覆盖什么”。2.2 从深度纹理重建世界坐标贴花投影的第一步是知道屏幕上某个像素对应的世界空间位置在哪里。这靠一张关键纹理_CameraDepthTexture即相机深度纹理。URP 在不透明物体渲染完之后会生成一张深度纹理。这张纹理记录的是每个像素的深度值。只要我们知道相机的逆 VP 矩阵View-Projection 矩阵的逆矩阵就能把一个屏幕像素的 UV 坐标和深度值反向换算成世界空间坐标。这里有一个容易搞混的点深度纹理里的深度值在不同渲染管线和不同平台上含义不完全一样有些是 NDC 深度有些是 Eye Depth有些还可能是反向 Z。所以在 Shader 里最好不要手工做一次 NDC 变换而是直接使用 URP 提供的封装函数ComputeWorldSpacePosition让它去处理平台差异。换算逻辑的核心是下面这一句话屏幕像素 深度值 逆VP矩阵 世界空间坐标得到世界坐标后我们不需要关心目标物体是什么 Mesh因为深度纹理已经告诉了我们“表面上这个像素在世界空间的确切位置”。接下来要做的就是把世界坐标转换到贴花自己的局部坐标里。2.3 盒式投影的核心思想所谓盒式投影就是假设贴花位于一个“透明盒子”内。这个盒子的位置、旋转、缩放决定贴花的中心、朝向和影响范围。盒子在世界空间占据一个长方体区域。对于任意一个世界空间坐标点只要它在贴花盒子内部就可以通过盒子自身的局部坐标算出 UV 采样坐标如果它在盒子外部就丢弃或透明处理。举例贴花物体的局部坐标是 X、Y、Z。如果我们规定贴花物体的 Z 轴正方向就是投影方向那么局部坐标的 X 和 Y 对应贴图 UV 局部坐标的 Z 对应投影厚度当某个世界点转换成贴花局部坐标后X 和 Y 落在 [-0.5, 0.5] 范围内Z 方向深度也在合理范围内就说明这个点应该被贴花覆盖。把 X、Y 从 [-0.5, 0.5] 映射到 [0, 1]就得到了可以采样贴图的 UV。这就是为什么用 Shader Graph 做贴花时经常会创建一个空物体当“贴花体积”。空物体的 Transform 就是贴花的投影盒贴花物体中心 贴花中心 贴花物体旋转 投影方向 贴花物体缩放 贴花范围其中 Z 方向是厚度这个实现思路最直观也最容易在 Shader Graph 节点中搭建。每一步都在做同一件事把屏幕坐标还原成世界坐标再变换到贴花物体的局部坐标。2.4 为什么它依然能贴合大部分不规则表面很多人的第一反应是既然只是把纹理从某个方向投影过去那么遇到曲面或者转角不是一定会拉伸吗确实会有拉伸但许多游戏场景并不那么敏感。血迹、弹孔、涂鸦、路面标识人类视觉对这类贴花的容忍度很高。投影盒子只要覆盖范围不大深度方向的厚度控制得足够薄投影误差就很小。转角墙面上投影贴花会“包”过墙角一点但看起来依然是合理的动态效果。这套方案真正的优势在于它不关心目标 Mesh 是什么拓扑结构。平面、立方体、球体、地形、多边形网格只要它们写入了深度纹理贴花就能覆盖上去。这让它非常适合做运行时生成的动态内容也是它相比“美术预烘焙贴花”的最大价值。3. 环境准备与 URP 渲染管线配置3.1 项目版本与依赖本文使用的方案基于 Unity 的通用渲染管线URP和 Shader Graph建议环境如下如果你的项目版本较新或较旧并不影响整体思路Unity 2021.3 LTS 或 Unity 2022.3 LTSURP 包版本 12 或以上Shader Graph 包版本 12 或以上渲染管线为工程默认的 URP Asset而不是内置渲染管线。如果你还在用 Built-in Render PipelineShader Graph 不一定开箱即用建议先迁移到 URP或在同一个工程里单独配置 URP 管线的测试场景。3.2 开启深度纹理这一步很容易被忽略却是整套贴花能否工作的前提。URP 中如果 Depth Texture 没有开启_CameraDepthTexture就不会生成Shader 采样的结果会是全 0最终贴花什么都显示不出来。开启方法打开 URP Asset 文件找到 Depth Texture 选项把它设为On或者如果你需要强制某些相机不关闭可以设为Force。如果你的项目用的是 Renderer Feature 或者多个 Renderer记得检查实际使用的 Renderer 对应的 URP Asset。这里也顺便说明一个常见坑URP 的深度纹理只在“不透明物体全部渲染完成后”才生成。所以贴花必须在不透明渲染之后绘制通常放在 Transparent 队列里否则会采样到不完整的场景深度。4. 纯 Shader Graph 实现贴花节点流程这一章先不写 HLSL完全用 Shader Graph 节点搭一个最小可用的贴花 Shader。这样你能够先理解完整的节点链路后面再升级为 HLSL 版本。4.1 创建 Shader Graph 与渲染面配置在 Project 面板中新建 Shader Graph选择Unlit Shader Graph。创建完成后打开 Graph Settings进行以下设置Surface Type Transparent Blend Mode Alpha Render Face Front这里的Render Face Front实际上执行的是 Cull Front也就是只渲染盒子的背面。因为真正要被看到的是贴花盒子内侧在场景表面上投下的区域而不是朝相机那一面的盒子正面。还要关闭 ZWriteZWrite Off这是贴花的通用配置不写深度避免贴花盒子污染场景深度透明混合让贴花能和场景原有颜色混合。4.2 重建世界坐标并转换到贴花局部空间在 Shader Graph 中获取屏幕像素世界坐标的最直接方式不是手动做逆矩阵变换而是利用下面这套组合添加Screen Position节点输出模式选 Default得到 0 到 1 范围的屏幕 UV添加Scene Depth节点输入屏幕 UV输出深度值添加Custom Function节点引用一个简单的 HLSL 函数传入屏幕 UV 和深度值重建世界坐标或者继续用节点组合但这部分节点组合比较复杂通常更建议直接用一个小型 HLSL 函数。为了保持全节点思路这里先给出可以理解的方式你可以用Screen Position、Scene Depth、View Direction等节点把屏幕像素沿相机射线外推得到世界位置。但因为涉及平台差异和深度语义节点连线很容易出错。我的建议是跳过纯节点重建世界坐标直接使用第 5 章给出的 HLSL 函数然后在 Shader Graph 中用一个Custom Function节点引进来。这个做法并不违背“从零实现”的目标。你仍然是在 Shader Graph 中搭流程只是把最敏感、最容易出错的“深度重建”封装成了一个小函数。拿到世界坐标后用Transform节点把坐标从 World 转换到 Object 空间输入世界坐标 World Position Transform 模式World - Object 输出贴花物体局部坐标注意这个 Object 不是场景中某个被贴花的墙壁而是挂载这个 Shader Material 的“贴花体积空物体”。因为贴花 Material 应该挂在贴花体积上而不是挂在被贴花的 Mesh 上。4.3 生成投影 UV 与有效性判断假设贴花物体的局部坐标用 localPos 表示用Split节点取出 X、Y、Z。然后做三件事。第一判断某个像素在不在贴花盒子内部localPos 的每个分量都在 [-0.5, 0.5] 范围内才说明这个点被贴花覆盖可以用Step节点和Multiply节点组合也可以用Clamp节点配合Subtract。最终得到 isValid范围是 0 或 1。第二取 X 和 Y 作为贴图 UVuv.x localPos.x 0.5 uv.y localPos.y 0.5如果发现贴花纹理上下颠倒把 uv.y 改成uv.y 1 - (localPos.y 0.5)第三把 Z 方向作为投影厚度用于做边缘柔化或者剔除。Z 越大说明该点离投影中心平面越远应该越透明。有了 UV 之后就可以加一个Sample Texture 2D节点采样贴花贴图。贴图的 A 通道可以作为混合系数和透明度来源。输出链路的最终连接Base Color 贴花贴图 RGB × Alpha Alpha 贴花贴图 A × isValid × DepthFade这样盒子外的像素 Alpha 为 0不会显示盒子内的像素根据贴图透明度显示Z 方向边缘会自然柔化。这个纯节点流程的优点是逻辑清晰适合学习和调试。缺点是节点数量多性能不是最优而且不容易做平台细节处理。所以接下来把它升级为 HLSL 自定义函数版本。5. 升级为 HLSL 自定义函数完整可复制 Demo5.1 DecalCore.hlsl 代码在Assets/Shaders/Decal/目录下新建文件DecalCore.hlsl写入下面内容// 文件路径Assets/Shaders/Decal/DecalCore.hlsl #ifndef DECAL_CORE_INCLUDED #define DECAL_CORE_INCLUDED #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/ShaderVariablesFunctions.hlsl void DecalProjection_float( float2 screenUV, float3 decalSize, out float2 uv, out float3 worldPos, out float isValid, out float depthFade) { // 1. 从深度纹理重建世界空间坐标 float rawDepth SampleSceneDepth(screenUV); worldPos ComputeWorldSpacePosition(screenUV, rawDepth, UNITY_MATRIX_I_VP); // 2. 将世界坐标转换到贴花体积物体的局部空间 // 注意这里的 Object 指的是挂载该 Shader 的贴花体积物体 float4 localPos4 mul(GetWorldToObjectMatrix(), float4(worldPos, 1.0)); float3 localPos localPos4.xyz / max(localPos4.w, 1e-5); // 3. 判断是否在盒子范围 [-halfSize, halfSize] 内 float3 halfSize decalSize * 0.5; float3 insideMin step(-halfSize, localPos); float3 insideMax step(localPos, halfSize); isValid insideMin.x * insideMin.y * insideMin.z * insideMax.x * insideMax.y * insideMax.z; // 4. 使用局部 X/Y 作为贴图 UV uv localPos.xy / decalSize.xy 0.5; uv.y 1.0 - uv.y; // 5. 局部 Z 方向作为投影厚度用于边缘淡出 depthFade saturate(1.0 - abs(localPos.z) / max(halfSize.z, 0.0001)); } #endif解释关键点SampleSceneDepth(screenUV)从 URP 深度纹理中取出原始深度值。ComputeWorldSpacePosition(screenUV, rawDepth, UNITY_MATRIX_I_VP)是 URP 提供的重建世界坐标的标准函数它处理了 NDC 坐标、平台 Y 翻转和反向 Z比自己写矩阵变换可靠得多。GetWorldToObjectMatrix()返回当前渲染物体的世界到局部矩阵。在 Shader Graph 里当前渲染物体就是贴花体积空物体这样我们不需要往函数里传 center、right、up、forward 这些参数代码会非常简洁。step 函数返回 0 或 1。step(-halfSize, localPos)表示当 localPos 大于等于 -halfSize 时返回 1否则返回 0。六个 step 结果相乘只有当 localPos 的每个分量都落在[-halfSize, halfSize]之间时isValid 才等于 1。5.2 在 Shader Graph 中接入回到 Shader Graph添加一个Custom Function节点配置如下Name: DecalProjection Type: File Source: DecalCore.hlsl Function Name: DecalProjection_float节点会出现两个输入和四个输出输入 screenUV (Vector2) decalSize (Vector3) 输出 uv (Vector2) worldPos (Vector3) isValid (Float) depthFade (Float)其中screenUV连接Screen Position节点模式选 Default输出屏幕 0 到 1 UV。decalSize连接Object节点的 Scale 输出。不过请注意不同 Unity 版本的 Shader GraphObject节点不一定都有 Scale 端口。如果没有就自己暴露一个Vector3属性手动填贴花体积的缩放值。为了简单可靠推荐直接暴露属性属性名称_DecalSize TypeVector3 Default Value(1, 1, 1)然后从_DecalSize属性连到decalSize。采样贴图时把uv连到Sample Texture 2D节点的 UV。之后把贴图 RGB 和 Alpha 分别作为 Base Color 和 Alpha 输出。最终输出链路Base Color 贴花贴图 RGB * isValid * depthFade Alpha 贴花贴图 A * isValid * depthFade如果不需要贴花边缘渐变可以让 depthFade 恒为 1或只乘 isValid。如果你希望贴花出现“接触面越远越淡”的自然衰减depthFade 很适合保留。5.3 一个可选的 C# 贴花放置脚本贴花体积通常不需要美术手动摆放。运行时玩家开枪、踩血、画涂鸦都应该由逻辑层在合适位置创建贴花。下面是一个最简脚本鼠标点击时在射线命中点生成贴花// 文件路径Assets/Scripts/DecalSpawner.cs using UnityEngine; public class DecalSpawner : MonoBehaviour { public GameObject decalPrefab; public float maxDistance 100f; void Update() { if (Input.GetMouseButtonDown(0)) { Ray ray Camera.main.ScreenPointToRay(Input.mousePosition); if (Physics.Raycast(ray, out RaycastHit hit, maxDistance)) { PlaceDecal(hit); } } } void PlaceDecal(RaycastHit hit) { GameObject decal Instantiate(decalPrefab, hit.point, Quaternion.LookRotation(hit.normal)); decal.transform.localScale Vector3.one * 1.5f; } }这段代码的关键点是Quaternion.LookRotation(hit.normal)。它会让贴花物体的 forward 方向对准表面法线也就是让投影方向垂直于被贴表面。如果贴花没有被精确摆成这样纹理就会在斜面上被拉伸或者变形。6. 场景搭建、运行与效果验证6.1 搭建测试台建议不要在游戏主场景里直接测先做一个干净的测试场景。场景中放置一个 Plane模拟地面一个 Cube模拟墙面一个 Sphere模拟曲面一个贴花体积 Prefab是一个空物体挂一个 Box Collider、一个 MeshRenderer以及一个引用本节 Shader 的 Material。MeshRenderer 上可以放一个半透明 Box Mesh方便在 Scene 视图中看到贴花体积的范围。但要注意运行时如果不想让这个透明盒影响视觉可以把 MeshRenderer 关掉或者使用单独的 Debug 层。把 DecalSpawner 挂到 Camera 上拖入贴花 Prefab运行游戏点击地面、墙壁和球体生成贴花。6.2 如何判断贴花是否正确从三个角度验证结果。第一贴花是否出现在鼠标点击的位置附近。如果贴花完全不出现优先检查深度纹理是否开启。这是出现概率最高的原因。第二贴花纹理是否相对于表面“躺平”。在平面上一张写有文字的贴图应该清晰可读没有透视变形在立方体墙面上文字应垂直于墙面法线而不是像贴在相机上在球面上中心区域应正常边缘会有拉伸但这属于预期表现。第三贴花是否跟随表面。移动被贴花的 Cube或者旋转视角贴花应该始终停留在表面原来的位置。如果贴花和表面错开说明重建出来的世界坐标和贴花体积的变换矩阵对不上重点检查 localPos 的转换层级。一个重要的视觉验证技巧贴花贴图上故意放一个大的方向箭头或数字“L/R”这样你一眼就能看出 UV 有没有反转、上下有没有颠倒。7. 贴花常见问题与排查方法问题现象可能原因排查方式解决方案贴花完全看不见URP 深度纹理未开启检查 URP Asset 中 Depth Texture 是否为 On设置为 On 或 Force贴花一闪一闪、抖动深度重建精度不足贴花盒厚度过大导致 Z 冲突把贴花体积的局部 Z 缩放调小开启帧调试器观察深度值减小投影厚度给贴花材质增加深度偏移改用更高精度深度纹理贴花纹理拉伸严重投影方向不正确贴花体积没有用 LookRotation 对准法线检查贴花物体旋转方向使用Quaternion.LookRotation(hit.normal)创建贴花旋转贴花只显示一半投影盒大小小于贴花覆盖范围检查贴花物体 Scale 和 UV 映射边界放大 scale或把 uv 映射改为从 [-0.5, 0.5] 映射到 [0,1]贴花反向、镜像UV 的 Y 方向平台差异观察纹理方向尝试uv.y 1 - uv.y翻转半透明物体上没有贴花半透明物体默认不写深度深度纹理中不包含这些表面检查半透明材质 ZWrite已知限制玻璃、水面等不建议直接用该方案Shader Graph 编译报错找不到函数include 路径错误函数名大小写错误控制台查看 HLSL 编译错误检查 Core.hlsl / DeclareDepthTexture.hlsl 路径核对函数签名贴花盒子自身遮挡场景贴花体积 MeshRenderer 在运行时可见检查贴花体积是否使用透明 Debug Mesh运行时关闭 MeshRenderer或把 Debug Mesh 放在编辑器专用层远处贴花出现条纹深度纹理精度不足查看相机近远裁剪面设置相机近远裁剪比值过大时提高深度纹理格式精度或缩小小贴花范围如果在排查时遇到“贴花显示在物体背面”这类表现通常不是深度重建错误而是 Culling 配置反了。贴花材质应使用 Cull Front也就是渲染背面这样从相机看向贴花盒子时看到的其实是盒子内侧的投影结果。8. 工程化落地中的最佳实践8.1 贴花图集与纹理规范一个项目里弹孔可能有几十种如果把每种弹孔都单独建一个 Material、单独采样一张纹理Draw Call 和材质管理都会失控。更合理的做法是把所有贴花纹理打成一张 Atlas。在 Shader 中暴露 Tiling 和 Offset或者用一个_DecalType参数控制采样区域。实践中推荐这样组织贴花图集一张 2048 或 4096 的 RGBA 贴图 A 通道控制贴花透明度也可用于边缘衰减 B 通道如果未来要加法线细节可放单通道法线或光滑度UV 计算取决于你的图集布局。如果每个贴花是一张 1/N 的小图就在采样时乘上对应 Tiling。8.2 渲染队列与深度偏差贴花最好的渲染位置是在不透明物体之后、半透明效果之前。URP 里用 Transparent 队列通常就能满足。但你需要注意Transparent 队列内部的深度写入默认是关闭的而贴花之间如果相互覆盖可能会因为混合顺序出现错误。一个经验法则是贴花材质统一使用相同的队列号并使用Cull Front尽量让贴花与贴花之间不互相影响。如果你发现贴花和墙面之间出现闪烁在 URP 中可以尝试给贴花材质加一个很小的深度偏移。Shader 中常见的做法是把采样到的深度值稍微向相机方向偏移一点比如rawDepth rawDepth - 0.0001;这个值不能一开始就设很大否则贴花会明显浮在表面上方出现伪影。8.3 半透明物体、法线贴花与 PBR 贴花的边界这套 Shader Graph 自定义贴花方案最适合“纯颜色叠加贴花”。如果项目需要 PBR 贴花比如弹孔附近的金属高光改变、表面粗糙度变化Unlit Shader Graph 就不够了。你需要把贴花信息写进场景物体的 GBuffer或者在 Lit Shader 中额外采样贴花贴图。这就不是简单一个 Unlit 贴花 Shader 能解决的事情。对于法线贴花同样是这个道理。颜色贴花可以直接用 Unlit Shader Alpha Blend法线贴花要参与光照计算必须路由到 Lit 管线或者在原 Lit 材质里判断贴花覆盖区域。另外半透明物体默认不写深度所以贴花天然无法正确覆盖玻璃、水面、透明塑料这种表面。如果产品确实需要“水面上的焦散血迹”这类效果建议改用其他技术比如直接用一个贴花 Mesh 跟随水面或者使用渲染时动态修改水面 UV。8.4 SRP Batcher 与合批注意事项Shader Graph 生成的 Shader 默认支持 SRP Batcher只要项目开启了 SRP Batcher并且贴花材质没有使用大量非 GPU Instancing 属性性能通常问题不大。真正影响性能的往往是贴花体积过大。一个贴花盒覆盖了半面墙那么像素着色器的采样范围就会非常大。深度重建、世界坐标转换、贴图采样全屏叠加起来开销不小。工程上的建议是贴花体积尽量小厚度尽量薄使用 Atlas 减少纹理切换控制同时存活的贴花数量及时回收过期贴花如果追求极致性能考虑使用 URP 自带的Decal Renderer Feature或 Screen Space Decal 方案。8.5 贴花生命周期管理运行时贴花本质上是一个个 GameObject如果不回收场景会越来越卡。建议为贴花体积提供一个简单生命周期机制生成后 5 秒到 30 秒淡出并销毁或者常驻限定数量。淡出可以在 C# 里做也可以直接在 Shader 里加一个_FadeFactor属性每一帧更新最终乘到 Alpha 上。Alpha 贴花贴图 A * isValid * depthFade * _FadeFactor这种做法在射击游戏血迹淡出、脚印消失等需求中非常常用而且实现成本很低。9. 总结这套方案的适用边界与下一步方向用 Shader Graph 做自定义贴花核心思路可以浓缩成一句话用深度纹理从屏幕像素反推世界坐标再把世界坐标变换到贴花体积的局部空间用局部坐标生成投影 UV 和覆盖范围判断。它不依赖模型 UV不依赖美术预烘焙适合弹孔、血迹、涂鸦、路面标识这类运行时动态内容。但也要清醒认识它的边界它是盒式投影不是曲面参数化。球体边缘会出现拉伸转角处纹理不是严格几何映射。如果你需要的是一整套完整 PBR 贴花系统需要在 Lit 管线里处理法线和粗糙度那就不能只靠一个 Unlit Shader 解决。更好的选择是在深入这个基础方案后去学习 URP 的 Decal Renderer Feature、屏幕空间贴花Screen Space Decal、以及 GPU Driven 的贴花管理方案。下一步你可以这样实践先照第 5 章的代码跑通一个最小 Demo把贴花打在地面和墙壁上观察纹理方向和边缘衰减然后加一个贴花 Atlas从脚本里控制不同贴花类型最后再考虑加入_FadeFactor淡出逻辑把它接入你的实际项目。当你把这一条链路跑通之后再看 Unity 自带的贴花方案会发现它们的原理和你的这套自定义实现高度相似区别只是性能优化和适用范围更广。到那时你才算真正理解了贴花技术在引擎渲染中的全貌。