透明、混合与模板测试
前面的 Shader 几乎都只关心“片元输出什么颜色”。这一章关心的是另一半:这个颜色怎么写进帧缓冲——和已有的颜色怎么混合、要不要通过深度测试、要不要写深度、要不要看模板缓冲的脸色。半透明物体的种种怪象、X 光透视、传送门,全都发生在这一半里。
16.1 渲染队列与 URP 的绘制顺序
每个材质都有一个渲染队列(Render Queue)值,默认来自 Shader 的 Tags { "Queue" = "..." },也可以在材质上覆盖(Inspector 的 Render Queue,或脚本里的 material.renderQueue)。Unity 预定义了几个名字:
| 名字 | 数值 | 典型用途 |
|---|---|---|
| Background | 1000 | 最先画的东西,例如自定义天空 |
| Geometry | 2000 | 普通不透明物体(默认) |
| AlphaTest | 2450 | Alpha 裁剪的物体,排在实心物体后面,让 Early-Z 先挡掉更多像素 |
| GeometryLast | 2500 | 不透明范围的最后一个值 |
| Transparent | 3000 | 半透明物体 |
| Overlay | 4000 | 镜头光晕等最后叠上去的效果 |
队列值也可以写成 "Geometry+10"、"Transparent-1" 这样的偏移形式。对 URP 来说,最重要的分界是 2500:RenderQueueRange.opaque 是 0~2500,RenderQueueRange.transparent 是 2501~5000。URP 的 UniversalRenderer 用两个不同的 DrawObjectsPass 分别画这两段,中间夹着天空盒和 Opaque Texture 的拷贝:
// 摘自 URP 14 Runtime/UniversalRenderer.cs(构造函数,节选) m_RenderOpaqueForwardPass = new DrawObjectsPass(URPProfileId.DrawOpaqueObjects, true, RenderPassEvent.BeforeRenderingOpaques, RenderQueueRange.opaque, data.opaqueLayerMask, m_DefaultStencilState, stencilData.stencilReference); m_DrawSkyboxPass = new DrawSkyboxPass(RenderPassEvent.BeforeRenderingSkybox); m_CopyColorPass = new CopyColorPass(RenderPassEvent.AfterRenderingSkybox, m_SamplingMaterial, m_BlitMaterial); m_RenderTransparentForwardPass = new DrawObjectsPass(URPProfileId.DrawTransparentObjects, false, RenderPassEvent.BeforeRenderingTransparents, RenderQueueRange.transparent, data.transparentLayerMask, m_DefaultStencilState, stencilData.stencilReference);
两个 Pass 的排序规则不同。不透明物体用相机的 defaultOpaqueSortFlags:通常是“按队列 → 由近到远 → 尽量减少状态切换”,由近到远是为了让 Early-Z 尽早剔除被挡住的像素。有意思的是,URP 会检测 GPU 是否自带隐藏面消除(HSR,例如苹果的 GPU),如果有,就跳过由近到远排序,因为硬件已经替你做了这件事:
// 摘自 URP 14 Runtime/UniversalRenderPipeline.cs bool hasHSRGPU = SystemInfo.hasHiddenSurfaceRemovalOnGPU; bool canSkipFrontToBackSorting = (baseCamera.opaqueSortMode == OpaqueSortMode.Default && hasHSRGPU) || baseCamera.opaqueSortMode == OpaqueSortMode.NoDistanceSort; cameraData.defaultOpaqueSortFlags = canSkipFrontToBackSorting ? noFrontToBackOpaqueFlags : commonOpaqueFlags;
透明物体用 SortingCriteria.CommonTransparent:按队列 → 由远到近。由远到近是混合正确的前提——后画的颜色叠在先画的上面,所以必须先画远的。但请注意排序的粒度是物体(以包围盒中心到相机的距离为准),不是三角形,更不是像素。这个粗粒度是后面所有透明排序问题的根源。
sortingOrder(排序优先级比距离高,常用于 2D 和 UI),或在 URP Lit 材质的 Advanced Options 里调 Sorting Priority(它实际修改的就是材质的渲染队列偏移)。16.2 混合公式
片元着色器输出的颜色叫源颜色(Source),帧缓冲里已有的颜色叫目标颜色(Destination)。混合单元按下面的公式算出新颜色写回去:
其中 F 是混合因子(Blend SrcFactor DstFactor),⊕ 是混合运算(BlendOp,默认 Add,还有 Sub、RevSub、Min、Max 等)。RGB 和 Alpha 可以用不同的因子:Blend SrcRGB DstRGB, SrcA DstA。URP 的 Lit 就是这么写的:
// 摘自 URP 14 Shaders/Lit.shader(ForwardLit Pass) Blend[_SrcBlend][_DstBlend], [_SrcBlendAlpha][_DstBlendAlpha] ZWrite[_ZWrite] Cull[_Cull] AlphaToMask[_AlphaToMask]
为什么要单独控制 Alpha?因为帧缓冲的 A 通道也是数据:如果之后还要把这个 RT 合成到别的画面上(例如 UI 相机、渲染到纹理的角色头像),目标 Alpha 需要按“覆盖率”累积(常用 One OneMinusSrcAlpha),而不是像 RGB 那样按 SrcAlpha OneMinusSrcAlpha 混合——后者会让 Alpha 被乘两次而偏小。
常用因子:Zero、One、SrcColor、SrcAlpha、DstColor、DstAlpha,以及它们的 OneMinus… 版本。在 Shader 属性里用 [Enum(UnityEngine.Rendering.BlendMode)] 声明,Inspector 会显示成下拉框,材质就能在运行时切换混合模式——这正是 URP Lit 的 Surface Type/Blending Mode 选项背后的机制。
案例 16.1混合模式大比拼入门单独打开 ↗
目标:用一个 Shader 切出六种常见混合模式,在同一个背景(上半黑→白渐变、下半色相条)上叠三张红绿蓝圆盘,直观对比。
完整代码:
// 混合模式实验台:源/目标因子和混合运算都做成材质属性,同一个 Shader 切出所有常见模式。 // _Output 决定片元输出的形式:直通(rgb, a)/ 预乘(贴图已预乘)/ 乘法就绪(lerp(1, rgb, a))。 Shader "Cookbook/Ch16/BlendModes" { Properties { [MainTexture] _BaseMap ("Base Map (alpha = shape)", 2D) = "white" {} [MainColor] _BaseColor ("Color", Color) = (1, 0.3, 0.2, 0.75) [Enum(UnityEngine.Rendering.BlendMode)] _SrcBlend ("Src Blend", Float) = 5 // SrcAlpha [Enum(UnityEngine.Rendering.BlendMode)] _DstBlend ("Dst Blend", Float) = 10 // OneMinusSrcAlpha [Enum(UnityEngine.Rendering.BlendOp)] _BlendOp ("Blend Op", Float) = 0 // Add [KeywordEnum(Straight, Premultiply, MultiplyReady)] _Output ("Fragment Output", Float) = 0 } SubShader { Tags { "RenderType"="Transparent" "RenderPipeline"="UniversalPipeline" "Queue"="Transparent" "IgnoreProjector"="True" } Pass { Name "Blend" Tags { "LightMode"="UniversalForward" } BlendOp [_BlendOp] Blend [_SrcBlend] [_DstBlend] ZWrite Off Cull Off HLSLPROGRAM #pragma vertex vert #pragma fragment frag #pragma shader_feature_local_fragment _OUTPUT_STRAIGHT _OUTPUT_PREMULTIPLY _OUTPUT_MULTIPLYREADY #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" CBUFFER_START(UnityPerMaterial) float4 _BaseMap_ST; half4 _BaseColor; float _SrcBlend, _DstBlend, _BlendOp; CBUFFER_END TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; }; Varyings vert(Attributes v) { Varyings o; o.positionCS = TransformObjectToHClip(v.positionOS.xyz); o.uv = TRANSFORM_TEX(v.uv, _BaseMap); return o; } half4 frag(Varyings i) : SV_Target { half4 tex = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv); half3 rgb = tex.rgb * _BaseColor.rgb; half a = tex.a * _BaseColor.a; #if defined(_OUTPUT_PREMULTIPLY) // 预乘:贴图的 rgb 已经乘过 alpha(导出时完成),这里只再乘上材质的不透明度。 // 于是 rgb 可以“大于” alpha——alpha = 0 而 rgb > 0 的像素就是纯叠加的辉光 return half4(tex.rgb * _BaseColor.rgb * _BaseColor.a, a); #elif defined(_OUTPUT_MULTIPLYREADY) return half4(lerp(1.0h, rgb, a), a); // 乘法类:透明处输出白色(乘 1 不变) #else return half4(rgb, a); // 直通 #endif } ENDHLSL } } }
// 混合模式实验台:源/目标因子和混合运算都做成材质属性,同一个 Shader 切出所有常见模式。(GLSL 版) // _Output 决定片元输出的形式:直通(rgb, a)/ 预乘(贴图已预乘)/ 乘法就绪(lerp(1, rgb, a))。 Shader "Cookbook/Ch16/BlendModes" { SubShader { Tags { "RenderType"="Transparent" "RenderPipeline"="UniversalPipeline" "Queue"="Transparent" "IgnoreProjector"="True" } Pass { Name "Blend" Tags { "LightMode"="UniversalForward" } BlendOp [_BlendOp] Blend [_SrcBlend] [_DstBlend] ZWrite Off Cull Off GLSLPROGRAM #pragma shader_feature_local_fragment _OUTPUT_STRAIGHT _OUTPUT_PREMULTIPLY _OUTPUT_MULTIPLYREADY uniform vec4 _BaseMap_ST; uniform vec4 _BaseColor; uniform sampler2D _BaseMap; VARYING vec2 uv; #ifdef VERTEX void main() { gl_Position = TransformObjectToHClip(positionOS.xyz); uv = TransformTex(texcoord0.xy, _BaseMap_ST); } #endif #ifdef FRAGMENT vec4 frag() { vec4 tex = texture(_BaseMap, uv); vec3 rgb = tex.rgb * _BaseColor.rgb; float a = tex.a * _BaseColor.a; #if defined(_OUTPUT_PREMULTIPLY) // 预乘:贴图的 rgb 已经乘过 alpha(导出时完成),这里只再乘上材质的不透明度。 // 于是 rgb 可以“大于” alpha——alpha = 0 而 rgb > 0 的像素就是纯叠加的辉光 return vec4(tex.rgb * _BaseColor.rgb * _BaseColor.a, a); #elif defined(_OUTPUT_MULTIPLYREADY) return vec4(mix(vec3(1.0), rgb, a), a); // 乘法类:透明处输出白色(乘 1 不变) #else return vec4(rgb, a); // 直通 #endif } void main() { fragColor = frag(); } #endif ENDGLSL } } }
渲染状态全部引用材质属性:
片元输出有三种形式,对应不同混合模式对“源颜色”的约定:
half4 frag(Varyings i) : SV_Target { half4 tex = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv); half3 rgb = tex.rgb * _BaseColor.rgb; half a = tex.a * _BaseColor.a; #if defined(_OUTPUT_PREMULTIPLY) // 预乘:贴图的 rgb 已经乘过 alpha(导出时完成),这里只再乘上材质的不透明度。 // 于是 rgb 可以“大于” alpha——alpha = 0 而 rgb > 0 的像素就是纯叠加的辉光 return half4(tex.rgb * _BaseColor.rgb * _BaseColor.a, a); #elif defined(_OUTPUT_MULTIPLYREADY) return half4(lerp(1.0h, rgb, a), a); // 乘法类:透明处输出白色(乘 1 不变) #else return half4(rgb, a); // 直通 #endif }
vec4 frag() { vec4 tex = texture(_BaseMap, uv); vec3 rgb = tex.rgb * _BaseColor.rgb; float a = tex.a * _BaseColor.a; #if defined(_OUTPUT_PREMULTIPLY) // 预乘:贴图的 rgb 已经乘过 alpha(导出时完成),这里只再乘上材质的不透明度。 // 于是 rgb 可以“大于” alpha——alpha = 0 而 rgb > 0 的像素就是纯叠加的辉光 return vec4(tex.rgb * _BaseColor.rgb * _BaseColor.a, a); #elif defined(_OUTPUT_MULTIPLYREADY) return vec4(mix(vec3(1.0), rgb, a), a); // 乘法类:透明处输出白色(乘 1 不变) #else return vec4(rgb, a); // 直通 #endif }
| 模式 | Blend 设置 | 结果 | 典型用途 |
|---|---|---|---|
| Alpha(传统透明) | SrcAlpha OneMinusSrcAlpha | S·a + D·(1−a) | 玻璃、半透明 UI、淡出 |
| 预乘 Alpha | One OneMinusSrcAlpha | S′ + D·(1−a),S′ = S·a | 同时表达“遮挡”和“发光”、正确的过滤与合成 |
| Additive(叠加) | SrcAlpha One 或 One One | S·a + D | 火焰、光束、能量特效 |
| Soft Additive(滤色) | OneMinusDstColor One | S + D − S·D = 1 − (1−S)(1−D) | 柔和的光晕,不容易过曝 |
| Multiply(正片叠底) | DstColor Zero | S·D | 阴影贴片、染色、污渍 |
| 2× Multiply | DstColor SrcColor | 2·S·D | 既能变亮也能变暗(S > 0.5 变亮)的细节叠加 |
| Max(变亮) | BlendOp Max | max(S, D) | 多层光斑取最亮,不累加 |


预乘 Alpha 为什么重要
预乘(Premultiplied Alpha)把“颜色乘以覆盖率”这一步提前到贴图导出时完成,Shader 输出的 rgb 已经是 S·a。这带来三个好处:
- 遮挡和发光可以共存:rgb 与 alpha 是独立的。alpha 决定“挡住多少背景”,rgb 决定“加上多少光”。alpha = 0、rgb > 0 的像素就是纯发光——图 16-2 中图的辉光就是这样做出来的,一个材质同时完成了 Alpha 混合和 Additive 两种工作,特效可以少一个 Draw Call。
- 过滤正确:直通 Alpha 的贴图在透明区域的 rgb 往往是无意义的值(常见是黑色),双线性过滤、mip 生成时会把这些黑色混进边缘,出现一圈暗边。预乘后透明区域的 rgb 一定是 0,过滤结果天然正确。
- 合成满足结合律:多层预乘图像可以先两两合成再整体合成,结果不变——这对把 UI 或特效先渲染到 RT 再合成的流程非常重要。
OneMinusDstColor 在目标值大于 1 时变成负数,会把颜色“减”掉,HDR 下请慎用。② Multiply 类模式要求透明区域输出白色(乘 1 不变),所以片元输出要用 lerp(1, rgb, a),直接输出 rgb 会把透明区域染黑。③ 在线性颜色空间工程里,混合发生在线性值上(sRGB 目标会在读写时自动转换),半透明边缘的观感和 Photoshop 默认的 Gamma 空间混合不同,美术对比参考图时需要知道这一点。④ Min/Max 运算会忽略混合因子,直接逐分量取最小/最大值。对照:URP Lit 的 Blending Mode 是怎么设置的
URP Lit 材质的 Surface Type 选 Transparent 后,Blending Mode 有四个选项。它们最终都落到材质的 _SrcBlend/_DstBlend/_SrcBlendAlpha/_DstBlendAlpha 四个属性上,由编辑器脚本 BaseShaderGUI.cs 设置(摘要如下,以 URP 14 源码为准):
| Blending Mode | RGB 因子 | Alpha 因子 | Shader 里的配合 |
|---|---|---|---|
| Alpha | SrcAlpha, OneMinusSrcAlpha | One, OneMinusSrcAlpha | — |
| Premultiply | One, OneMinusSrcAlpha | One, OneMinusSrcAlpha | 关键字 _ALPHAPREMULTIPLY_ON |
| Additive | SrcAlpha, One | One, One | — |
| Multiply | DstColor, Zero | Zero, One(保留目标 Alpha) | 关键字 _ALPHAMODULATE_ON,Shader 输出 lerp(1, rgb, a) |
注意 Alpha 模式下 Alpha 通道用的是 One, OneMinusSrcAlpha 而不是和 RGB 一样的因子——正是上面说的“覆盖率累积”。另外还有一个 Preserve Specular Lighting 选项(Alpha 与 Additive 模式可用):勾选后 RGB 的源因子被改成 One,由 Shader 自己只把漫反射部分乘上 alpha,高光部分不乘。物理上这很合理——一块几乎全透明的玻璃,表面反射的高光并不会因为玻璃透明而变弱。用前面的说法,这就是“预乘 Alpha,但只预乘漫反射”。如果你自己写的透明 Shader 里玻璃的高光总是灰蒙蒙的,原因多半就在这里。
16.3 深度与剔除:ZWrite、ZTest、Cull
片元在混合之前还要过两关:深度测试决定它能不能写进来,深度写入决定它会不会挡住以后的片元。再往前,面剔除在光栅化阶段就决定了一个三角形要不要生成片元。
ZWrite On|Off:通过测试后是否把自己的深度写进深度缓冲。不透明物体写,透明物体通常不写(否则会挡住后面的透明物体)。ZTest Less|LEqual|Equal|GEqual|Greater|NotEqual|Always|Never:用片元深度和缓冲中的深度比较,满足条件才通过。默认 LEqual。ShaderLab 里写的是“传统”语义(越小越近),在使用 Reversed-Z 的平台上(第 1 章),Unity 会自动把比较函数翻转,Shader 作者无需关心。Cull Back|Front|Off:剔除背面、正面或都不剔除。正反面由三角形在屏幕上的绕序决定,Unity 中顺时针为正面。
还有一个常和深度测试一起出现的状态:Offset Factor, Units(多边形偏移)。它在光栅化时给片元深度加一个与三角形斜率(Factor)和最小可分辨深度单位(Units)成比例的偏移,用来解决两个共面表面(贴在墙上的弹孔、地面上的网格贴花)互相“打架”(Z-Fighting)的问题,例如 Offset -1, -1 让贴花略微靠近相机。第 22 章的网格贴花会用到它。
深度测试本来发生在片元着色器之后,但现代 GPU 会尽量把它提前到片元着色器之前(Early-Z),被挡住的片元干脆不执行着色器。如果片元着色器里用了 clip()/discard、或者修改了输出深度,GPU 就可能无法提前写深度甚至无法提前测试(具体取决于硬件),这是 Alpha Test 物体比实心物体贵的根本原因,第 29 章会结合 TBDR 架构细讲。
案例 16.2ZTest / ZWrite / Cull 实验台入门单独打开 ↗
目标:把三个状态都做成材质属性,在“一面墙挡住一个纽结”的场景里观察不同组合。
// 深度/剔除状态实验台:ZTest、ZWrite、Cull 都是材质属性,可在 Inspector 里直接切换。 // 渲染状态写成 [属性名] 的形式,Unity 会在提交绘制时读取材质上的值。 Shader "Cookbook/Ch16/RenderStates" { Properties { _BaseColor ("Color", Color) = (0.9, 0.5, 0.2, 1) [HDR] _BackColor ("Back Face Color", Color) = (0.2, 0.6, 0.9, 1) [Enum(UnityEngine.Rendering.CompareFunction)] _ZTest ("ZTest", Float) = 4 // LessEqual [Enum(Off, 0, On, 1)] _ZWrite ("ZWrite", Float) = 1 [Enum(UnityEngine.Rendering.CullMode)] _Cull ("Cull", Float) = 2 // Back } SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" } HLSLINCLUDE #include "Assets/Cookbook/Ch16/Ch16Common.hlsl" CBUFFER_START(UnityPerMaterial) half4 _BaseColor, _BackColor; float _ZTest, _ZWrite, _Cull; CBUFFER_END ENDHLSL Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForwardOnly" } ZTest [_ZTest] ZWrite [_ZWrite] Cull [_Cull] HLSLPROGRAM #pragma vertex Ch16Vert #pragma fragment frag #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN #pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH half4 frag(Varyings i, FRONT_FACE_TYPE face : FRONT_FACE_SEMANTIC) : SV_Target { bool front = IS_FRONT_VFACE(face, true, false); float3 N = normalize(i.normalWS) * (front ? 1.0 : -1.0); half3 albedo = front ? _BaseColor.rgb : _BackColor.rgb; return half4(SimpleLighting(albedo, N, i.positionWS, 0.4h), 1); } ENDHLSL } Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 Cull [_Cull] HLSLPROGRAM #pragma vertex vert #pragma fragment frag float4 vert(Attributes v) : SV_POSITION { return Ch16ShadowPositionCS(v.positionOS.xyz, v.normalOS); } half4 frag() : SV_Target { return 0; } ENDHLSL } } }
// 深度/剔除状态实验台:ZTest、ZWrite、Cull 都是材质属性,可在 Inspector 里直接切换。(GLSL 版) // 渲染状态写成 [属性名] 的形式,Unity 会在提交绘制时读取材质上的值。 Shader "Cookbook/Ch16/RenderStates" { SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" } GLSLINCLUDE #include "Ch16/Ch16Common.glsl" uniform vec4 _BaseColor, _BackColor; ENDGLSL Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForwardOnly" } ZTest [_ZTest] ZWrite [_ZWrite] Cull [_Cull] GLSLPROGRAM #ifdef VERTEX void main() { Ch16Vert(); } #endif #ifdef FRAGMENT void main() { bool front = gl_FrontFacing; vec3 N = normalize(normalWS) * (front ? 1.0 : -1.0); vec3 albedo = front ? _BaseColor.rgb : _BackColor.rgb; fragColor = vec4(SimpleLighting(albedo, N, positionWS, 0.4), 1.0); } #endif ENDGLSL } Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 Cull [_Cull] GLSLPROGRAM #ifdef VERTEX void main() { gl_Position = Ch16ShadowPositionCS(positionOS.xyz, normalOS); } #endif #ifdef FRAGMENT void main() { fragColor = vec4(0.0); } #endif ENDGLSL } } }
属性用 Unity 自带的枚举类型声明,状态块里用方括号引用:
[Enum(UnityEngine.Rendering.CompareFunction)] _ZTest ("ZTest", Float) = 4 // LessEqual [Enum(Off, 0, On, 1)] _ZWrite ("ZWrite", Float) = 1 [Enum(UnityEngine.Rendering.CullMode)] _Cull ("Cull", Float) = 2 // Back
// 深度/剔除状态实验台:ZTest、ZWrite、Cull 都是材质属性,可在 Inspector 里直接切换。(GLSL 版) // 渲染状态写成 [属性名] 的形式,Unity 会在提交绘制时读取材质上的值。 Shader "Cookbook/Ch16/RenderStates" { SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" } GLSLINCLUDE #include "Ch16/Ch16Common.glsl" uniform vec4 _BaseColor, _BackColor; ENDGLSL Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForwardOnly" } ZTest [_ZTest] ZWrite [_ZWrite] Cull [_Cull] GLSLPROGRAM #ifdef VERTEX void main() { Ch16Vert(); } #endif #ifdef FRAGMENT void main() { bool front = gl_FrontFacing; vec3 N = normalize(normalWS) * (front ? 1.0 : -1.0); vec3 albedo = front ? _BaseColor.rgb : _BackColor.rgb; fragColor = vec4(SimpleLighting(albedo, N, positionWS, 0.4), 1.0); } #endif ENDGLSL } Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 Cull [_Cull] GLSLPROGRAM #ifdef VERTEX void main() { gl_Position = Ch16ShadowPositionCS(positionOS.xyz, normalOS); } #endif #ifdef FRAGMENT void main() { fragColor = vec4(0.0); } #endif ENDGLSL } } }
拍摄时把纽结材质的队列设为 2010(m.renderQueue = 2010),保证墙先画。原因很关键:ZTest 比较的是“此刻”深度缓冲里的值。如果纽结先于墙绘制,深度缓冲里还没有墙,Always 和 Greater 就看不出任何区别。

第三幅图就是“X 光”的雏形:ZTest Greater 恰好选出了被遮挡的部分。不过它还有一个问题——纽结自己挡住自己的部分也满足 Greater,后面的模板测试小节会解决它。
案例 16.3透明排序问题与双 Pass 预深度进阶单独打开 ↗
目标:复现半透明物体最常见的两类排序错误,并用“先写深度、再画颜色”的双 Pass 解决自身穿插问题。
原理:Alpha 混合不满足交换律:先画 A 再画 B,和先画 B 再画 A,结果不同。正确的结果要求同一像素上的所有半透明片元从远到近依次混合。而 URP(以及几乎所有光栅化渲染器)只做到了“物体级别从远到近”,于是有两类问题:
- 物体之间:两个物体互相穿插,或者一个大物体(比如一大片半透明水面)的包围盒中心比小物体近、但其实有一部分在小物体后面——无论按中心怎么排,总有一部分是错的。
- 物体自身:一个凹形物体(纽结、头发、多层衣服)自己的三角形之间也有前后关系,但一次 Draw Call 内三角形按索引顺序光栅化,和远近无关。不写深度时,后面的三角形如果索引靠后,就会画在前面的三角形“上面”。
针对“物体自身”的问题,一个经典而便宜的解法是双 Pass 预深度:第一个 Pass 只写深度、不写颜色(ColorMask 0),把这个物体最前面那层表面的深度先占好;第二个 Pass 正常混合颜色,ZTest LEqual 让只有“最前面那层”的片元通过。结果是物体自身不再错乱——代价是看不到物体自己的背面和内部(它变成了一个“半透明的实心体”),对角色、头发、玻璃制品来说这往往正是想要的效果。
// 双 Pass 半透明:第一个 Pass 只写深度(不写颜色),第二个 Pass 用 LEqual 正常混合。 // 结果是每个像素只留下“离相机最近的那层表面”,自身前后层次不再错乱。 // URP 的 DrawObjectsPass 按 SRPDefaultUnlit → UniversalForward 的顺序绘制同一物体的多个 Pass, // 所以把预深度放在 SRPDefaultUnlit 里,就能保证它先于颜色 Pass 执行。 Shader "Cookbook/Ch16/TransparentPrepass" { Properties { _BaseColor ("Color (A = opacity)", Color) = (0.3, 0.7, 1, 0.45) } SubShader { Tags { "RenderType"="Transparent" "RenderPipeline"="UniversalPipeline" "Queue"="Transparent" } HLSLINCLUDE #include "Assets/Cookbook/Ch16/Ch16Common.hlsl" CBUFFER_START(UnityPerMaterial) half4 _BaseColor; CBUFFER_END ENDHLSL Pass { Name "DepthPrepass" Tags { "LightMode"="SRPDefaultUnlit" } ZWrite On ColorMask 0 // 只写深度,不动颜色 HLSLPROGRAM #pragma vertex vert #pragma fragment frag float4 vert(Attributes v) : SV_POSITION { return TransformObjectToHClip(v.positionOS.xyz); } half4 frag() : SV_Target { return 0; } ENDHLSL } Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForward" } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off ZTest LEqual // 深度相等的那层(最前面的表面)才能通过 HLSLPROGRAM #pragma vertex Ch16Vert #pragma fragment frag #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN #pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH half4 frag(Varyings i) : SV_Target { return half4(SimpleLighting(_BaseColor.rgb, normalize(i.normalWS), i.positionWS, 0.8h), _BaseColor.a); } ENDHLSL } } }
// 双 Pass 半透明:第一个 Pass 只写深度(不写颜色),第二个 Pass 用 LEqual 正常混合。(GLSL 版) // 结果是每个像素只留下“离相机最近的那层表面”,自身前后层次不再错乱。 // URP 的 DrawObjectsPass 按 SRPDefaultUnlit → UniversalForward 的顺序绘制同一物体的多个 Pass, // 所以把预深度放在 SRPDefaultUnlit 里,就能保证它先于颜色 Pass 执行。 Shader "Cookbook/Ch16/TransparentPrepass" { SubShader { Tags { "RenderType"="Transparent" "RenderPipeline"="UniversalPipeline" "Queue"="Transparent" } GLSLINCLUDE #include "Ch16/Ch16Common.glsl" uniform vec4 _BaseColor; ENDGLSL Pass { Name "DepthPrepass" Tags { "LightMode"="SRPDefaultUnlit" } ZWrite On ColorMask 0 // 只写深度,不动颜色 GLSLPROGRAM #ifdef VERTEX void main() { gl_Position = TransformObjectToHClip(positionOS.xyz); } #endif #ifdef FRAGMENT void main() { fragColor = vec4(0.0); } #endif ENDGLSL } Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForward" } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off ZTest LEqual // 深度相等的那层(最前面的表面)才能通过 GLSLPROGRAM #ifdef VERTEX void main() { Ch16Vert(); } #endif #ifdef FRAGMENT void main() { fragColor = vec4(SimpleLighting(_BaseColor.rgb, normalize(normalWS), positionWS, 0.8), _BaseColor.a); } #endif ENDGLSL } } }
两个 Pass 的执行顺序靠 LightMode 保证。URP 的 DrawObjectsPass 在构造时按固定顺序登记了三个 Shader Tag,绘制同一个物体时按这个顺序执行匹配的 Pass:
// 摘自 URP 14 Runtime/Passes/DrawObjectsPass.cs new ShaderTagId[] { new ShaderTagId("SRPDefaultUnlit"), new ShaderTagId("UniversalForward"), new ShaderTagId("UniversalForwardOnly") }
所以把预深度 Pass 标成 SRPDefaultUnlit、颜色 Pass 标成 UniversalForward,前者一定先执行。对比渲染结果(图 16-6 右图)可以验证这一点:如果顺序反了,预深度就完全没有作用,结果会和左图一样。

顺带看看 URP Lit 自己怎么处理透明物体的深度写入。Lit 材质的 Advanced Options 里有一个 Depth Write(属性名 _ZWriteControl,取值 Auto / ForceEnabled / ForceDisabled):Auto 时不透明写深度、透明不写;强制开启则透明物体也写深度——也就是本例中图的“单 Pass ZWrite On”,会有同样的缺块问题。编辑器脚本在设置 ZWrite 的同时,还会调用 material.SetShaderPassEnabled("DepthOnly", zwrite):不写深度的材质,它的 DepthOnly Pass 也被禁用。再结合 UniversalRenderer 里深度预通道的构造参数 new DepthOnlyPass(…, RenderQueueRange.opaque, …) 可以知道:深度预通道只画不透明队列,透明队列的物体即使带了 DepthOnly Pass 也不会进入 _CameraDepthTexture;反过来,如果你为了解决排序把某个半透明材质的队列改进了不透明范围,它的 DepthOnly Pass 就会写进深度图,SSAO、软粒子、水面深度这些依赖深度图的效果都会“看到”它,这往往不是你想要的。
✓ 预深度适合
单个凹形的半透明物体(角色淡出、幽灵、玻璃雕塑),想要“看起来像实心体变透明”。✗ 预深度不适合
需要看到内部结构的物体(透明容器里的液体、玻璃杯的背面);它还会挡住排在它之后绘制的、位于它后面的其他半透明物体。案例 16.4Alpha Clip 与 Alpha to Coverage进阶单独打开 ↗
目标:比较铁丝网这类“细镂空”材质的三种做法,理解 Alpha to Coverage 的原理以及 alpha 锐化。
原理:Alpha Clip(Alpha Test)在片元里 clip(alpha - cutoff),低于阈值的像素直接丢弃。它不需要排序、能写深度、能投射正确的阴影,是树叶、草、栅栏的标准做法。缺点有两个:边缘是“要么全有要么全无”,锯齿明显,而且 MSAA 救不了它——MSAA 只对几何边缘多采样,片元着色器每像素只执行一次,clip 的结果对所有子样本都一样;第二个缺点是 mip:远处的 mip 层把细线和透明背景平均成很低的 alpha,全部低于阈值,铁丝网在远处就“断”成了零星的点。
Alpha to Coverage(A2C)利用 MSAA 的子样本:开启后,GPU 把片元输出的 alpha 换算成一个覆盖掩码——4× MSAA 下 alpha = 0.5 大约覆盖 2 个子样本——只把颜色写进被覆盖的子样本。MSAA 解析(Resolve)时求平均,就得到了 4 级的半透明过渡,而且不需要排序、照样写深度。
但直接把贴图 alpha 交给 A2C 会发虚:贴图边缘的 alpha 过渡可能跨好几个像素,放大看是一片半透明的雾。解决办法是锐化 alpha:以 cutoff 为中心,用 fwidth 把过渡压缩到约一个像素宽:
这其实就是案例 15.3 网点抗锯齿的同一个技巧:在阈值处做一个像素宽的 smoothstep。URP 14 自己就是这么做的——Lit 材质在“不透明 + Alpha Clipping”时会自动打开 AlphaToMask,而 AlphaClip() 函数在 MSAA 可用时返回锐化后的 alpha:
// 摘自 URP 14 ShaderLibrary/ShaderVariablesFunctions.hlsl half SharpenAlphaStrict(half alpha, half alphaClipTreshold) { half dAlpha = fwidth(alpha); return saturate(((alpha - alphaClipTreshold - (0.5 * dAlpha)) / max(dAlpha, 0.0001)) + 1.0); }
“MSAA 可用”由一个全局变量 _AlphaToMaskAvailable 表示,DrawObjectsPass 只在相机目标多于 1 个采样、且正在画不透明物体时把它设为 1。也就是说:没开 MSAA 时,同一个材质自动退化为普通的 Alpha Clip,不会出现“A2C 没有子样本可用、结果全透明”的事故。
完整代码:
// 镂空材质的三种做法: // Alpha Clip:clip() 硬裁剪,边缘锯齿,远处细线会因 mip 平均而消失; // Alpha to Coverage(A2C):把 alpha 转成 MSAA 的覆盖掩码,边缘有 MSAA 级别的过渡(需要开 MSAA); // A2C + 锐化:用 fwidth 把 alpha 重新拉成“一个像素宽的过渡”,既保住细线又不发虚。 Shader "Cookbook/Ch16/Cutout" { Properties { [MainTexture] _BaseMap ("Base Map", 2D) = "white" {} [MainColor] _BaseColor ("Color", Color) = (1, 1, 1, 1) _Cutoff ("Alpha Cutoff", Range(0, 1)) = 0.5 [Enum(Off, 0, On, 1)] _AlphaToMask ("Alpha To Coverage", Float) = 0 [Toggle] _Sharpen ("Sharpen Alpha (for A2C)", Float) = 0 [Toggle] _MipScale ("Preserve Coverage Over Mips", Float) = 0 } SubShader { Tags { "RenderType"="TransparentCutout" "RenderPipeline"="UniversalPipeline" "Queue"="AlphaTest" } HLSLINCLUDE #include "Assets/Cookbook/Ch16/Ch16Common.hlsl" CBUFFER_START(UnityPerMaterial) float4 _BaseMap_ST; half4 _BaseColor; half _Cutoff; float _AlphaToMask, _Sharpen, _MipScale; float4 _BaseMap_TexelSize; CBUFFER_END TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); half CutoutAlpha(float2 uv) { half a = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv).a * _BaseColor.a; if (_MipScale > 0.5) { // 远处 mip 把细线和透明背景平均成很低的 alpha,裁剪后线条就“断”了。 // 按 mip 级别把 alpha 放大一些,抵消平均带来的损失(经验做法,系数可调) float2 texel = uv * _BaseMap_TexelSize.zw; float2 dx = ddx(texel), dy = ddy(texel); float mip = max(0.0, 0.5 * log2(max(dot(dx, dx), dot(dy, dy)))); a *= 1.0 + mip * 0.3; } return a; } ENDHLSL Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForwardOnly" } Cull Off AlphaToMask [_AlphaToMask] HLSLPROGRAM #pragma vertex Ch16Vert #pragma fragment frag #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN #pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH half4 frag(Varyings i, FRONT_FACE_TYPE face : FRONT_FACE_SEMANTIC) : SV_Target { float2 uv = TRANSFORM_TEX(i.uv, _BaseMap); half4 tex = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv); half a = CutoutAlpha(uv); if (_AlphaToMask > 0.5) { if (_Sharpen > 0.5) { // 以 cutoff 为中心,把 alpha 的过渡压缩到约一个像素宽 a = (a - _Cutoff) / max(fwidth(a), 1e-4) + 0.5; } a = saturate(a); } else { clip(a - _Cutoff); // 经典 Alpha Test a = 1; } float3 N = normalize(i.normalWS) * IS_FRONT_VFACE(face, 1.0, -1.0); half3 col = SimpleLighting(tex.rgb * _BaseColor.rgb, N, i.positionWS, 0.2h); return half4(col, a); // 开启 A2C 时,这个 alpha 决定 MSAA 子样本覆盖了几个 } ENDHLSL } Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 Cull Off HLSLPROGRAM #pragma vertex vert #pragma fragment frag struct V2F { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; }; V2F vert(Attributes v) { V2F o; o.positionCS = Ch16ShadowPositionCS(v.positionOS.xyz, v.normalOS); o.uv = TRANSFORM_TEX(v.uv, _BaseMap); return o; } // 阴影贴图没有 MSAA,只能用硬裁剪 half4 frag(V2F i) : SV_Target { clip(CutoutAlpha(i.uv) - _Cutoff); return 0; } ENDHLSL } Pass { Name "DepthOnly" Tags { "LightMode"="DepthOnly" } ZWrite On ColorMask R Cull Off HLSLPROGRAM #pragma vertex vert #pragma fragment frag struct V2F { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; }; V2F vert(Attributes v) { V2F o; o.positionCS = TransformObjectToHClip(v.positionOS.xyz); o.uv = TRANSFORM_TEX(v.uv, _BaseMap); return o; } half4 frag(V2F i) : SV_Target { clip(CutoutAlpha(i.uv) - _Cutoff); return 0; } ENDHLSL } } }
// 镂空材质的三种做法(GLSL 版): // Alpha Clip:clip() 硬裁剪,边缘锯齿,远处细线会因 mip 平均而消失; // Alpha to Coverage(A2C):把 alpha 转成 MSAA 的覆盖掩码,边缘有 MSAA 级别的过渡(需要开 MSAA); // A2C + 锐化:用 fwidth 把 alpha 重新拉成“一个像素宽的过渡”,既保住细线又不发虚。 Shader "Cookbook/Ch16/Cutout" { SubShader { Tags { "RenderType"="TransparentCutout" "RenderPipeline"="UniversalPipeline" "Queue"="AlphaTest" } GLSLINCLUDE #include "Ch16/Ch16Common.glsl" uniform vec4 _BaseMap_ST; uniform vec4 _BaseColor; uniform float _Cutoff; uniform float _AlphaToMask, _Sharpen, _MipScale; uniform vec4 _BaseMap_TexelSize; uniform sampler2D _BaseMap; #ifdef FRAGMENT float CutoutAlpha(vec2 uv) { float a = texture(_BaseMap, uv).a * _BaseColor.a; if (_MipScale > 0.5) { // 远处 mip 把细线和透明背景平均成很低的 alpha,裁剪后线条就“断”了。 // 按 mip 级别把 alpha 放大一些,抵消平均带来的损失(经验做法,系数可调) vec2 texel = uv * _BaseMap_TexelSize.zw; vec2 dx = dFdx(texel), dy = dFdy(texel); float mip = max(0.0, 0.5 * log2(max(dot(dx, dx), dot(dy, dy)))); a *= 1.0 + mip * 0.3; } return a; } #endif ENDGLSL Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForwardOnly" } Cull Off AlphaToMask [_AlphaToMask] GLSLPROGRAM #ifdef VERTEX void main() { Ch16Vert(); } #endif #ifdef FRAGMENT void main() { vec2 uvT = TransformTex(uv, _BaseMap_ST); vec4 tex = texture(_BaseMap, uvT); float a = CutoutAlpha(uvT); if (_AlphaToMask > 0.5) { if (_Sharpen > 0.5) { // 以 cutoff 为中心,把 alpha 的过渡压缩到约一个像素宽 a = (a - _Cutoff) / max(fwidth(a), 1e-4) + 0.5; } a = saturate(a); } else { if (a - _Cutoff < 0.0) discard; // 经典 Alpha Test a = 1.0; } vec3 N = normalize(normalWS) * (gl_FrontFacing ? 1.0 : -1.0); vec3 col = SimpleLighting(tex.rgb * _BaseColor.rgb, N, positionWS, 0.2); fragColor = vec4(col, a); // 开启 A2C 时,这个 alpha 决定 MSAA 子样本覆盖了几个 } #endif ENDGLSL } Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 Cull Off GLSLPROGRAM VARYING vec2 uvS; #ifdef VERTEX void main() { gl_Position = Ch16ShadowPositionCS(positionOS.xyz, normalOS); uvS = TransformTex(texcoord0.xy, _BaseMap_ST); } #endif #ifdef FRAGMENT // 阴影贴图没有 MSAA,只能用硬裁剪 void main() { if (CutoutAlpha(uvS) - _Cutoff < 0.0) discard; fragColor = vec4(0.0); } #endif ENDGLSL } Pass { Name "DepthOnly" Tags { "LightMode"="DepthOnly" } ZWrite On ColorMask R Cull Off GLSLPROGRAM VARYING vec2 uvS; #ifdef VERTEX void main() { gl_Position = TransformObjectToHClip(positionOS.xyz); uvS = TransformTex(texcoord0.xy, _BaseMap_ST); } #endif #ifdef FRAGMENT void main() { if (CutoutAlpha(uvS) - _Cutoff < 0.0) discard; fragColor = vec4(gl_FragCoord.z); } #endif ENDGLSL } } }
half4 frag(Varyings i, FRONT_FACE_TYPE face : FRONT_FACE_SEMANTIC) : SV_Target { float2 uv = TRANSFORM_TEX(i.uv, _BaseMap); half4 tex = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv); half a = CutoutAlpha(uv); if (_AlphaToMask > 0.5) { if (_Sharpen > 0.5) { // 以 cutoff 为中心,把 alpha 的过渡压缩到约一个像素宽 a = (a - _Cutoff) / max(fwidth(a), 1e-4) + 0.5; } a = saturate(a); } else { clip(a - _Cutoff); // 经典 Alpha Test a = 1; } float3 N = normalize(i.normalWS) * IS_FRONT_VFACE(face, 1.0, -1.0); half3 col = SimpleLighting(tex.rgb * _BaseColor.rgb, N, i.positionWS, 0.2h); return half4(col, a); // 开启 A2C 时,这个 alpha 决定 MSAA 子样本覆盖了几个 }
void main() { vec2 uvT = TransformTex(uv, _BaseMap_ST); vec4 tex = texture(_BaseMap, uvT); float a = CutoutAlpha(uvT); if (_AlphaToMask > 0.5) { if (_Sharpen > 0.5) { // 以 cutoff 为中心,把 alpha 的过渡压缩到约一个像素宽 a = (a - _Cutoff) / max(fwidth(a), 1e-4) + 0.5; } a = saturate(a); } else { if (a - _Cutoff < 0.0) discard; // 经典 Alpha Test a = 1.0; } vec3 N = normalize(normalWS) * (gl_FrontFacing ? 1.0 : -1.0); vec3 col = SimpleLighting(tex.rgb * _BaseColor.rgb, N, positionWS, 0.2); fragColor = vec4(col, a); // 开启 A2C 时,这个 alpha 决定 MSAA 子样本覆盖了几个 }
远处细线消失的问题,本例用了一个经验做法:根据 UV 导数估算当前 mip 级别,按级别放大 alpha(每级约 30%),抵消平均带来的损失:
half CutoutAlpha(float2 uv) { half a = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv).a * _BaseColor.a; if (_MipScale > 0.5) { // 远处 mip 把细线和透明背景平均成很低的 alpha,裁剪后线条就“断”了。 // 按 mip 级别把 alpha 放大一些,抵消平均带来的损失(经验做法,系数可调) float2 texel = uv * _BaseMap_TexelSize.zw; float2 dx = ddx(texel), dy = ddy(texel); float mip = max(0.0, 0.5 * log2(max(dot(dx, dx), dot(dy, dy)))); a *= 1.0 + mip * 0.3; } return a; }
float CutoutAlpha(vec2 uv) { float a = texture(_BaseMap, uv).a * _BaseColor.a; if (_MipScale > 0.5) { // 远处 mip 把细线和透明背景平均成很低的 alpha,裁剪后线条就“断”了。 // 按 mip 级别把 alpha 放大一些,抵消平均带来的损失(经验做法,系数可调) vec2 texel = uv * _BaseMap_TexelSize.zw; vec2 dx = dFdx(texel), dy = dFdy(texel); float mip = max(0.0, 0.5 * log2(max(dot(dx, dx), dot(dy, dy)))); a *= 1.0 + mip * 0.3; } return a; }
更严谨的做法是在导入/生成贴图时,为每一级 mip 单独缩放 alpha,使“alpha > cutoff 的像素比例”与 mip 0 保持一致(Unity 的纹理导入设置里有 Mip Maps Preserve Coverage 选项,做的就是这件事)。运行时估算的好处是不依赖导入设置,缺点是系数要凭经验调。

案例 16.5抖动透明(Dither)入门单独打开 ↗
目标:让物体“半透明”却不进透明队列——依然写深度、依然投影、完全不用排序。
原理:抖动透明也叫“纱窗透明”(Screen-door Transparency):不是把颜色混合进去,而是按一个固定的阈值图案,丢弃一部分像素。不透明度 0.5 就丢掉一半的像素,远看起来就是半透明的。阈值图案用 4×4 的 Bayer 矩阵,它的 16 个阈值均匀分布在 0~1,相邻像素的阈值尽量拉开,所以任何不透明度下丢弃的像素都分布得很均匀:
// 4×4 Bayer 矩阵:16 个阈值均匀分布在 0..1,且相邻像素的阈值尽量“错开” float Bayer4x4(uint2 p) { const float m[16] = { 0.0, 8.0, 2.0, 10.0, 12.0, 4.0, 14.0, 6.0, 3.0, 11.0, 1.0, 9.0, 15.0, 7.0, 13.0, 5.0 }; p %= 4; return (m[p.y * 4 + p.x] + 0.5) / 16.0; } // opacity 为 0.5 时,正好有一半像素的阈值低于它而被保留 void DitherClip(float2 pixelPos, half opacity) { clip(opacity - Bayer4x4((uint2)pixelPos)); }
// 4×4 Bayer 矩阵:16 个阈值均匀分布在 0..1,且相邻像素的阈值尽量“错开” float Bayer4x4(uvec2 p) { const float m[16] = float[16]( 0.0, 8.0, 2.0, 10.0, 12.0, 4.0, 14.0, 6.0, 3.0, 11.0, 1.0, 9.0, 15.0, 7.0, 13.0, 5.0); p %= 4u; return (m[p.y * 4u + p.x] + 0.5) / 16.0; } #ifdef FRAGMENT // opacity 为 0.5 时,正好有一半像素的阈值低于它而被保留 void DitherClip(vec2 pixelPos, float opacity) { if (opacity - Bayer4x4(uvec2(pixelPos)) < 0.0) discard; } #endif
// 抖动透明(Dither / Screen-door Transparency):不混合,而是按屏幕位置的阈值图案丢弃一部分像素。 // 物体仍然是不透明队列、写深度、不需要排序;代价是近看能看到网点。阴影 Pass 用同样的抖动。 Shader "Cookbook/Ch16/DitherFade" { Properties { _BaseColor ("Color", Color) = (0.9, 0.5, 0.25, 1) _Fade ("Opacity", Range(0, 1)) = 0.5 [Toggle] _DitherShadow ("Dither In Shadow Pass", Float) = 1 } SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="AlphaTest" } HLSLINCLUDE #include "Assets/Cookbook/Ch16/Ch16Common.hlsl" CBUFFER_START(UnityPerMaterial) half4 _BaseColor; half _Fade; float _DitherShadow; CBUFFER_END // 4×4 Bayer 矩阵:16 个阈值均匀分布在 0..1,且相邻像素的阈值尽量“错开” float Bayer4x4(uint2 p) { const float m[16] = { 0.0, 8.0, 2.0, 10.0, 12.0, 4.0, 14.0, 6.0, 3.0, 11.0, 1.0, 9.0, 15.0, 7.0, 13.0, 5.0 }; p %= 4; return (m[p.y * 4 + p.x] + 0.5) / 16.0; } // opacity 为 0.5 时,正好有一半像素的阈值低于它而被保留 void DitherClip(float2 pixelPos, half opacity) { clip(opacity - Bayer4x4((uint2)pixelPos)); } ENDHLSL Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForwardOnly" } HLSLPROGRAM #pragma vertex Ch16Vert #pragma fragment frag #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN #pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH half4 frag(Varyings i) : SV_Target { DitherClip(i.positionCS.xy, _Fade); // SV_POSITION.xy 在片元里就是像素坐标 return half4(SimpleLighting(_BaseColor.rgb, normalize(i.normalWS), i.positionWS, 0.5h), 1); } ENDHLSL } Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 HLSLPROGRAM #pragma vertex vert #pragma fragment frag float4 vert(Attributes v) : SV_POSITION { return Ch16ShadowPositionCS(v.positionOS.xyz, v.normalOS); } half4 frag(float4 positionCS : SV_POSITION) : SV_Target { // 阴影贴图里的“像素”是阴影贴图的纹素,抖动后经过 PCF 软阴影过滤,会得到浅一些的阴影 if (_DitherShadow > 0.5) DitherClip(positionCS.xy, _Fade); return 0; } ENDHLSL } Pass { Name "DepthOnly" Tags { "LightMode"="DepthOnly" } ZWrite On ColorMask R HLSLPROGRAM #pragma vertex vert #pragma fragment frag float4 vert(Attributes v) : SV_POSITION { return TransformObjectToHClip(v.positionOS.xyz); } half4 frag(float4 positionCS : SV_POSITION) : SV_Target { DitherClip(positionCS.xy, _Fade); return 0; } ENDHLSL } } }
// 抖动透明(Dither / Screen-door Transparency):不混合,而是按屏幕位置的阈值图案丢弃一部分像素。(GLSL 版) // 物体仍然是不透明队列、写深度、不需要排序;代价是近看能看到网点。阴影 Pass 用同样的抖动。 Shader "Cookbook/Ch16/DitherFade" { SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="AlphaTest" } GLSLINCLUDE #include "Ch16/Ch16Common.glsl" uniform vec4 _BaseColor; uniform float _Fade; uniform float _DitherShadow; // 4×4 Bayer 矩阵:16 个阈值均匀分布在 0..1,且相邻像素的阈值尽量“错开” float Bayer4x4(uvec2 p) { const float m[16] = float[16]( 0.0, 8.0, 2.0, 10.0, 12.0, 4.0, 14.0, 6.0, 3.0, 11.0, 1.0, 9.0, 15.0, 7.0, 13.0, 5.0); p %= 4u; return (m[p.y * 4u + p.x] + 0.5) / 16.0; } #ifdef FRAGMENT // opacity 为 0.5 时,正好有一半像素的阈值低于它而被保留 void DitherClip(vec2 pixelPos, float opacity) { if (opacity - Bayer4x4(uvec2(pixelPos)) < 0.0) discard; } #endif ENDGLSL Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForwardOnly" } GLSLPROGRAM #ifdef VERTEX void main() { Ch16Vert(); } #endif #ifdef FRAGMENT void main() { DitherClip(gl_FragCoord.xy, _Fade); // gl_FragCoord.xy 在片元里就是像素坐标(对应 HLSL 的 SV_POSITION.xy) fragColor = vec4(SimpleLighting(_BaseColor.rgb, normalize(normalWS), positionWS, 0.5), 1.0); } #endif ENDGLSL } Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 GLSLPROGRAM #ifdef VERTEX void main() { gl_Position = Ch16ShadowPositionCS(positionOS.xyz, normalOS); } #endif #ifdef FRAGMENT void main() { // 阴影贴图里的“像素”是阴影贴图的纹素,抖动后经过 PCF 软阴影过滤,会得到浅一些的阴影 if (_DitherShadow > 0.5) DitherClip(gl_FragCoord.xy, _Fade); fragColor = vec4(0.0); } #endif ENDGLSL } Pass { Name "DepthOnly" Tags { "LightMode"="DepthOnly" } ZWrite On ColorMask R GLSLPROGRAM #ifdef VERTEX void main() { gl_Position = TransformObjectToHClip(positionOS.xyz); } #endif #ifdef FRAGMENT void main() { DitherClip(gl_FragCoord.xy, _Fade); fragColor = vec4(gl_FragCoord.z); } #endif ENDGLSL } } }
URP 自己的 LOD 交叉淡入淡出(LOD Cross-Fade)就是抖动透明:LODCrossFade.hlsl 里用屏幕坐标采样一张抖动纹理 _DitheringTexture,两个 LOD 级别用互补的阈值丢弃像素,过渡期间两者拼起来恰好覆盖每一个像素。

阴影的抖动有个细节:ShadowCaster Pass 里的 SV_POSITION.xy 是阴影贴图的像素坐标,所以抖动图案是“印”在阴影贴图上的。软阴影的 PCF 过滤会把这些网点平均,于是地面上得到的是一个浅一些的、平滑的阴影,而不是网点状的阴影。这一点在第 23 章“半透明物体的阴影”里会进一步展开。
案例 16.6双面材质入门单独打开 ↗
目标:让只有一层面的网格(树叶、纸片、旗帜、布料、开口的碗)正反两面都正确受光,并能给背面不同的颜色。
原理:Cull Off 只是让背面也生成片元,但插值出来的法线依然是网格的正面法线。当我们从背面看时,法线背对着我们,光照结果就是“错的那一面”的光照。修正方法是在片元里判断当前片元来自正面还是背面,背面就把法线取反。HLSL 的系统值 SV_IsFrontFace 提供这个信息,URP(准确说是 Core RP 的平台抽象层)把它包装成了三个宏,屏蔽了平台差异:
// 摘自 Core RP 14 ShaderLibrary/API/Metal.hlsl(D3D11.hlsl 中相同) #define FRONT_FACE_SEMANTIC SV_IsFrontFace #define FRONT_FACE_TYPE bool #define IS_FRONT_VFACE(VAL, FRONT, BACK) ((VAL) ? (FRONT) : (BACK))
half4 frag(Varyings i, FRONT_FACE_TYPE face : FRONT_FACE_SEMANTIC) : SV_Target { // IS_FRONT_VFACE 屏蔽了平台差异:D3D/Metal 是 bool,旧的 GL 是 float 符号 bool isFront = IS_FRONT_VFACE(face, true, false); float3 N = normalize(i.normalWS); if (!isFront && _FlipBackNormal > 0.5) N = -N; half3 albedo = isFront ? _BaseColor.rgb : _BackColor.rgb; return half4(SimpleLighting(albedo, N, i.positionWS, 0.3h), 1); }

Cull Off,否则薄片只有一面投影,阴影会随光照角度时有时无。② 如果用了法线贴图,背面除了翻转几何法线,还要翻转 TBN 中的副切线方向(或者直接在世界空间翻转最终法线),否则凹凸方向是反的。③ 双面渲染不等于两倍开销:被剔除的三角形本来就不产生片元,Cull Off 只是让原本被剔除的那些也参与着色。真正的开销取决于背面实际被看到的面积。16.4 模板测试
模板缓冲(Stencil Buffer)是和深度缓冲绑在一起的一块 8 位缓冲(每像素 0~255)。它本身不参与任何默认渲染,完全由 Shader 来定义含义:可以把它当成“画面上的标记层”——这块区域属于传送门、那块属于角色、这里已经画过描边了……然后让别的物体根据标记决定画还是不画。
模板测试在每个片元上做如下比较(Ref 是 Shader 给的参考值,Stencil 是缓冲中的当前值):
然后根据结果执行三种操作之一:模板测试失败执行 Fail;模板通过但深度测试失败执行 ZFail;两者都通过执行 Pass。写回时只修改 WriteMask 指定的位。
| Comp(比较函数) | Pass / Fail / ZFail(操作) |
|---|---|
| Never、Less、Equal、LEqual、Greater、NotEqual、GEqual、Always | Keep(保持)、Zero(清零)、Replace(写入 Ref)、IncrSat/DecrSat(加减 1,饱和)、Invert(按位取反)、IncrWrap/DecrWrap(加减 1,回绕) |
ReadMask 和 WriteMask 让一个 8 位模板值可以被拆成几个互不干扰的“位字段”:例如低 2 位标记“属于哪个传送门”,第 3 位标记“已经画过描边”,各个效果只读写自己的位。不同系统(你的效果、第三方插件、URP 的 Deferred 路径)共用模板缓冲时,划分好位字段是避免互相踩踏的唯一办法。
ShaderLab 里所有字段都可以用属性引用:Ref [_StencilRef]、Comp [_StencilComp],配合 [Enum(UnityEngine.Rendering.CompareFunction)] 和 [Enum(UnityEngine.Rendering.StencilOp)] 声明,材质面板上就能直接调。下面三个案例共用这样一个“带模板参数的简单受光 Shader”:
[IntRange] _StencilRef ("Stencil Ref", Range(0, 255)) = 1 [Enum(UnityEngine.Rendering.CompareFunction)] _StencilComp ("Stencil Comp", Float) = 8 // Always [Enum(UnityEngine.Rendering.StencilOp)] _StencilPass ("Stencil Pass Op", Float) = 0 // Keep
// 带模板测试参数的简单受光 Shader:Ref/Comp/Pass 全部做成材质属性。(GLSL 版) // 用它可以搭出“只在模板值等于 1 的地方可见”的物体(传送门里的世界),或者“画的时候顺便写模板”的物体(X 光的角色)。 Shader "Cookbook/Ch16/StencilLit" { SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" } GLSLINCLUDE #include "Ch16/Ch16Common.glsl" uniform vec4 _BaseMap_ST; uniform vec4 _BaseColor; uniform float _Unlit; uniform sampler2D _BaseMap; ENDGLSL Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForwardOnly" } Cull [_Cull] Stencil { Ref [_StencilRef] Comp [_StencilComp] Pass [_StencilPass] } GLSLPROGRAM #ifdef VERTEX void main() { Ch16Vert(); } #endif #ifdef FRAGMENT void main() { vec3 albedo = texture(_BaseMap, TransformTex(uv, _BaseMap_ST)).rgb * _BaseColor.rgb; if (_Unlit > 0.5) { fragColor = vec4(albedo, 1.0); return; } fragColor = vec4(SimpleLighting(albedo, normalize(normalWS), positionWS, 0.3), 1.0); } #endif ENDGLSL } Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 Cull [_Cull] GLSLPROGRAM #ifdef VERTEX void main() { gl_Position = Ch16ShadowPositionCS(positionOS.xyz, normalOS); } #endif #ifdef FRAGMENT void main() { fragColor = vec4(0.0); } #endif ENDGLSL } Pass { Name "DepthOnly" Tags { "LightMode"="DepthOnly" } ZWrite On ColorMask R Cull [_Cull] GLSLPROGRAM #ifdef VERTEX void main() { gl_Position = TransformObjectToHClip(positionOS.xyz); } #endif #ifdef FRAGMENT void main() { fragColor = vec4(gl_FragCoord.z); } #endif ENDGLSL } } }
Runtime/StencilUsage.cs 中,位 0~3 标注为“保留给用户”(UserMask = 0b_0000_1111),位 4 用于光源体积的模板,位 5~6 用于区分材质类型(Lit/SimpleLit/Unlit)。如果项目可能切到 Deferred,自己的模板逻辑最好只用低 4 位,并设置 ReadMask/WriteMask 为 15。另外 Universal Renderer Data 上有一个 Default Stencil State 覆盖项,能给整个不透明/透明 Pass 统一设置模板状态,排查“模板不生效”时别忘了看一眼。案例 16.7X 光透视:被遮挡的部分显示剪影进阶单独打开 ↗
目标:角色走到墙后时,被墙挡住的部分显示为发光的剪影,而角色自身可见的部分不受影响。
原理:案例 16.2 已经看到,ZTest Greater 能选出“被别的东西挡住”的片元。问题是“别的东西”也包括角色自己:纽结前面的管子挡住了后面的管子,后面那段管子同样满足 Greater,X 光颜色就会画到角色自己身上(图 16-12 左)。解决办法是让角色在正常绘制时顺手在模板缓冲里写 1,标记“这些像素是角色可见的部分”;X 光 Pass 再加一个条件“模板 ≠ 1”。这样,X 光只会出现在“角色被别人挡住、且这个像素上没有角色自身可见部分”的地方。
这里还有一个顺序问题:X 光 Pass 必须在墙画完之后执行(否则深度缓冲里还没有墙),也必须在角色本体写完模板之后执行。最省事的办法是把 X 光做成第二个材质,放在 Transparent 队列:
// 角色:第一个材质正常着色并写模板 1;第二个材质是 X 光剪影(Transparent 队列,ZTest Greater) var body = s.Mat("Cookbook/Ch16/StencilLit"); body.SetColor("_BaseColor", new Color(0.9f, 0.45f, 0.2f)); body.SetFloat("_StencilRef", 1); body.SetFloat("_StencilComp", (float)CompareFunction.Always); body.SetFloat("_StencilPass", (float)StencilOp.Replace); var xray = s.Mat("Cookbook/Ch16/XRaySilhouette"); // 左图:不做模板测试(Always);右图:只在模板 ≠ 1(不是角色自身可见部分)处绘制 xray.SetFloat("_StencilComp", s.Panel == 0 ? (float)CompareFunction.Always : (float)CompareFunction.NotEqual); var knot = s.Knot(body, new Vector3(0.05f, 1.0f, 0.6f), 1.15f); knot.GetComponent<MeshRenderer>().sharedMaterials = new[] { body, xray }; // 多出的材质会把网格再画一遍
当 MeshRenderer 的材质数量多于网格的子网格数量时,多出来的材质会把最后一个子网格再画一遍。两个材质各自有自己的队列:本体在 Geometry(2000),随不透明物体一起绘制并写模板;X 光材质在 Transparent(3000),等所有不透明物体(包括墙)都画完才执行。X 光 Shader 的渲染状态如下:
ZTest [_ZTest] ZWrite Off Blend SrcAlpha OneMinusSrcAlpha Stencil { Ref [_StencilRef] Comp [_StencilComp] }
// 被遮挡部分的“X 光”剪影:ZTest Greater 只画在“已有东西比我更近”的像素上, // 再用模板排除角色自身可见的部分。颜色带菲涅尔边缘,看起来像发光的轮廓。 // 既可以作为角色的第二个材质使用,也可以作为 RenderObjects Feature 的 Override Material。 Shader "Cookbook/Ch16/XRaySilhouette" { Properties { [HDR] _RimColor ("Rim Color", Color) = (0.2, 0.8, 1.6, 1) _FillAlpha ("Fill Alpha", Range(0, 1)) = 0.25 _RimPower ("Rim Power", Range(0.5, 8)) = 2 [Enum(UnityEngine.Rendering.CompareFunction)] _ZTest ("ZTest", Float) = 5 // Greater [IntRange] _StencilRef ("Stencil Ref", Range(0, 255)) = 1 [Enum(UnityEngine.Rendering.CompareFunction)] _StencilComp ("Stencil Comp", Float) = 6 // NotEqual } SubShader { Tags { "RenderType"="Transparent" "RenderPipeline"="UniversalPipeline" "Queue"="Transparent" } Pass { Name "XRay" Tags { "LightMode"="UniversalForward" } ZTest [_ZTest] ZWrite Off Blend SrcAlpha OneMinusSrcAlpha Stencil { Ref [_StencilRef] Comp [_StencilComp] } HLSLPROGRAM #pragma vertex Ch16Vert #pragma fragment frag #include "Assets/Cookbook/Ch16/Ch16Common.hlsl" CBUFFER_START(UnityPerMaterial) half4 _RimColor; half _FillAlpha, _RimPower; float _ZTest, _StencilRef, _StencilComp; CBUFFER_END half4 frag(Varyings i) : SV_Target { float3 N = normalize(i.normalWS); float3 V = GetWorldSpaceNormalizeViewDir(i.positionWS); half rim = pow(1.0h - saturate(dot(N, V)), _RimPower); half a = saturate(_FillAlpha + rim); return half4(_RimColor.rgb, a); } ENDHLSL } } }
// 被遮挡部分的“X 光”剪影:ZTest Greater 只画在“已有东西比我更近”的像素上,(GLSL 版) // 再用模板排除角色自身可见的部分。颜色带菲涅尔边缘,看起来像发光的轮廓。 // 既可以作为角色的第二个材质使用,也可以作为 RenderObjects Feature 的 Override Material。 Shader "Cookbook/Ch16/XRaySilhouette" { SubShader { Tags { "RenderType"="Transparent" "RenderPipeline"="UniversalPipeline" "Queue"="Transparent" } Pass { Name "XRay" Tags { "LightMode"="UniversalForward" } ZTest [_ZTest] ZWrite Off Blend SrcAlpha OneMinusSrcAlpha Stencil { Ref [_StencilRef] Comp [_StencilComp] } GLSLPROGRAM #include "Ch16/Ch16Common.glsl" uniform vec4 _RimColor; uniform float _FillAlpha, _RimPower; #ifdef VERTEX void main() { Ch16Vert(); } #endif #ifdef FRAGMENT void main() { vec3 N = normalize(normalWS); vec3 V = GetWorldSpaceNormalizeViewDir(positionWS); float rim = pow(1.0 - saturate(dot(N, V)), _RimPower); float a = saturate(_FillAlpha + rim); fragColor = vec4(_RimColor.rgb, a); } #endif ENDGLSL } } }

_FillAlpha 调到 0、_RimPower 调大。案例 16.8传送门 / 窗口进阶单独打开 ↗
目标:一扇门框里能看到另一个世界——紫色天幕、青色地面和几个物体,它们只在门框范围内可见;从侧面看,门里的世界依然在“门后”。
原理:用一块不可见的遮罩(门洞大小的 Quad)在模板缓冲里写 1,门里世界的所有物体都设置“模板 = 1 才画”。遮罩必须在门里的物体之前绘制,所以它的队列是 Geometry-10:
ColorMask 0 ZWrite Off Cull Off Stencil { Ref [_StencilRef] Comp Always Pass Replace // 通过(含深度测试)就把模板值替换为 Ref }
遮罩不写颜色(ColorMask 0)也不写深度——如果写了深度,门后的物体就会被这块看不见的 Quad 挡住。门里的物体用 StencilLit,设置 Comp Equal、Ref 1;门里的天幕是一个很大的球,Cull Front 从内部看,用无光照模式输出纯色。
还有一个容易忽略的地方:门外的地面是一张 60 米的大平面,它在门洞后面的部分在深度上可能比门里的物体更近(比如门里的地面在同样高度),如果门外地面不做任何模板测试,就会把门里的世界盖掉。所以门外地面也要设置模板条件:Comp NotEqual。一个可靠的规则是:凡是会出现在门洞后方的“外部”物体,都要设置 NotEqual;只出现在门前的外部物体可以不管。

ShadowCastingMode.Off),因为阴影 Pass 不做模板测试,它们的影子会出现在门外的地面上。门外物体的影子却会落到门里的地面上(图中门里地面的一道影子),如果不想要,可以让门里的材质不接收阴影,或为门里的世界使用独立的灯光层。② 透明物体、粒子在门里也要有模板测试,否则会“漏”出门外。③ 想让门里的世界有自己的天空盒、雾和后处理,模板就不够了,需要用第二台相机渲染到 RT,再贴到门洞上(这也是很多游戏的传送门做法,代价更高但更灵活)。案例 16.9用 RenderObjects 显示被遮挡部分的轮廓色进阶单独打开 ↗
目标:不修改角色的任何材质和 Shader(角色用的是 URP 自带的 Lit),只通过 URP 自带的 Render Objects Renderer Feature,实现案例 16.7 的效果。
原理:RenderObjects 是 URP 自带的一个 Renderer Feature,能在指定的时机(RenderPassEvent)把指定层(Layer)、指定队列的物体再画一遍,并可以覆盖材质、深度状态和模板状态。先看它在 URP 14 源码中暴露的设置字段:
// 摘自 URP 14 Runtime/RendererFeatures/RenderObjects.cs(RenderObjectsSettings,节选) public string passTag = "RenderObjectsFeature"; public RenderPassEvent Event = RenderPassEvent.AfterRenderingOpaques; public FilterSettings filterSettings = new FilterSettings(); // RenderQueueType、LayerMask、PassNames public Material overrideMaterial = null; public int overrideMaterialPassIndex = 0; public OverrideMaterialMode overrideMode = OverrideMaterialMode.Material; // None / Material / Shader public bool overrideDepthState = false; public CompareFunction depthCompareFunction = CompareFunction.LessEqual; public bool enableWrite = true; public StencilStateData stencilSettings = new StencilStateData(); // overrideStencilState、stencilReference、Comp、passOperation…
方案用两个 Feature 配合,思路和案例 16.7 完全相同,只是“写模板”和“画剪影”都交给 Feature 去做:
- 把角色放到单独的层(本例用第 8 层),并从 Universal Renderer Data 的 Opaque Layer Mask 里去掉这一层——默认的不透明 Pass 不再画角色。
- Feature A(Character Visible):事件 AfterRenderingOpaques,过滤 Opaque 队列 + 角色层,不覆盖材质(用角色自己的 Lit),覆盖模板状态:Ref 1、Comp Always、Pass Replace。角色被正常画出来,同时在可见像素上写入 1。
- Feature B(Character Occluded):同样的事件和过滤条件,覆盖材质为 XRaySilhouette,覆盖深度状态为 Greater、不写深度,覆盖模板状态为 Ref 1、Comp NotEqual。
- 两个 Feature 的事件相同,URP 按它们在列表中的顺序入队,所以 A 一定先于 B 执行。
在 Inspector 里就是按上面的步骤在 Renderer Data 上点选()。配图脚本里用代码完成同样的设置:
// 1) 默认的不透明 Pass 不再画角色层,交给下面两个 Feature s.Renderer.opaqueLayerMask = ~(1 << CharacterLayer); // 2) 第一个 Feature:正常画角色,同时把模板写成 1(标记“角色可见的像素”) var visible = s.Feature<RenderObjects>(); visible.settings.passTag = "Character Visible"; visible.settings.Event = RenderPassEvent.AfterRenderingOpaques; visible.settings.filterSettings.RenderQueueType = RenderQueueType.Opaque; visible.settings.filterSettings.LayerMask = 1 << CharacterLayer; visible.settings.overrideMode = RenderObjects.RenderObjectsSettings.OverrideMaterialMode.None; visible.settings.stencilSettings.overrideStencilState = s.Panel == 2; visible.settings.stencilSettings.stencilReference = 1; visible.settings.stencilSettings.stencilCompareFunction = CompareFunction.Always; visible.settings.stencilSettings.passOperation = StencilOp.Replace; // 3) 第二个 Feature:用剪影材质再画一遍,深度测试改成 Greater、不写深度, // 并且只在模板 ≠ 1 的地方画(排除角色自己挡住自己的情况) var occluded = s.Feature<RenderObjects>(); occluded.settings.passTag = "Character Occluded"; occluded.settings.Event = RenderPassEvent.AfterRenderingOpaques; occluded.settings.filterSettings.RenderQueueType = RenderQueueType.Opaque; occluded.settings.filterSettings.LayerMask = 1 << CharacterLayer; occluded.settings.overrideMode = RenderObjects.RenderObjectsSettings.OverrideMaterialMode.Material; occluded.settings.overrideMaterial = silhouette; occluded.settings.overrideMaterialPassIndex = 0; occluded.settings.overrideDepthState = true; occluded.settings.depthCompareFunction = CompareFunction.Greater; occluded.settings.enableWrite = false; occluded.settings.stencilSettings.overrideStencilState = s.Panel == 2; occluded.settings.stencilSettings.stencilReference = 1; occluded.settings.stencilSettings.stencilCompareFunction = CompareFunction.NotEqual; occluded.settings.stencilSettings.passOperation = StencilOp.Keep; // 4) Feature 在 OnEnable 时就用默认设置 Create 过一次,改完设置要再 Create 一次 visible.Create(); occluded.Create();
代码最后的 Create() 很关键。ScriptableRendererFeature 在 OnEnable 和 OnValidate 里都会调用 Create(),而 RenderObjects 的 Create() 会根据当时的 settings 构造 RenderObjectsPass,把层、事件、深度/模板状态都“烘焙”进 Pass 对象。用 ScriptableObject.CreateInstance 新建 Feature 时,OnEnable 已经用默认设置调用过一次 Create,之后在代码里改 settings 不会自动生效——必须手动再调一次(在 Inspector 里改不需要,因为会触发 OnValidate)。

✓ RenderObjects 方案
不改角色材质,任何 Shader 都适用;可以按层批量开关;美术在 Inspector 里就能配置。✗ 需要注意
角色被移出默认 Pass,依赖默认 Pass 的功能(例如 Depth Priming、部分调试视图)要单独验证;两个 Feature 各是一次额外的绘制遍历;要求模板缓冲可用(URP 的深度附件带 8 位模板)。DrawRenderers + FilteringSettings + ShaderTagId)放在第 25 章讲。本章小结
- URP 以队列 2500 为界分两段绘制:不透明由近到远(有 HSR 的 GPU 上跳过距离排序),透明由远到近;排序粒度是物体,不是三角形。
- 混合公式是 源×因子 ⊕ 目标×因子,RGB 与 Alpha 可分开设置;预乘 Alpha 让遮挡与发光共存、过滤与合成正确。
- ZTest 比较的是“此刻”的深度缓冲,所以队列顺序决定了 Always/Greater 这类测试的结果。
- 半透明自身穿插可以用双 Pass 预深度解决,URP 中 SRPDefaultUnlit Pass 先于 UniversalForward 执行;物体间的问题需要拆分、手动排序或 OIT。
- Alpha to Coverage 把 alpha 变成 MSAA 覆盖率,配合 fwidth 锐化效果最好;URP Lit 在不透明 + Alpha Clipping 时自动启用它,无 MSAA 时自动退化。
- 抖动透明不排序、写深度、可投影,代价是网点;双面材质要用
IS_FRONT_VFACE翻转背面法线。 - 模板测试先于深度测试;X 光 = 本体写模板 + 第二遍 ZTest Greater 且模板不等;传送门 = 遮罩写模板 + 内部物体 Equal + 会出现在门后的外部物体 NotEqual。
- RenderObjects Feature 可以在不改材质的前提下覆盖深度与模板状态;代码中修改 settings 后要重新调用
Create()。
练习
- 给 BlendModes Shader 加上
_SrcBlendAlpha/_DstBlendAlpha两个属性,渲染到一张带 Alpha 的 RT 上,再把 RT 合成到另一张背景图上,比较 Alpha 通道分别用SrcAlpha OneMinusSrcAlpha和One OneMinusSrcAlpha时的结果。 - 做一个“半透明水面 + 水中的半透明鱼”的场景,复现物体间排序错误;尝试用 Sorting Priority 修复,并思考摄像机进入水下时这个修复是否依然成立。
- 实现加权混合 OIT 的最简版本:一个 Renderer Feature 把所有透明物体渲染到两张 RT(累积颜色与透射率),再全屏合成。
- 修改 DitherFade,让不透明度随“物体到相机的距离”变化:离相机 1 米以内逐渐淡出,用于第三人称相机穿过角色时防止遮挡视线。
- 为传送门增加一个“门框发光边”:用模板值 2 标记门洞边缘的一圈(提示:缩小一圈的遮罩写 1、原尺寸写 2),只在值为 2 的区域画发光色。
- 把案例 16.9 改成“被遮挡时只显示轮廓线”:Feature B 改用一个外扩描边的 Shader 作为 Override Material,并思考此时模板应该怎么设。