贴花
弹孔、脚印、血迹、地上的喷漆箭头、墙上的海报——贴花(Decal)让同一块墙面、同一片地面在不同地方讲出不同的故事,而不需要为每一处单独做贴图。本章先用好 URP 14 自带的 Decal Projector,再从深度重建开始手写一个屏幕空间贴花,最后实现能贴合曲面的网格贴花。
22.1 贴花的几种做法
“贴花”指在已有表面上叠加一层局部的颜色(以及可能的法线、粗糙度),而不改变表面本身的材质和网格。它解决的是一个很实际的矛盾:墙、地面这些大面积表面通常用可平铺的材质,看上去千篇一律;而细节(污渍、破损、标识)只出现在局部,不值得为此单独画一张不重复的大贴图。实现方式按“贴花的几何体是什么”大致分三类:
| 做法 | 几何体 | 怎么找到要染色的像素 | 优点 | 缺点 |
|---|---|---|---|---|
| 网格贴花(Mesh Decal) | 一块贴着表面的网格 | 直接光栅化 | 最便宜,任何管线都能用,可以受光、带法线 | 要么只能贴平面,要么需要按表面生成网格;Z-Fighting |
| 屏幕空间贴花(Screen-Space Decal) | 一个投影盒 | 读深度图重建位置,判断是否在盒内 | 自动贴合任意几何,不需要网格信息 | 需要深度图;画在不透明物体之后,只能“盖”在最终颜色上 |
| DBuffer / GBuffer 贴花 | 一个投影盒 | 同上,但写入专门的缓冲 | 在光照计算之前修改表面属性,法线、光滑度、金属度都能改,受光完全正确 | 需要额外的 Pass 和缓冲,带宽开销大 |
后两类都使用“投影盒”:一个有朝向的长方体,盒子内的表面被染色,贴图沿盒子的一个轴(URP 约定是本地 +Z)投影下去,UV 就是表面点在盒子本地空间里的 xy 坐标。
22.2 URP 的 Decal Renderer Feature
URP 12 起内置了贴花系统,由两部分组成:挂在 Renderer 资源上的 Decal Renderer Feature,以及场景里的 Decal Projector 组件。Feature 的 有三个选项:Automatic、DBuffer、Screen Space。实际采用哪种技术由 DecalRendererFeature.GetTechnique 决定,摘自 URP 14 Runtime/RendererFeatures/DecalRendererFeature.cs:
switch (m_Settings.technique) { case DecalTechniqueOption.Automatic: if (IsAutomaticDBuffer() || isDeferred && needsGBufferAccurateNormals) technique = DecalTechnique.DBuffer; else if (isDeferred) technique = DecalTechnique.GBuffer; else technique = DecalTechnique.ScreenSpace; break; case DecalTechniqueOption.ScreenSpace: if (isDeferred) technique = DecalTechnique.GBuffer; else technique = DecalTechnique.ScreenSpace; break; case DecalTechniqueOption.DBuffer: technique = DecalTechnique.DBuffer; break; }
其中 IsAutomaticDBuffer() 在目标平台不是移动端(SHADER_API_MOBILE)且不是 WebGL 时返回 true。所以:桌面平台 Automatic 选 DBuffer;移动平台选屏幕空间;Deferred 渲染路径下“屏幕空间”实际变成 GBuffer 贴花(直接写进 GBuffer)。源码后面还会检查:OpenGL 系列不支持 DBuffer,DBuffer 和 GBuffer 贴花都要求设备支持至少 4 个渲染目标(MRT4),条件不满足时直接报错并不绘制。
DBuffer 的数据流
- DepthNormals 预渲染:DBuffer Pass 声明需要法线输入(
ConfigureInput(ScriptableRenderPassInput.Normal)),这会强制 URP 在不透明物体之前先跑一遍 DepthNormals Pass,得到深度和法线。 - 拷贝深度:Feature 在
AfterRenderingPrePasses + 1插入一个 CopyDepth,供贴花读取场景深度。 - 渲染 DBuffer:每个 Decal Projector 画一个盒子,把贴花的属性写进 1~3 张 RGBA8 缓冲(
_DBufferTexture0..2):0 号存反照率与不透明度(线性空间下是 sRGB 格式),1 号存法线,2 号存金属度/AO/光滑度。张数由 Feature 的 决定:Albedo → 1 张、Albedo Normal → 2 张、Albedo Normal MAOS → 3 张,并相应开启关键字_DBUFFER_MRT1/2/3。 - 不透明物体读取 DBuffer:Lit 等 Shader 在算光照之前调用
ApplyDecalToSurfaceData,用 DBuffer 里的数据修改SurfaceData和法线,然后照常计算光照。
第 4 步意味着:只有支持 DBuffer 的 Shader 才能接收 DBuffer 贴花。URP 14 自带的 Lit、Simple Lit、Complex Lit、Baked Lit、Unlit、Terrain Lit,以及 Shader Graph 生成的 Lit/Unlit 都支持(它们的前向 Pass 里都有 _DBUFFER_MRT1/2/3 关键字);自己手写的 Shader 要接收贴花,需要在前向 Pass 里加 #pragma multi_compile_fragment _ _DBUFFER_MRT1 _DBUFFER_MRT2 _DBUFFER_MRT3,include ShaderLibrary/DBuffer.hlsl,并在调用 UniversalFragmentPBR 之前写一行(摘自 URP 14 Shaders/LitForwardPass.hlsl):
#if defined(_DBUFFER) ApplyDecalToSurfaceData(input.positionCS, surfaceData, inputData); #endif
屏幕空间技术则简单得多:贴花在不透明物体和天空盒之后(AfterRenderingSkybox)作为普通的透明盒子画上去,直接与颜色缓冲混合。它不需要 DepthNormals 预渲染和额外缓冲,对 TBDR 架构的移动 GPU 更友好;代价是它画在“已经算完光照的颜色”上,法线只能从深度重建(Feature 的 选择用 1、5 或 9 次深度采样重建),也不能修改表面的金属度和光滑度。
案例 22.1用 Decal Projector 贴花入门单独打开 ↗
目标:在场景里放几个 Decal Projector:台阶边缘的警示条纹、地面的喷漆箭头、箱子上的弹孔和地上的血迹。
在编辑器里的步骤是:
- 选中 URP Asset 使用的 Renderer 资源(Universal Renderer Data),,Technique 选 Automatic 或指定一种。
- 创建材质,Shader 选 (URP 包自带的
Shaders/Decal.shadergraph),设置 Base Map,需要的话设置 Normal Map 和 Normal Blend。也可以用 自己做一个。 - ,把材质拖进去,调整 Width/Height/Projection Depth 和朝向。
本书的配图在代码里搭场景,需要用代码完成这三步。第一步有个障碍:DecalRendererFeature 和它的设置类在 URP 14 里都是 internal 的,不能直接 new 或 AddComponent。可以用反射拿到类型、ScriptableObject.CreateInstance 创建,再通过 SerializedObject 修改它的序列化字段——这与在 Inspector 里修改的是同一份数据。这只是为了自动化拍摄,项目里在 Renderer 资源上手动添加即可。
// DecalRendererFeature 及其设置类在 URP 14 里是 internal 的:编辑器里在 Renderer 资源上点 Add Renderer Feature › Decal 即可; // 代码里只能用反射创建,再通过 SerializedObject 改它的序列化字段(与 Inspector 改的是同一份数据)。 // technique: 0 Automatic / 1 DBuffer / 2 ScreenSpace;surfaceData: 0 Albedo / 1 AlbedoNormal / 2 AlbedoNormalMAOS static ScriptableRendererFeature AddDecalFeature(Shot s, int technique, int surfaceData = 2, int normalBlend = 1) { var type = typeof(ScriptableRendererFeature).Assembly.GetType("UnityEngine.Rendering.Universal.DecalRendererFeature"); var feature = (ScriptableRendererFeature)ScriptableObject.CreateInstance(type); feature.name = "Decal"; var so = new SerializedObject(feature); so.FindProperty("m_Settings.technique").enumValueIndex = technique; so.FindProperty("m_Settings.dBufferSettings.surfaceData").enumValueIndex = surfaceData; so.FindProperty("m_Settings.screenSpaceSettings.normalBlend").enumValueIndex = normalBlend; so.ApplyModifiedPropertiesWithoutUndo(); s.Temp.Add(feature); s.Renderer.rendererFeatures.Add(feature); s.Renderer.SetDirty(); return feature; }
static Material UrpDecalMat(Shot s, Texture2D baseMap, Texture2D normal = null, bool angleFadeGraph = false) { // URP 包自带的 Decal Shader Graph(Packages/.../Shaders/Decal.shadergraph),属性:Base_Map / Normal_Map / Normal_Blend var m = s.Mat(angleFadeGraph ? "Cookbook/Ch22/DecalAngleFade" : "Shader Graphs/Decal"); m.SetTexture("Base_Map", baseMap); if (normal != null) { m.SetTexture("Normal_Map", normal); m.SetFloat("Normal_Blend", 1f); } else m.SetFloat("Normal_Blend", 0f); m.enableInstancing = true; // Decal 系统按材质分批用 GPU Instancing 绘制 return m; } static DecalProjector UrpDecal(Material m, Vector3 pos, Vector3 euler, Vector3 size, float opacity = 1f) { var go = new GameObject("Decal Projector"); go.transform.SetPositionAndRotation(pos, Quaternion.Euler(euler)); var d = go.AddComponent<DecalProjector>(); d.material = m; d.size = size; // x/y = 贴花宽高,z = 投影深度 d.pivot = new Vector3(0, 0, size.z * 0.5f); // 默认:盒子从投影器位置向前延伸 d.fadeFactor = opacity; return d; }
Decal Projector 的主要参数:
| 参数(代码属性) | 含义 |
|---|---|
| size | x/y 是贴花的宽高,z 是投影深度。深度只要能覆盖到目标表面即可,太深会贴到不该贴的远处物体上 |
| pivot | 盒子相对于物体原点的偏移;默认 (0, 0, z/2),即盒子从投影器位置向前延伸 |
| scaleMode | 是否让 Transform 的缩放参与盒子尺寸(ScaleInvariant / InheritFromHierarchy) |
| uvScale / uvBias | 贴图的平铺与偏移,用来从图集里选一格 |
| fadeFactor | 整体不透明度,做“随时间淡出”的弹孔就改它 |
| drawDistance / fadeScale | 超过绘制距离不画;fadeScale 是开始淡出的比例 |
| startAngleFade / endAngleFade | 按表面法线与投影方向的夹角淡出,需要 Shader Graph 开启 Angle Fade(案例 22.3) |
| renderingLayerMask | 配合 Feature 的 Use Rendering Layers,只贴到指定渲染层的物体上(例如不贴到角色身上) |

注意台阶踢面上的拉伸:投影方向与踢面平行,贴图在这个方向上没有任何“分辨率”,只剩被无限拉长的一行像素。这正是图 22-1 里说的问题,案例 22.3 处理它。
new Material 创建的材质默认没有开启实例化(enableInstancing = false),要手动打开;在编辑器里创建的 Decal Shader Graph 材质默认已勾选。案例 22.2DBuffer 与屏幕空间对比入门单独打开 ↗
目标:同一组贴花在两种技术下的效果与取舍。

两张图看起来差不多,这恰恰是值得强调的结论:对“颜色为主、法线为辅”的常见贴花,屏幕空间技术的画面已经足够好。真正的差别在看不见的地方:
| DBuffer | Screen Space | |
|---|---|---|
| 额外 Pass | DepthNormals 预渲染 + 拷贝深度 + DBuffer 绘制 | 只有贴花盒子本身(需要深度图) |
| 额外显存/带宽 | 1~3 张全屏 RGBA8 + 法线图 | 几乎没有 |
| 能修改的属性 | 反照率、法线、金属度、AO、光滑度,受光完全正确 | 反照率、法线(重建),画在光照结果之上 |
| 对自定义 Shader 的要求 | 接收方要加 DBuffer 关键字并调用 ApplyDecal | 无要求,任何不透明物体都能被贴 |
| 适用平台 | 桌面、主机(需要 MRT4,不支持 OpenGL) | 全平台,移动端的默认选择 |
案例 22.3角度淡出:开启 Decal Shader Graph 的 Angle Fade进阶单独打开 ↗
目标:贴花在与投影方向接近平行的表面上自动淡出,消除拉伸。
Decal Projector 有 startAngleFade / endAngleFade 两个参数(0–180°,默认都是 180,即不淡出)。但只设置它们没有效果——还需要贴花材质的 Shader Graph 在 Graph Settings 里勾选 ,它会为 Shader 生成 DECAL_ANGLE_FADE 关键字和对应的计算。URP 包自带的 Decal.shadergraph 没有勾选这一项。
.shadergraph 文件本质上是 JSON,Graph Settings 存在 UniversalDecalSubTarget 对象的 m_DecalData 字段里。本章把包里的 Decal.shadergraph 复制一份到 Ch22/DecalAngleFade.shadergraph,只改了两处:"angleFade": true,以及把 Shader 路径 m_Path 改为 Cookbook/Ch22。在编辑器里则只需打开 Graph、在 Graph Inspector 里勾选 Angle Fade。
淡出的计算在 C# 端预先打包好,摘自 URP 14 Runtime/Decal/Entities/DecalEntityManager.cs 的注释与代码:
// In the shader to remap from cosine -1 to 1 to new range 0..1 (with 0 - 0 degree and 1 - 180 degree) // we do 1.0 - (dot() * 0.5 + 0.5) => 0.5 * (1 - dot()) // we actually square that to get smoother result => x = (0.5 - 0.5 * dot())^2 // Do a remap in the shader. 1.0 - saturate((x - start) / (end - start)) // After simplification => saturate(a + b * dot() * (dot() - 2.0)) float angleStart = startAngleFade / 180.0f; float angleEnd = endAngleFade / 180.0f; var range = Mathf.Max(0.0001f, angleEnd - angleStart); cachedChunk.angleFades[arrayIndex] = new Vector2(1.0f - (0.25f - angleStart) / range, -0.25f / range);
也就是说,它没有在 Shader 里算反余弦求角度,而是把“夹角 0–180°”近似映射成 ,再把重映射的系数在 CPU 上算好,Shader 里只剩一次乘加。这是一个很典型的“把非线性重映射的常量部分搬到 CPU”的优化。

22.3 自己写一个屏幕空间贴花
URP 的贴花系统好用,但它是一个封闭的整体:不能改变它的混合方式,不能插入自己的光照,Decal Shader Graph 能表达的东西也有限。理解屏幕空间贴花的原理后,自己写一个只需要一个 Shader。整个算法只有五步:
- 画一个单位立方体(就是投影盒),在它覆盖的每个像素上运行片元着色器。
- 用像素的屏幕 UV 读取
_CameraDepthTexture,得到这个像素上场景表面的深度。 - 由屏幕 UV 和深度反推出场景表面的世界坐标。
- 把世界坐标变换到立方体的本地空间;不在 [−0.5, 0.5]³ 内的丢弃。
- 本地坐标的 xy + 0.5 就是贴花 UV,采样、混合到颜色缓冲上。
案例 22.4屏幕空间贴花:深度重建与盒内裁剪进阶单独打开 ↗
目标:实现上面五步,得到一个可以贴在任意几何体上的贴花。
渲染状态
Cull Front // 画盒子的“背面”:相机钻进盒子里也不会丢 ZTest Greater // 背面比场景表面“更远”才通过 = 场景表面在盒子里(或盒子前方) ZWrite Off Blend [_SrcBlend] [_DstBlend]
为什么画背面、用 ZTest Greater?画正面看似更自然,但相机一旦进入盒子(贴花很大、或者贴在相机脚下的地面上),正面全在相机后方被裁掉,贴花瞬间消失。背面则只要盒子还在视野里就一定有片元。ZTest Greater 的意思是“盒子背面比深度缓冲里的场景表面更远时才通过”——也就是场景表面在背面的前方,它要么在盒子里、要么在盒子前面(后者会被第 4 步的范围判断丢弃)。这样盒子后面完全没有表面的像素(比如天空)连片元着色器都不会执行,省掉了不少开销。Unity 会根据平台是否使用 Reversed-Z 自动翻转比较方向,Shader 里按“Greater = 更远”写即可。
队列设为 Transparent-100:在不透明物体之后(此时深度图已经生成),在普通透明物体之前(贴花不应该出现在玻璃、粒子上面)。
重建世界坐标
// 1) 屏幕 UV → 场景深度 → 世界坐标 float2 screenUV = GetNormalizedScreenSpaceUV(i.positionCS); float rawDepth = SampleSceneDepth(screenUV); #if !UNITY_REVERSED_Z rawDepth = lerp(UNITY_NEAR_CLIP_VALUE, 1, rawDepth); // OpenGL 类平台:[0,1] → [-1,1] #endif float3 positionWS = ComputeWorldSpacePosition(screenUV, rawDepth, UNITY_MATRIX_I_VP); // 2) 世界坐标 → 贴花盒子的本地空间;盒子外的像素直接丢掉 float3 positionOS = TransformWorldToObject(positionWS); clip(0.5 - abs(positionOS));
// 1) 屏幕 UV → 场景深度 → 世界坐标 vec2 screenUV = GetNormalizedScreenSpaceUV(gl_FragCoord); float rawDepth = SampleSceneDepth(screenUV); // HLSL 版在 OpenGL 类平台要先把 [0,1] 深度换成 [-1,1];GLSL 版的 ComputeWorldSpacePosition 内部已经做了 vec3 positionWS = ComputeWorldSpacePosition(screenUV, rawDepth, UNITY_MATRIX_I_VP); // 2) 世界坐标 → 贴花盒子的本地空间;盒子外的像素直接丢掉 vec3 positionOS = TransformWorldToObject(positionWS); if (any(lessThan(0.5 - abs(positionOS), vec3(0.0)))) discard;
ComputeWorldSpacePosition(core 库 Common.hlsl)做的是透视除法的逆运算:屏幕 UV 和设备深度组成 NDC 坐标,乘以 UNITY_MATRIX_I_VP(视图投影矩阵的逆),再除以 w。需要注意的平台差异只有一处:D3D、Metal、Vulkan 的 NDC 深度范围是 [0, 1](Unity 在这些平台上还使用 Reversed-Z),OpenGL 是 [−1, 1],所以在 UNITY_REVERSED_Z 未定义时要先把采样到的 0..1 深度映射到 −1..1——这也是 URP 文档中重建世界坐标示例的写法。屏幕 UV 用 GetNormalizedScreenSpaceUV(positionCS) 从 SV_POSITION 换算,它会处理动态分辨率缩放。
得到世界坐标后,TransformWorldToObject 用当前物体(就是盒子)的逆矩阵变到本地空间,clip(0.5 − abs(positionOS)) 一行同时检查三个轴:任何一个分量的绝对值超过 0.5,结果为负,像素被丢弃。
/// 屏幕空间贴花就是一个挂着贴花材质的单位立方体:位置/旋转/缩放 = 投影盒 static GameObject SSD(Shot s, Material m, Vector3 pos, Vector3 euler, Vector3 size) { var go = s.Cube(m, pos); go.name = "Screen Space Decal"; go.transform.rotation = Quaternion.Euler(euler); go.transform.localScale = size; var r = go.GetComponent<MeshRenderer>(); r.shadowCastingMode = ShadowCastingMode.Off; // 投影盒本身不投影、不接收 r.receiveShadows = false; return go; }
在 C# 一侧,屏幕空间贴花就是一个普通的立方体:位置、旋转、缩放就是投影盒。它关闭了投射阴影和接收阴影;Shader 的 Tags 里还加了 "DisableBatching"="True"——静态/动态合批会把多个立方体合成一个网格、改写顶点坐标,物体矩阵就不再代表盒子了。GPU Instancing 则不受影响,因为每个实例仍有自己的矩阵。

depthFade),如果地面恰好位于盒子的 ±Z 端面上,贴花会完全透明——本章拍图时就踩到过:盒子中心放在 0.6 m、深度 1.2 m,地面正好在端面上,地面上的贴花全部消失。让目标表面落在盒子深度的中间部分。案例 22.5由深度重建法线与角度衰减进阶单独打开 ↗
目标:知道每个像素所在表面的朝向,在侧面上淡出贴花。
屏幕空间贴花拿不到网格的法线,只能从深度推算。最简单的方法利用片元着色器的屏幕空间导数:ddx(positionWS) 和 ddy(positionWS) 是相邻像素之间世界坐标的差,都位于表面的切平面内,它们的叉积就是表面法线。
// 3) 用屏幕空间导数重建表面法线(平面法线,物体轮廓处会有噪点) float3 normalWS = normalize(cross(ddx(positionWS), ddy(positionWS))); normalWS *= sign(dot(normalWS, GetWorldSpaceViewDir(positionWS))); // 统一朝向相机,免去平台 Y 方向差异 // 4) 角度衰减:表面越“侧”对投影方向,贴花越淡,避免在侧壁上拉出长条 float3 projDirWS = normalize(TransformObjectToWorldDir(float3(0, 0, 1))); float facing = dot(normalWS, -projDirWS); // 1 = 正对投影器 float cosStart = cos(radians(_AngleStart)), cosEnd = cos(radians(_AngleEnd)); half angleFade = saturate((facing - cosEnd) / max(cosStart - cosEnd, 1e-4)); // 盒子前后两端也淡出,贴花不会在投影深度的尽头被一刀切断 half depthFade = saturate((0.5 - abs(positionOS.z)) / _DepthFade);
// 3) 用屏幕空间导数重建表面法线(平面法线,物体轮廓处会有噪点) vec3 normalWS = normalize(cross(dFdx(positionWS), dFdy(positionWS))); normalWS *= sign(dot(normalWS, GetWorldSpaceViewDir(positionWS))); // 统一朝向相机,免去平台 Y 方向差异 // 4) 角度衰减:表面越“侧”对投影方向,贴花越淡,避免在侧壁上拉出长条 vec3 projDirWS = normalize(TransformObjectToWorldDir(vec3(0, 0, 1))); float facing = dot(normalWS, -projDirWS); // 1 = 正对投影器 float cosStart = cos(radians(_AngleStart)), cosEnd = cos(radians(_AngleEnd)); float angleFade = saturate((facing - cosEnd) / max(cosStart - cosEnd, 1e-4)); // 盒子前后两端也淡出,贴花不会在投影深度的尽头被一刀切断 float depthFade = saturate((0.5 - abs(positionOS.z)) / _DepthFade);
叉积的方向取决于屏幕 Y 轴朝上还是朝下(各图形 API 不同),所以最后用“法线应朝向相机”这一事实统一符号。角度衰减比较的是重建法线与“逆投影方向”的点积(即夹角余弦),在 _AngleStart 到 _AngleEnd 之间从 1 降到 0。为了与 Inspector 上的角度直觉一致,参数用角度表示,Shader 里转成余弦再比较——两次 cos(radians()) 都只依赖材质常量,编译器会把它们折叠或至少只在一处计算;对性能敏感时也可以像 URP 一样在 C# 里预先算好。

导数法线的两个缺陷在左图里都能看到。第一,它是平面法线:深度图里存的是光栅化后的三角形,导数得到的是三角形本身的朝向,不是网格的平滑插值法线,所以球面看起来是多面体。用它做角度衰减没问题,用它做光照则会暴露网格的面片感。第二,轮廓处有噪点:GPU 按 2×2 像素块求导,块里如果同时有前景和背景两个表面,算出的“切线”跨越了深度断层,法线完全错误。改进方法有两种:一是用多次深度采样挑选更可信的邻居(URP 屏幕空间贴花的 Normal Blend Medium/High 就是 5 次和 9 次采样的版本,实现在 ShaderLibrary/NormalReconstruction.hlsl);二是直接读取 _CameraNormalsTexture(DeclareNormalsTexture.hlsl 里的 SampleSceneNormals),前提是本帧有 DepthNormals 预渲染——例如开启了 SSAO,或者某个 Renderer Feature 用 ConfigureInput(ScriptableRenderPassInput.Normal) 请求了法线。
案例 22.6让贴花正确受光进阶单独打开 ↗
目标:贴花在阴影里应该变暗,受到点光时应该被照亮。
屏幕空间贴花画在“已经算完光照的颜色”上。如果直接输出贴图颜色(Unlit),它就像一张自发光的纸:阴影里依然鲜艳,墙角里依然明亮。解决方法有两种。
方法一:正片叠底。污渍、焦痕、脚印这类“让表面变暗”的贴花,用 Blend DstColor Zero:结果 = 背景颜色 × 贴花颜色。背景已经包含了光照和阴影,相乘后贴花自动继承了它们。这是最便宜也最稳妥的办法,但只能变暗、不能改变色相到更亮的颜色——白色喷漆就做不出来。
方法二:在贴花里重新算光照。我们已经有了世界坐标和法线,完全可以像普通物体一样组装 InputData 和 SurfaceData,调用 UniversalFragmentPBR:主光阴影用 TransformWorldToShadowCoord(positionWS) 取得,环境光用 SampleSH,附加光源在 Forward+ 下通过 normalizedScreenSpaceUV 查找分块光源列表。然后用 Alpha 混合盖在背景上。
float2 boxUV = positionOS.xy + 0.5; // 盒子本地 xy → 0..1 的贴花 UV float2 uv = TRANSFORM_TEX(boxUV, _BaseMap); half4 decal = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv) * _BaseColor; half alpha = decal.a * angleFade * depthFade; half3 color = decal.rgb; #if defined(_DECAL_LIT) half3 n = normalWS; #if defined(_NORMALMAP) // 贴花自己的法线贴图:切线空间就是投影器的 X/Y 轴,“上”是 -投影方向 half3 nTS = UnpackNormalScale(SAMPLE_TEXTURE2D(_BumpMap, sampler_BumpMap, uv), _BumpScale); float3 T = normalize(TransformObjectToWorldDir(float3(1, 0, 0))); float3 B = normalize(TransformObjectToWorldDir(float3(0, 1, 0))); // 把法线贴图的扰动量(xy)叠加到重建的表面法线上(UDN 式混合) n = normalize(n + (T * nTS.x + B * nTS.y) * decal.a); #endif InputData inputData = (InputData)0; inputData.positionWS = positionWS; inputData.normalWS = n; inputData.viewDirectionWS = GetWorldSpaceNormalizeViewDir(positionWS); inputData.shadowCoord = TransformWorldToShadowCoord(positionWS); inputData.bakedGI = SampleSH(n); inputData.normalizedScreenSpaceUV = screenUV; inputData.shadowMask = half4(1, 1, 1, 1); SurfaceData surface = (SurfaceData)0; surface.albedo = decal.rgb; surface.smoothness = _Smoothness; surface.occlusion = 1; surface.alpha = alpha; color = UniversalFragmentPBR(inputData, surface).rgb; #endif // 正片叠底模式:alpha 折算进颜色(透明处 = 白 = 不改变背景) color = lerp(color, lerp(1.0, decal.rgb, alpha), _Modulate); return half4(color, alpha);
vec2 boxUV = positionOS.xy + 0.5; // 盒子本地 xy → 0..1 的贴花 UV vec2 uv = TransformTex(boxUV, _BaseMap_ST); vec4 decal = texture(_BaseMap, uv) * _BaseColor; float alpha = decal.a * angleFade * depthFade; vec3 color = decal.rgb; #if defined(_DECAL_LIT) vec3 n = normalWS; #if defined(_NORMALMAP) // 贴花自己的法线贴图:切线空间就是投影器的 X/Y 轴,“上”是 -投影方向 vec3 nTS = UnpackNormalScale(texture(_BumpMap, uv), _BumpScale); vec3 T = normalize(TransformObjectToWorldDir(vec3(1, 0, 0))); vec3 B = normalize(TransformObjectToWorldDir(vec3(0, 1, 0))); // 把法线贴图的扰动量(xy)叠加到重建的表面法线上(UDN 式混合) n = normalize(n + (T * nTS.x + B * nTS.y) * decal.a); #endif InputData inputData = InitInputData(); inputData.positionWS = positionWS; inputData.normalWS = n; inputData.viewDirectionWS = GetWorldSpaceNormalizeViewDir(positionWS); inputData.shadowCoord = TransformWorldToShadowCoord(positionWS); inputData.bakedGI = SampleSH(n); inputData.normalizedScreenSpaceUV = screenUV; inputData.shadowMask = vec4(1, 1, 1, 1); SurfaceData surface = InitSurfaceData(); surface.albedo = decal.rgb; surface.metallic = 0.0; surface.smoothness = _Smoothness; surface.occlusion = 1.0; surface.alpha = alpha; color = UniversalFragmentPBR(inputData, surface).rgb; #endif // 正片叠底模式:alpha 折算进颜色(透明处 = 白 = 不改变背景) color = mix(color, mix(vec3(1.0), decal.rgb, alpha), _Modulate); return vec4(color, alpha);
贴花自带的法线贴图怎么用?它的“切线空间”就是投影盒的本地坐标轴:x 轴是盒子的 right、y 轴是 up。我们把法线贴图的 xy 扰动量沿这两个轴叠加到重建出的表面法线上(与第 20 章 UDN 混合同一思路),并乘上贴花自身的 alpha,让法线扰动只出现在贴花可见的地方。

22.4 网格贴花
网格贴花是最古老也最便宜的做法:在表面上方放一块带透明贴图的网格。它不需要深度图、没有额外的全屏 Pass,可以直接使用任何材质(包括完整的 PBR 和法线贴图),在移动端和需要大量静态贴花(比如关卡美术摆放的上百块污渍)的场景里依然是首选。它要解决两个问题:与底面共面时的深度冲突,以及如何贴合非平面的表面。
案例 22.7Z-Fighting 与深度偏移 Offset入门单独打开 ↗
目标:理解共面网格为什么闪烁,用 Offset 解决。
贴花网格与底面在同一个平面上时,两者在每个像素上的深度理论上相等。但它们的三角形划分不同(地面是细分网格,贴花是一个 Quad),光栅化器各自对顶点深度插值,浮点舍入误差的方向逐像素随机,ZTest LEqual 的结果就一会儿是贴花赢、一会儿是地面赢,形成斑驳的碎片,相机移动时还会闪烁——这就是 Z-Fighting。
ShaderLab 的 Offset Factor, Units 在光栅化阶段给深度加一个偏移:
其中 m 是该多边形深度相对屏幕坐标的最大斜率(表面越斜,m 越大),r 是深度缓冲格式能分辨的最小深度差。负值表示“往相机方向拉”(Unity 会按平台的深度方向处理符号)。Factor 项处理斜着看的表面——斜面上相邻像素的深度变化大,舍入误差也大,需要更大的偏移;Units 项处理正对相机的表面。
Blend SrcAlpha OneMinusSrcAlpha ZWrite Off ZTest LEqual // 深度偏移:depth += factor × 斜率 + units × 最小可分辨深度单位 // 负值 = 往相机方向拉一点,让与底面共面的贴花稳定地赢下深度测试 Offset [_OffsetFactor], [_OffsetUnits]

pushOut)。还要注意:在本书的配图环境里,水平地面上的一块共面 Quad 并没有出现明显的 Z-Fighting——平台使用 32 位浮点 Reversed-Z 深度,精度很高;把地面换成倾斜的细分网格后问题才显现出来。所以“我这里没闪”不代表别的设备、别的角度也不会闪,共面贴花一律加 Offset。案例 22.8生成贴合曲面的网格贴花高阶单独打开 ↗
目标:给定一个投影盒,从目标网格上裁出盒内的三角形,生成一块紧贴曲面的贴花网格。
平面 Quad 只能贴在平面上;贴到球、圆柱、台阶上,要么悬空要么插进去。很多射击游戏的弹孔采用的方法是:在命中时,把目标网格里落在贴花盒内的三角形“裁”出来,生成一块新网格——它与原表面完全重合(再配合 Offset 和微小外推),UV 则按投影盒计算。这和投影盒贴花的思路相同,只是把“逐像素判断是否在盒内”换成了“逐三角形裁剪”,计算发生在 CPU 上、只做一次。
算法如下:
- 对目标网格的每个三角形,变换到世界空间,计算面法线;与逆投影方向夹角过大(背面或太斜)的直接跳过——这就是网格贴花版本的“角度淡出”。
- 把三个顶点变换到盒子本地空间,依次用盒子的 6 个面(x = ±0.5、y = ±0.5、z = ±0.5)裁剪这个多边形。一个三角形被凸盒子裁剪后,最多得到一个九边形。
- 剩下的凸多边形做扇形三角化;每个顶点输出世界坐标(沿法线外推一点)、插值后的法线、UV = 本地 xy + 0.5。
第 2 步是经典的 Sutherland–Hodgman 多边形裁剪:沿多边形的边依次走过每个顶点,在平面内侧的顶点保留;边跨越平面时,计算交点并插入。顶点上的其它属性(这里是法线)按同样的参数 t 线性插值。
/// 保留 sign × pos[axis] ≤ 0.5 的部分。src → dst static void ClipAgainstPlane(List<Vert> src, List<Vert> dst, int axis, float sign) { dst.Clear(); int n = src.Count; for (int i = 0; i < n; i++) { Vert a = src[i], b = src[(i + 1) % n]; float da = 0.5f - sign * a.pos[axis]; // ≥ 0 表示在盒子这一侧 float db = 0.5f - sign * b.pos[axis]; if (da >= 0) dst.Add(a); if ((da >= 0) != (db >= 0)) // 跨过平面:插入交点 dst.Add(Vert.Lerp(a, b, da / (da - db))); } }
/// <param name="box">贴花盒子(用它的 Transform 的位置/旋转/缩放)</param> /// <param name="targets">要贴上去的网格</param> /// <param name="maxAngle">表面法线与“逆投影方向”的夹角超过它就不贴(避免侧壁拉伸)</param> /// <param name="pushOut">沿法线外推的距离(米),配合 Shader 的 Offset 进一步防止 Z-Fighting</param> /// <returns>世界空间网格(挂到一个位于原点、无旋转缩放的物体上)</returns> public static Mesh Build(Transform box, IEnumerable<MeshFilter> targets, float maxAngle = 80f, float pushOut = 0.002f) { var worldToBox = box.worldToLocalMatrix; var boxToWorld = box.localToWorldMatrix; Vector3 backDir = -box.forward; // “逆投影方向”,正对投影器的表面法线与它一致 float minFacing = Mathf.Cos(maxAngle * Mathf.Deg2Rad); var outPos = new List<Vector3>(); var outNrm = new List<Vector3>(); var outUV = new List<Vector2>(); var outIdx = new List<int>(); var poly = new List<Vert>(9); var tmp = new List<Vert>(9); foreach (var mf in targets) { var mesh = mf.sharedMesh; var l2w = mf.transform.localToWorldMatrix; var nrmMat = l2w.inverse.transpose; // 法线用逆转置矩阵变换 var verts = mesh.vertices; var normals = mesh.normals; var tris = mesh.triangles; for (int t = 0; t < tris.Length; t += 3) { Vector3 w0 = l2w.MultiplyPoint3x4(verts[tris[t]]); Vector3 w1 = l2w.MultiplyPoint3x4(verts[tris[t + 1]]); Vector3 w2 = l2w.MultiplyPoint3x4(verts[tris[t + 2]]); // 1) 背向投影器(或太斜)的三角形不要 Vector3 faceN = Vector3.Cross(w1 - w0, w2 - w0).normalized; if (Vector3.Dot(faceN, backDir) < minFacing) continue; poly.Clear(); for (int k = 0; k < 3; k++) { int vi = tris[t + k]; Vector3 w = k == 0 ? w0 : (k == 1 ? w1 : w2); Vector3 n = nrmMat.MultiplyVector(normals[vi]).normalized; poly.Add(new Vert(worldToBox.MultiplyPoint3x4(w), n)); } // 2) 依次用盒子的 6 个面裁剪(Sutherland–Hodgman) for (int axis = 0; axis < 3 && poly.Count >= 3; axis++) { ClipAgainstPlane(poly, tmp, axis, +1f); ClipAgainstPlane(tmp, poly, axis, -1f); } if (poly.Count < 3) continue; // 3) 剩下的凸多边形扇形三角化,输出世界坐标 + 盒子 UV int baseIdx = outPos.Count; foreach (var v in poly) { Vector3 n = v.normal.normalized; outPos.Add(boxToWorld.MultiplyPoint3x4(v.pos) + n * pushOut); outNrm.Add(n); outUV.Add(new Vector2(v.pos.x + 0.5f, v.pos.y + 0.5f)); } for (int k = 1; k < poly.Count - 1; k++) { outIdx.Add(baseIdx); outIdx.Add(baseIdx + k); outIdx.Add(baseIdx + k + 1); } } } var m = new Mesh { name = "DecalMesh", indexFormat = IndexFormat.UInt32 }; m.SetVertices(outPos); m.SetNormals(outNrm); m.SetUVs(0, outUV); m.SetTriangles(outIdx, 0); m.RecalculateTangents(); // 有了 UV 才能算切线,供法线贴图使用 m.RecalculateBounds(); return m; }
法线用目标物体矩阵的逆转置变换——物体有非均匀缩放时(例如图中被压扁的圆柱),直接用模型矩阵变换法线会使它不再垂直于表面(第 1 章讲过这个推导)。生成网格之后调用 RecalculateTangents,有了 UV 就能算出切线,贴花材质就可以正常使用法线贴图。
// 投影盒:一个空物体的 Transform(也可以直接用 DecalProjector 所在的物体) var boxGo = new GameObject("Decal Box"); boxGo.transform.SetPositionAndRotation(new Vector3(-0.85f, 0.65f, -0.5f), Quaternion.identity); // 沿 +Z 朝球投影 boxGo.transform.localScale = new Vector3(0.8f, 0.8f, 1.0f); var mesh = DecalMeshBuilder.Build(boxGo.transform, targets, 80f, 0.002f);

实际项目中的注意事项
- 读取网格数据:
mesh.vertices等在运行时要求网格开启 Read/Write(导入设置的 Read/Write Enabled),否则在发布版本中会报错。大网格每次命中都遍历全部三角形太慢,应先用物理射线拿到命中的三角形索引,只处理附近的三角形,或者对网格建立空间划分。 - 蒙皮网格:角色身上的贴花需要跟随骨骼动画,生成的网格要复制原网格的骨骼权重(按裁剪时的插值参数插值权重),用 SkinnedMeshRenderer 绘制。这正是投影盒贴花难以做到、网格贴花的独特优势。
- 数量控制:弹孔会越积越多,用一个固定容量的环形缓冲池,超出时回收最旧的;或者把同一材质的所有贴花网格合并成一个网格,一次绘制。
案例 22.9综合:弹孔、脚印与血迹进阶单独打开 ↗
目标:在一个场景里混合使用三种贴花,各取所长。
- 墙上的弹孔:自写屏幕空间贴花,开启受光与法线贴图,每个弹孔绕投影轴随机旋转、随机大小。它们共用一个材质,可以用 GPU Instancing 合批。
- 地上的一串脚印:同样是屏幕空间贴花,Alpha 混合的深棕色,左右脚交替偏移。脚印这种大量、短暂、贴在任意地形上的贴花,正是投影盒的强项。
- 墙角的血迹:一个投影盒同时覆盖地面和墙根,角度衰减(55°–85°)让它在墙面上自然收住,而不是被拉成竖条。
- 地面箭头:URP Decal Projector,与自写贴花在同一画面中共存。
- 油桶上的警示标签:网格贴花,由
DecalMeshBuilder从圆柱上裁出,随油桶移动时不需要任何重新计算。

性能与工程建议
- 屏幕空间贴花的开销与屏幕覆盖面积成正比。一个贴在相机脚下的大投影盒会让整个屏幕下半部分都执行贴花片元着色器;盒子尽量贴身,深度只覆盖需要的范围。
- Overdraw:大量贴花互相重叠(比如密集弹孔),每个像素会被每个盒子都处理一次。可以把一组弹孔合并到一张图集、一个投影盒里。
- 绘制顺序:同为 Alpha 混合的贴花,重叠时谁在上面取决于绘制顺序(透明队列按距离排序)。需要固定顺序时调整材质的
renderQueue,或者 URP Decal Shader Graph 的 Draw Order。 - 不该被贴的物体:投影盒会贴到盒内的所有不透明物体上,包括走过的角色。URP 的方案是 Rendering Layers(Feature 勾选 Use Rendering Layers,投影器设置 Rendering Layer Mask);自写贴花可以用模板测试:让角色写入一个模板值,贴花 Pass 用
Stencil { Ref 1 Comp NotEqual }跳过这些像素(模板测试见第 16 章)。 - 与 Built-in 管线的差异:Built-in 没有内置的贴花系统,常用的 Projector 组件在 URP 中不受支持;HDRP 的 Decal Projector 与 URP 的接口相似但实现不同,迁移项目时需要重新配置材质。
22.5 贴花不显示?排查清单
贴花出问题时现象往往只有一个——“看不见”,原因却很多。按从外到内的顺序排查:
- Feature 是否生效:当前相机用的 Renderer 资源上有没有 Decal Feature(质量档位切换可能换了 URP Asset 和 Renderer);Frame Debugger 里能否找到 “DBuffer Render” 或 “Decal Screen Space Render”。在 OpenGL 或不支持 MRT4 的设备上,DBuffer 会直接报错并跳过。
- 材质是否正确:Decal Projector 必须使用 Decal Shader Graph 生成的材质(它包含
DBufferProjector、DecalScreenSpaceProjector等专用 Pass),普通 Lit 材质放进去不会显示;材质是否开启了 GPU Instancing。 - 接收方是否支持:DBuffer 技术下,接收贴花的物体 Shader 必须读取 DBuffer;自定义 Shader 不读就不会显示贴花。屏幕空间技术则要求接收方写入深度(有 DepthOnly/DepthNormals Pass),透明物体永远不会接收投影盒贴花。
- 投影盒是否覆盖到表面:盒子的深度方向是否穿过目标表面;投影方向是否朝向表面(盒子转反了,贴花会出现在背面或者被角度淡出完全隐藏);Draw Distance 是否太小。
- 自写贴花的深度来源:URP Asset 是否开启 Depth Texture;贴花的渲染队列是否在深度拷贝之后(本书的拍摄 Renderer 把 Copy Depth 设在 After Opaques,
Transparent-100队列可以读到本帧深度;如果 Renderer 的 Depth Texture Mode 设为 After Transparents,透明队列里读到的将是上一帧或空的深度)。 - 层与遮罩:Rendering Layers 是否把目标物体排除在外;模板测试是否误伤。
另一类常见问题是“贴花看起来不对”:贴花颜色发灰(贴图是数据贴图却开了 sRGB,或者反过来)、法线方向反了(法线贴图没有标记为 Normal Map、或者绿色通道约定不同)、在斜面上拉伸(没开角度淡出)、远处闪烁(贴图没有 mipmap,或屏幕空间贴花的导数在轮廓处出错)。这些都能在本章的案例里找到对应的处理办法。
本章小结
- 贴花分三类:网格贴花(直接光栅化)、屏幕空间贴花(投影盒 + 深度重建)、DBuffer/GBuffer 贴花(在光照之前改写表面属性)。
- URP Decal Feature 的 Automatic 在桌面选 DBuffer、在移动端选屏幕空间、在 Deferred 下选 GBuffer;DBuffer 需要 DepthNormals 预渲染,接收方 Shader 必须调用
ApplyDecalToSurfaceData。 - Decal Projector 的角度淡出需要 Decal Shader Graph 勾选 Angle Fade;代码创建的 Decal 材质要开启 GPU Instancing。
- 屏幕空间贴花:画盒子背面 +
ZTest Greater,读深度 →ComputeWorldSpacePosition→ 盒子本地空间 →clip→ UV = xy + 0.5。 - 法线可由
ddx/ddy叉积重建(平面法线、轮廓有噪点),或读取_CameraNormalsTexture;角度衰减消除侧面拉伸。 - 屏幕空间贴花要么用正片叠底继承光照,要么在贴花里调用
UniversalFragmentPBR重新计算光照。 - 网格贴花用
Offset避免 Z-Fighting;用 Sutherland–Hodgman 裁剪从目标网格生成贴合曲面的贴花网格,法线用逆转置矩阵变换。
练习
- 给自写的
ScreenSpaceDecal.shader加一个_Progress参数,用噪声阈值做“血迹逐渐干涸、缩小”的动画。 - 让你在前面章节写的一个自定义 Lit Shader 支持 DBuffer 贴花(加关键字、include DBuffer.hlsl、调用 ApplyDecalToSurfaceData),验证 URP 的 Decal Projector 能贴到它上面。
- 把屏幕空间贴花的法线重建改为读取
_CameraNormalsTexture,并写一个最小的 Renderer Feature 用ConfigureInput(ScriptableRenderPassInput.Normal)请求法线图,比较球面上的效果。 - 为
DecalMeshBuilder增加“只处理命中点附近三角形”的优化:先用包围盒粗筛,再统计一次构建耗时。 - 做一个弹孔池:最多保留 64 个屏幕空间贴花,超出时淡出并回收最旧的一个。