资讯详情

Unity URP模板测试完全指南:从渲染管线原理到描边贴花实战

📅 2026/10/3 9:04:04 | 华诺云谱 👁 阅读
Unity URP模板测试完全指南:从渲染管线原理到描边贴花实战
刚接触Shader的同学一听到“模板测试”脑子里多半会蹦出三个问号模板什么模板美术模板其实它是渲染流水线逐片元阶段里一道不起眼但极其关键的关卡。在Unity URP里它决定了哪些像素能被画出来、哪些像素必须被扔掉贴花、描边、传送门、UI遮罩这些效果背后都有它的影子。这篇就以URP为例把模板测试从流水线位置、核心参数到可复现的Shader代码一次讲透。我最早在Built-in管线里用过模板测试后来切到URP发现不少老写法不能直接搬踩过不少坑。这篇文章里的代码和排查思路都是实际项目验证过的不是从文档里抄来的概念。读完你可以直接照着搭三个效果模板贴花裁剪、门洞/传送门效果、角色描边。1. 逐片元阶段的定位模板测试到底卡在哪一步1.1 GPU流水线里“片元”出现之后发生了什么要理解模板测试先把渲染流水线的概念理一下。一个物体从CPU提交到屏幕上要经过三个阶段应用阶段、几何阶段、逐片元阶段。顶点着色器、曲面细分、几何着色器都在几何阶段它们负责把模型顶点变换到屏幕坐标并生成一个个三角形。光栅化阶段把这些三角形转换成屏幕上的“片元”也就是一个个待处理的像素候选。从这往后就进入逐片元阶段了。逐片元阶段不是只有片元着色器Fragment Shader一件事。完整的流程大致是光栅化生成片元 → 片元着色器 → 裁剪测试 → 模板测试 → 深度测试 → 混合 → 写入帧缓冲这里有个容易搞混的点片元着色器输出颜色后片元还要过好几道关卡才能真正变成屏幕像素。裁剪测试用来裁掉视口外的部分模板测试和深度测试负责“这个片元到底能不能显示”混合决定颜色和已有颜色的叠加方式。在不同图形API里这些测试的顺序细节略有差异。比如在DirectX和Vulkan中深度和模板通常合并在同一个Depth-Stencil阶段里实现OpenGL里则是模板测试在前、深度测试在后。不过对Unity开发者来说只要知道它们都发生在片元着色器之后、混合写入之前就足够理解绝大部分使用场景了。1.2 模板测试到底解决什么问题模板测试解决的是一个很朴素的问题如何在像素级别控制“谁能画在这”。它不像深度测试那样比较远近而是拿一张额外的“标记图像”来当关卡。具体工作方式是这样的GPU的帧缓冲里有一块“模板缓冲区”Stencil Buffer每个像素位置存一个8位整数取值范围0到255。渲染某个物体时每个片元都可以和当前像素位置的模板值做一次比较比较通过才继续往后走同时根据比较结果还能修改这个位置的模板值。打个比方就更清楚了。深度测试是“先来后到”谁离相机近谁留下。模板测试则是“对暗号”片元必须出示正确的工牌才准进门。进了门之后要不要给工牌盖上章、盖什么章就是Stencil操作参数管的。这个机制最大的价值在于它可以跨物体协作。一个物体A负责“写标记”另一个物体B负责“按标记过滤”两个物体甚至可以是完全不同的网格、不同的材质。这就能实现很多用普通混合和深度测试做不到的效果。比如你想在地板上画一个火焰形状的图案但火焰图片是矩形的怎么让矩形边界外的部分自动消失用模板测试先在地板上做个火焰形状的标记区域再让贴花图片只在标记区域内显示矩形多余的部分就被自动丢弃了。1.3 模板缓冲区和深度缓冲区有什么关系模板缓冲区和深度缓冲区在硬件上经常是绑在一起的所以会看到“Depth-Stencil”这种叫法。Unity里如果开启了深度纹理一张RenderTexture的深度通道同时可以携带模板信息。这也是为什么有些后处理技巧能从深度法线纹理里挖出高4位当模板值用。但要注意两个缓冲区的用途完全不同。深度缓冲区存的是每个像素的深度值是一个浮点数或定点数用来判断可见性。模板缓冲区存的是自定义标记是一个8位整数。深度值可以直接用模板值更像一张“地图”具体什么意思完全由你的Shader逻辑决定。因为模板值只有8位能表示0到255所以同一个像素位置理论上可以叠加最多256种标记组合。实际项目里一般用不了这么多但如果你搞技能范围提示、区域重叠检测这类效果需要记录“这片区域被几个技能圈覆盖”8位也够用了。2. 模板测试的五个核心参数从入门到敢改2.1 Ref、Comp、Pass、Fail、ZFail逐个说清楚Unity Shader里配置模板测试用的是Stencil代码块。最常见的参数是这么几个参数含义Ref参考值0到255的整数Comp比较函数定义参考值和当前模板值的比较方式Pass模板测试和深度测试都通过时对模板缓冲执行的操作Fail模板测试本身失败时对模板缓冲执行的操作ZFail模板测试通过但深度测试失败时对模板缓冲执行的操作这几个参数中Ref和Comp决定了“哪些片元能通过这道门禁”。Comp的取值有Always、Never、Equal、NotEqual、Less、Greater、LEqual、GEqual语义和HLSL里的比较函数差不多。这里最容易写反的是Less和Greater的方向Comp Less表示“当 Ref 小于当前模板值时通过”不是“当前模板值小于Ref时通过”。我在项目里就吃过这个亏排查了半小时才发现比较方向反了。Pass、Fail、ZFail操作的是模板缓冲区本身。比如Pass设为Replace那么片元通过测试后当前位置的模板值会被替换成Ref。Fail设为Keep那么模板测试失败的片元不会影响缓冲区。ZFail是很多人会忽略的参数它在“片元通过了模板测试但被深度测试淘汰”时触发。这个参数在光晕、遮挡标记这类效果里非常有用比如你想在物体被遮挡的地方也留下模板标记就得靠ZFail。2.2 StencilOp八个操作值对照Stencil操作不是只有Keep和ReplaceUnity提供了一整组操作值操作值行为Keep保持当前模板值不变Zero将当前模板值设置为0Replace将当前模板值替换为RefIncrSat模板值加1超过255则停在255DecrSat模板值减1低于0则停在0Invert对模板值按位取反IncrWrap模板值加1255加1后回绕到0DecrWrap模板值减10减1后回绕到255这里有几个细节值得说。IncrSat有饱和IncrWrap有回绕两者在重叠计数场景里的表现完全不同。如果只是统计“覆盖次数”用IncrSat就够了它保证值不会溢出。但如果你想把模板值当成ID循环使用比如给不同区域轮流编号IncrWrap更合适。Invert是个容易误用的操作。它不是简单的“取反加一”而是按位取反也就是把0变255、把255变0。如果你想实现“每经过一次就翻一次标记状态”Invert很合适但如果你以为它是“数值正负翻转”结果会很诡异。实际操作里一个Pass里通常只会用到一两个操作值。比如做“写标记”Pass时设置Comp Always Pass Replace意思是“不用比较直接盖章替换成指定值”。做“按标记过滤”Pass时设置Comp Equal Pass Keep意思是“值相等才放行而且不改动标记”。2.3 URP中如何把模板参数暴露给材质面板模板参数写死在Shader里不是不行但每次调数值都要改代码太痛苦了。在URP里可以通过材质属性把Ref暴露出来。在Properties块里加上[IntRange]_StencilRef(Stencil Ref, Range(0, 255)) 1然后在Stencil块里用方括号引用Stencil { Ref [_StencilRef] Comp Equal Pass Keep }这样材质面板上就会出现一个整数滑动条从0到255不用碰代码就能切换标记值。这个技巧在做多角色描边时特别有用每个角色用不同Ref值标记再用不同的描边颜色区分阵营美术调起来非常快。ReadMask和WriteMask是两个容易被忽视的参数。它们按位过滤模板值。比如WriteMask设为240也就是二进制11110000那么写模板时只会修改高4位低4位保持不变。这样同一个模板缓冲区就能存两组互不干扰的标记高4位存阵营低4位存区域编号。需要精细控制时这两个参数是利器。注意Stencil块还支持CompBack、PassBack、FailBack、ZFailBack这些带Back后缀的参数它们单独控制背面片元的行为。默认情况下不写这些参数会沿用正面参数双面渲染时容易出偏差要特别留意。3. URP模板状态配置与第一个可运行的Shader3.1 URP与Built-in在Stencil写法上的差异URP和Built-in管线写Stencil的语法大体一致都是ShaderLab里的Stencil块但有几个实际差异要注意。首先是Shader整体语言从CGPROGRAM转向HLSLPROGRAM。URP的官方Shader模板基本都用HLSL底层通过Core.hlsl、Lighting.hlsl组织。你如果继续沿用Built-in的CG写法部分环境下能跑但没法享受SRP Batcher的合批优化而且很多URP内置函数用不了。其次是Pass的LightMode标签。URP里不透明物体的主Pass一般标记为UniversalForwardURP 14以上也有UniversalForwardOnly还有专门用于深度预处理的DepthOnly。Stencil状态是挂在具体的Pass上的所以同一个Shader的不同Pass可以有不同的模板行为。这一点既是优势也是坑后面讲DepthOnlyPass时会详细说。最后是渲染顺序。URP和Built-in一样用Queue控制渲染顺序模板写入Pass和被过滤Pass必须在顺序上配合。写模板的物体必须排在读模板的物体之前否则读的时候标记还没写上。3.2 模板写入ShaderColorMask 0的“盖章机”先写一个最基础的模板写入Shader作用是在屏幕上“盖章”不输出任何颜色。这个Shader可以作为模板效果的“载体”比如你要在地板上做一块任意形状的标记区域就把它放到一个平面上。Shader Custom/StencilWriter { SubShader { Tags { RenderTypeOpaque QueueGeometry-1 } ZWrite Off ColorMask 0 Stencil { Ref 1 Comp Always Pass Replace } Pass { HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl struct Attributes { float4 positionOS : POSITION; }; struct Varyings { float4 positionHCS : SV_POSITION; }; Varyings vert(Attributes IN) { Varyings OUT; OUT.positionHCS TransformObjectToHClip(IN.positionOS.xyz); return OUT; } half4 frag(Varyings IN) : SV_Target { return 0; } ENDHLSL } } }这个Shader有几个关键设定。ColorMask 0代表完全不输出颜色屏幕上不会出现奇怪的白块。ZWrite Off是不写深度避免一个看不见的平面把场景里的物体挡掉。Queue设为Geometry-1让它在所有普通不透明物体之前渲染这样后续读模板的物体就能拿到标记。这时候可能会有一个疑问模板测试也要过深度测试吗答案是分平台分情况。在多数API里一个片元只有通过了深度测试才会执行ZFail之后的Pass操作。如果模板写入物体的Queue太靠后深度缓冲区里已经有其他物体的深度它的片元可能被深度测试挡掉导致模板没写上。这也是为什么写入模板的物体要尽量排在前面或者在必要时设置ZTest Always。3.3 第一个案例实战用地板模板做贴花裁剪有了“盖章机”做一个地板贴花裁剪效果就很简单了。场景里放置两个物体一个平面Quad或Plane套用StencilWriter材质形状和位置就是你想要的贴花区域比如地板上的一个六边形。一个贴花平面稍微抬高一点套用下面的StencilDecal材质纹理可以是一张火焰、脚印或者魔法阵。贴花Shader长这样Shader Custom/StencilDecal { Properties { _MainTex(Decal Texture, 2D) white {} _Color(Color, Color) (1, 1, 1, 1) } SubShader { Tags { RenderTypeTransparent QueueTransparent } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off ZTest Always Stencil { Ref 1 Comp Equal Pass Keep } Pass { HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionHCS : SV_POSITION; float2 uv : TEXCOORD0; }; Varyings vert(Attributes IN) { Varyings OUT; OUT.positionHCS TransformObjectToHClip(IN.positionOS.xyz); OUT.uv IN.uv; return OUT; } half4 frag(Varyings IN) : SV_Target { half4 col tex2D(_MainTex, IN.uv) * _Color; return col; } ENDHLSL } } }原理一句话就能讲完StencilWriter先在地板上画了一个“标记值为1”的区域StencilDecal的每个片元都要拿自己的Ref值1和当前位置的模板值比较只有等于1的片元才被放行。贴花纹理是矩形的但模板区域是六边形所以矩形四角那些片元因为模板值不等于1被丢弃最终只留下六边形里的贴花边缘干净利落。这里把Decal的渲染队列放在Transparent是因为它要等在Geometry队列的写模板平面执行完之后再渲染。ZTest Always保证贴花平面不会被地板本身的深度挡住即使它和地板重叠也不会被裁剪。这个技巧在很多游戏里都有应用比如地板上的技能范围指示、墙上的涂鸦、地面血迹。它比动态Mesh裁剪简单也比贴花投影方案兼容性好缺点是需要额外物体和DrawCall数量不多时性价比很高。3.4 实操时如何验证模板值写完Shader先别急着调外观先用Unity自带的Frame Debugger验证模板状态。菜单路径是Window Analysis Frame Debugger打开后点击Enable逐帧查看DrawCall列表。选中一个DrawCall右侧的Shader Properties或Render State面板里能看到当前Pass的Stencil配置Ref、Comp、Pass、Fail这些参数都会列出来。如果看不到模板值的具体存储内容可以用RenderDoc抓帧。RenderDoc能直接查看Depth-Stencil Buffer的纹理数据你可以看到每个像素的模板值分布排查“哪个区域标记没写上”这类问题非常直观。4. 更实用的两个效果门洞裁剪与角色描边4.1 门洞/传送门效果把场景“抠”进门框模板贴花能控制矩形贴花显示在特定区域同理也能控制一整块画面显示在特定区域这就引出门洞和传送门效果。最简单的传送门做法是架设一台额外相机拍摄门后面的场景把渲染结果输出到一张RenderTexture然后把这张RT作为纹理贴到一个和门洞大小一致的平面上。如果不加任何处理这块平面就是一个“贴了画面的亮块”和周围环境格格不入。这时让模板测试出场先在门洞位置用StencilWriter盖一个“门洞形状”的标记再让贴了RT的平面用Comp Equal过滤。结果就是RT画面只在门洞里显示门洞形状之外的像素全部被丢弃看起来就像真的从门洞看到了另一个空间。如果门洞形状复杂比如拱门加上花纹镂空也完全没问题。模板缓冲区是像素级的你想怎么抠就怎么抠只需要把StencilWriter的平面换成对应的形状。这个思路还能扩展出不少玩法电梯井的深渊、镜像镜子、全息投影窗口。核心都是“先标记区域再按区域裁剪渲染结果”。4.2 角色描边三Pass循环的模板用法角色描边是模板测试另一个经典用法也是很多卡通渲染里必备的一环。先说一下常见的错误做法避免你走弯路。新手最常想到的方案是把模型背面放大一点沿着法线方向外扩顶点然后只渲染背面配合深度测试来做描边。这个方案能出效果但问题在于当相机靠近模型时背面外扩的三角形会有一部分穿进模型正面被深度测试挡掉描边变细甚至消失相机远离时描边又会变得很宽。描边和模型的相对关系完全取决于相机距离很不稳定。用模板测试可以解决这个问题。我的做法是三个Pass配合Pass 1只写模板不写颜色。正常渲染模型的正面模板值设为1。Pass 2正常渲染模型的颜色走一遍普通的不透明Pass。Pass 3沿法线外扩顶点只渲染背面模板比较设为NotEqual后通过。已经在模板里被标记为1的区域不画只画模型边缘外圈那一层。Shader代码大致如下Shader Custom/StencilOutline { Properties { _MainTex(Texture, 2D) white {} _Color(Color, Color) (1, 1, 1, 1) _OutlineColor(Outline Color, Color) (1, 1, 0, 1) _OutlineWidth(Outline Width, Range(0, 0.1)) 0.02 } SubShader { Tags { RenderTypeOpaque QueueGeometry } // Pass 1只写模板 Pass { Name StencilFill Cull Back ColorMask 0 ZWrite On Stencil { Ref 1 Comp Always Pass Replace } HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl struct Attributes { float4 positionOS : POSITION; }; struct Varyings { float4 positionHCS : SV_POSITION; }; Varyings vert(Attributes IN) { Varyings OUT; OUT.positionHCS TransformObjectToHClip(IN.positionOS.xyz); return OUT; } half4 frag(Varyings IN) : SV_Target { return 0; } ENDHLSL } // Pass 2正常颜色 Pass { Name ColorPass Cull Back ZWrite On HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionHCS : SV_POSITION; float2 uv : TEXCOORD0; }; Varyings vert(Attributes IN) { Varyings OUT; OUT.positionHCS TransformObjectToHClip(IN.positionOS.xyz); OUT.uv IN.uv; return OUT; } half4 frag(Varyings IN) : SV_Target { return tex2D(_MainTex, IN.uv) * _Color; } ENDHLSL } // Pass 3外扩描边 Pass { Name OutlinePass Cull Front ZWrite Off Stencil { Ref 1 Comp NotEqual Pass Keep } HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl float _OutlineWidth; half4 _OutlineColor; struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; }; struct Varyings { float4 positionHCS : SV_POSITION; }; Varyings vert(Attributes IN) { Varyings OUT; float3 posOS IN.positionOS.xyz IN.normalOS.xyz * _OutlineWidth; OUT.positionHCS TransformObjectToHClip(posOS); return OUT; } half4 frag(Varyings IN) : SV_Target { return _OutlineColor; } ENDHLSL } } }这个方案的关键在于Pass 1和Pass 3的配合。Pass 1把模型正面占用的像素位置都标记为1。Pass 3外扩背面时真正露出来的只有模型轮廓外圈那一层这一层的位置没被标记过模板值还是0所以Comp NotEqual能放行而外扩背面深入模型内部的那部分因为位置已经被Pass 1标记为1就被模板测试丢弃了。最终描边只出现在模型边缘外圈厚薄均匀不受相机距离影响。4.3 描边方案的权衡与延展模板描边不是唯一选择。后处理描边用屏幕空间的边缘检测算法做全屏统一描边很省事但因为是在整张屏幕上找边缘会连带把场景里的硬边、阴影边界一起描出来很难单独给某个角色加描边。模板描边的优点是精准控制单物体、批量控制多物体缺点是每个角色要多两个PassDrawCall翻倍。实际项目里怎么选如果是卡通渲染、固定视角、主角就一两个角色模板描边很稳。如果是大量敌人同时出现、机器性能吃紧可以考虑屏幕空间描边或者用额外的深度预处理Pass配合边缘检测。没有银弹适合场景的就是好的。模板描边还能玩出花样。比如多角色阵营区分给不同阵营的角色用不同的Ref值描边Pass的Comp NotEqual再配上对应的描边颜色就能在一遍渲染里自动给每个阵营描不同颜色的边。这个操作用UE里的Custom Stencil也能做但Unity URP在Shader级别就能控制灵活度其实更高。5. 避坑手册我在URP里踩过的模板测试的坑5.1 模板写入了但不起作用的排查顺序模板测试报错不像C#抛异常那么明显最常见的症状就是“效果没出来”。我自己的排查顺序是这样的先确认渲染顺序。写模板的物体必须排在读模板的物体前面。在Frame Debugger里看DrawCall顺序写标记的物体如果排在后渲染的物体后面那基本上就是Queue设置出了问题。写模板物体用Geometry-1读模板物体放在普通不透明或透明队列通常能解决。再确认写模板的物体有没有真的通过深度测试。如果它被别的物体挡住了模板就不会被写入。此时要么调整Queue让它在场景最前面渲染要么给它加ZTest Always。然后确认ColorMask 0写模板的Pass如果不加这一行经常会在屏幕上留下一个莫名其妙的纯色块。最后检查比较方向。大部分“没效果”其实是Comp Equal和Comp NotEqual用反了或者Ref值两边不一致。这里没什么技巧老老实实对一遍参数。5.2 URP的DepthOnlyPass警告URP会自动给不透明物体生成深度预渲染Pass但这里有个坑如果你自己写的Shader没有DepthOnlyPassURP的SRP Batcher会走默认深度Pass而这个默认Pass往往不带你的模板设置。结果就是主Pass里写的模板值可能在深度预渲染环节被覆盖或者读取时机错乱。这个问题的经典表现是某些角度模板效果正常换个相机角度就失效了。排查方法是在Shader里显式声明一个DepthOnlyPass并且把主Pass的Stencil块原样复制过去。Pass { Name DepthOnly Tags { LightMode DepthOnly } ColorMask 0 Stencil { Ref 1 Comp Always Pass Replace } HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl // 顶点/片元代码略 ENDHLSL }这个坑比较隐蔽网上讨论也不多碰上时别怀疑URP坏了先检查DepthOnlyPass。5.3 透明队列和模板的相爱相杀透明物体和模板测试的配合要小心。QueueTransparent意味着这个物体在绝大多数不透明物体之后渲染如果它要在写模板的物体后面读模板往往没问题但如果它自己还要写模板给后面的物体用扯皮就来了。比如一个半透明光罩要做成只能显示在地板上光罩在Transparent队列写模板的地板在Geometry队列顺序没问题。但如果一个粒子系统要在自己的能力圈内显示粒子在Transparent队列而读模板的“圈内高亮”也在Transparent队列两者顺序就不好控制了。我的习惯是把写模板的物体尽量放在Geometry队列前段不要依赖Transparent队列尤其是粒子、特效这类渲染顺序不稳定的物体能用独立的RendererFeature控制顺序就不要依赖Shader里的Queue。5.4 后处理里读模板、移动端性能提醒很多同学想在URP后处理Shader里读模板值比如做描边、做区域高亮后期的调整。URP默认的_CameraDepthTexture只有深度数据模板信息并没有一起打包进去直接读是拿不到的。想要模板数据到后处理通常需要额外写一个RendererFeature或者DepthNormals预处理把模板值转换到一张颜色纹理里再传给后处理Shader。这一步不是开箱即用的别被网上老旧的Built-in教程误导。手机端的模板测试性能也要心里有数。模板读写本身不贵但它会增加Depth-Stencil缓冲区的带宽占用。如果你同时开了多个写模板的Pass、又在后处理里反复读取移动端功耗会明显上升帧率曲线会很难看。能用一次Replace完成的标记就不要拆成三个Pass做累加。换句话说模板测试是“按需使用、用完即走”的工具不适合当作贯穿全屏的常驻机制。最后分享一个实际经验模板测试这种东西看十篇文档不如亲手跑一遍。我最初是在一个MOBA类项目里做技能范围提示时接触它的当时想在地面上画一个扇形的技能预警区贴图是矩形的怎么都切不出扇形边界。后来在Frame Debugger里看到模板值一步步变化才彻底明白了这个机制。做渲染功能时不要把模板测试想成一个“高深算法”它就是一道门禁加一枚印章你在合适的位置让它放行它就帮你把像素管理得明明白白。如果你正打算在URP里实现类似效果建议按文中的三个案例顺一遍先跑通StencilWriter和StencilDecal的贴花裁剪再试试门洞效果最后做角色描边。跑通之后再在Frame Debugger里观察每个DrawCall的模板状态变化很多参数一看就懂比死记硬背快得多。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑