做了这么多年图形相关的东西,一直觉得渲染管线里最容易被忽视又最实用的一个环节就是模板测试。很多人知道深度测试、知道混合模式,但提到模板测试就只记得一个“这玩意儿能做描边”,真到了项目里要用,又容易被各种细节卡住。加上Unity的URP普及之后,不少资料还停留在Built-in管线的写法上,新手照着抄经常跑不出预期效果。这篇我就以URP为背景,把逐片元阶段里的模板测试整个拆开讲透,顺带把我自己踩过的坑一并交代清楚。
1. 逐片元阶段里,模板测试到底插在哪一步?
1.1 片元从着色器出来后的“质检流程”
一个物体从CPU提交到GPU,经过顶点着色器、光栅化,然后在片段着色器里算出颜色,这个颜色并不能直接写到屏幕上。它还要过一套“质检流水线”,模板测试就是其中一道关卡。整个逐片元阶段可以理解为一片片即将被涂到画面上的“贴纸”,要经过以下检查才能最终贴上去:
| 阶段 | 作用 | 说明 |
|---|---|---|
| 裁剪测试 | 剔除视口矩形以外的片元 | 最廉价的淘汰机制 |
| 模板测试 | 按模板缓冲的数值决定去留 | 本篇文章主角 |
| 深度测试 | 按物体前后关系决定遮挡 | 和模板测试是搭档 |
| 混合 | 把颜色和已有颜色混合 | 透明物体的关键步骤 |
在统一渲染架构下,URP里片元阶段大致就是这个顺序。需要特别注意的是,模板测试在硬件上往往和深度测试绑定在一起执行,原因是深度缓冲和模板缓冲经常共用同一块显存区域,比如D24S8这种格式,前24位存深度,后8位存模板。由于这个物理特性,很多移动设备GPU都把模板测试和深度测试放在同一个时钟周期里处理。
1.2 模板测试和深度测试的关系
很多人问:模板测试和深度测试到底谁在前谁在后?这其实取决于你站在哪个层面看。从Unity的ShaderLab语法来说,Stencil块和ZTest都是独立设置的,引擎层面先执行模板测试再执行深度测试,但如果模板测试失败,GPU可以直接跳过深度测试和后续的像素着色器写入操作,这是一种硬件层面的性能优化。
这里有个容易被忽略的细节:模板测试失败后,是可以配置成“仍然执行深度测试”的。光栅化的片元在通过模板测试、深度测试之前,像素着色器已经执行完毕了,但这并不代表后续的写入也会发生。只要模板测试或深度测试任何一个失败,颜色缓冲就不会被写入,取而代之的是一张“谁都没看见的测试结果”。
我用一个传送门的例子来说明这个关系——这里稍后会展开,先记住一个结论:模板测试适合做“先标记区域,再按标记筛选片元”的逻辑,而深度测试适合做“谁离相机更近谁显示”的逻辑,两者不冲突,经常一起配合使用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 模板缓冲与测试机制:一张8位图如何决定片元的生死
2.1 模板缓冲的硬件形态
模板缓冲最直观的理解是:它是和屏幕分辨率一样大的一张二维数组,每个像素存一个整数值,通常是8位,因此取值范围是0到255。你在Shader里看到的Ref 2,意思就是“我希望这个片元对应的模板值等于2时通过测试”。
和深度缓冲最大的区别在于,深度缓冲存的是连续深度值,反映的是几何距离远近;模板缓冲存的是人工标记的整数,完全由开发者自由定义。你完全可以把模板缓冲当成“画布上的遮罩层”——你先用某种方式在遮罩层上画几个图形,之后所有的片元都必须拿自己所在位置的遮罩值和你的设定比对,符合条件才放行。
2.2 比较函数与参考值:条件判断的原始代码
Unity ShaderLab里模板测试的语法长这样:
glsl复制Stencil
{
Ref 2
Comp Equal
Pass Keep
}
这段的意思是:参考值是2,比较条件是“相等”,当片元的模板缓冲值等于2时,这个片元通过测试,并且保持模板缓冲里的值不变。
比较函数的选项就那几个,但恰恰是这几个选项构成了所有模板效果的表达基础:
| 比较函数 | 语义 | 常用场景 |
|---|---|---|
| Always | 总是通过 | 用于给模板缓冲打标记 |
| Never | 永远失败 | 配合Pass指令做某些特殊写入 |
| Equal | 参考值等于缓冲值 | 区域匹配的最终筛选 |
| NotEqual | 参考值不等于缓冲值 | 排除特定标记区域 |
| Less | 参考值小于缓冲值 | 数值区间控制 |
| Greater | 参考值大于缓冲值 | 数值区间控制 |
| LEqual / GEqual | 小于等于 / 大于等于 | 边界包含类控制 |
注意比较是被动的:系统拿Ref的值和缓冲里的值做比较,而不是拿缓冲值和缓冲值比。比如你设置了Comp Greater、Ref 3,那么模板缓冲值为4、5、6的地方能通过,值为0、1、2的地方会被挡掉。
2.3 通过、失败、深度失败后的操作
Comp只是判据,真正决定模板缓冲状态的是后面那三个操作:Pass(模板测试+深度测试都通过时)、Fail(模板测试失败时)、ZFail(模板测试通过但深度测试失败时)。
Pass、Fail、ZFail可选的操作如下:
| 操作 | 效果 | 典型用途 |
|---|---|---|
| Keep | 保持原样 | 多数情况下用这个 |
| Zero | 设为0 | 清除模板值 |
| Replace | 设为Ref值 | 写入标记 |
| IncrSat | 加1,饱和到255 | 计数类效果 |
| DecrSat | 减1,饱和到0 | 计数类效果 |
| Invert | 按位取反 | 少见但有意思 |
| IncrWrap / DecrWrap | 加1/减1并回绕 | 连续动画计数 |
这里最核心的操作就是Replace,它的作用是:当片元通过测试时,把模板缓冲当前值替换成Ref里写的数值。这是所有“打标记”效果的基础。而Keep更多时候用于“只做筛选不写标记”的场景。
举个例子:你想让角色周围的区域标记为1,其他区域保持0。那么你可以先用一个透明的几何体(比如一个比角色略大的球体)一次性渲染,Stencil块设为Ref 1、Comp Always、Pass Replace。这等于告诉GPU:不管现在缓冲里是什么,只要这个球体覆盖到的像素,统统把模板缓冲改成1。然后你渲染角色本体时,设置Ref 1、Comp Equal、Pass Keep,只有在模板值为1的地方,角色的像素才会被写进屏幕。
这个“先标记、后筛选”的双Pass思路,可以推导出无数个效果。
3. Unity URP里的Stencil配置:从ShaderLab语法到RenderObjects
3.1 ShaderLab Stencil块基础写法
URP目前的Shader结构默认使用ShaderLab语法,所以模板测试的写法根本没有变化,差别在于URP自定义的Shader往往要自己写Pass和渲染状态。默认的Lit Shader不暴露Stencil参数,但你自己动手写Shader的时候完全可以加上。
最简单的URP无光照Shader加上Stencil是这样的:
glsl复制Shader "Custom/StencilTest"
{
Properties
{
_Color ("Color", Color) = (1,1,1,1)
_Ref ("Stencil Ref", Int) = 1
_Comp ("Stencil Comp", Int) = 8
_Op ("Stencil Pass Op", Int) = 0
}
SubShader
{
Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" }
Pass
{
Stencil
{
Ref [_Ref]
Comp [_Comp]
Pass [_Op]
}
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 input)
{
Varyings output;
output.positionHCS = TransformObjectToHClip(input.positionOS.xyz);
return output;
}
half4 frag (Varyings input) : SV_Target
{
return _Color;
}
ENDHLSL
}
}
}
注意_Comp和_Op的取值要和Unity的枚举值对齐:Comp的Always对应8,Equal对应3;Pass的Keep对应0,Replace对应2。这些数值进材质面板后你直接手写数字就行,比在代码里传枚举字符串方便。
3.2 URP中两个容易漏掉的细节:DepthTexture与透明物体
URP里使用模板测试有两大坑,我周围已经有不少同事和朋友中招过。
第一个坑是:URP默认不生成DepthTexture,而模板缓冲的写入往往依赖深度信息。尤其是你想用ZFail写模板的时候,如果深度缓冲没有正常生成,ZFail永远不会触发。你可以在URP Asset里勾选Depth Texture选项,或者在自己的Shader中额外加一个DepthOnlyPass,确保深度信息可用。
第二个坑是:透明物体默认会写入模板缓冲,这有时不是你想要的。URP的透明队列(Transparent Queue)执行顺序在大多数不透明物体之后,如果你在一个半透明的UI上设置了模板操作,很可能会覆盖掉之前不透明物体写入的模板标记。解决办法是在对应的Pass里把所有模板操作都设为Keep,让它在模板维度上完全隐形。
3.3 用URP Renderer Feature做运行时模板控制
如果不想重写整套Shader,URP提供了一个更轻量的路径:Renderer Feature。在URP Asset的Renderer List里选中一个Renderer,点击Add Renderer Feature,选择Render Objects。这个Feature支持对指定的Layer或RenderQueue范围内的物体,在某个CameraEvent点额外渲染一个Pass,并且直接暴露Stencil参数配置。
它的界面里有一组Stencil设置:Stencil Reference、Stencil Compare、Stencil Pass、Stencil Fail、Stencil ZFail。你不需要写Shader,直接把值配好,就能在指定物体上执行一个完整的“标记或筛选”操作。
这个方案有两点需要注意。第一,Render Objects的Pass是额外的渲染,不是原始Pass的副本,所以Shader本身的颜色输出逻辑你要写清楚,要么给物体配一个纯标记用的材质,要么让Shader在URP下兼容无光照模式。第二,Feature的执行顺序是按列表从上到下的,如果你需要“先标记后筛选”,务必把标记物体的Feature排在前面。
4. 经典案例拆解:传送门、角色描边、UI遮罩
4.1 传送门效果:两个Pass的先后配合
传送门是我觉得最能体现模板测试本质的一个例子。很多教程用“相机纹理”做传送门,但用模板测试是另一种思路,而且更轻量、不需要额外相机。
做法分两步。第一步,把传送门的洞口模型渲染进模板缓冲,记为“门内区域”。我给这个洞口写一个极简的Pass,Stencil上标记为Ref 2、Comp Always、Pass Replace。洞口模型的大小位置和实际的传送门框完全重合,渲染出来的结果就是模板缓冲里出现一块值为2的“门内区域”。
第二步,把传送门另一边的场景内容渲染到屏幕上,但只显示“门内区域”。这里有一个强烈的约束:物体的渲染顺序必须保证在洞口标记之后。要么把门内物体放在更高的RenderQueue,要么使用Renderer Feature把整个渲染拆成两个事件点。渲染这组物体时Stencil设置Ref 2、Comp Equal、Pass Keep。
这套做法听着简单,实际落地时有个大坑:如果门内物体跨越了多个相机或半透明材质,它们之间也会互相交叠,模板标记可能被中途意外覆盖。解决办法是把标记物体的所有Pass全部改为Keep,避免非预期写入。
4.2 角色描边:模板测试Don't让我头痛的描边算法
角色描边方案其实一抓一大把,但模板版描边在某些场景下依然不可替代,因为它对“被遮挡的轮廓”和“不规则的间隙”非常准确。
模板描边的思路是:先正常渲染角色,模板标记为Ref 1、Comp Always、Pass Replace。这一步完成时,屏幕上凡是角色覆盖过的像素,模板值都是1。然后渲染一圈放大的角色模型,模板设为Ref 1、Comp NotEqual、Pass Keep。由于角色原本覆盖的像素模板值是1,放大的模型在这些位置会被NotEqual挡掉;但放大模型超出原本轮廓一圈的像素,模板值仍是0,于是NotEqual通过,这些外围像素会被渲染成描边颜色。
这个方案的数学逻辑非常干净,而且描边粗细和模型放大的比例直接相关,你不需要额外采样深度图。唯一要注意的:放大的模型不能有背面的深度写入干扰,所以描边Pass通常要设置Cull Front只渲染背面,或者把放大部分用顶点沿法线外扩。
用代码写出来就是:
glsl复制// Pass 1: 正常角色
Stencil
{
Ref 1
Comp Always
Pass Replace
}
// Pass 2: 描边放大
Stencil
{
Ref 1
Comp NotEqual
Pass Keep
}
Cull Front
这个方法比后处理描边最大的优势在于:它可以完整保留模型的深浅遮挡关系,描边不依赖屏幕空间边缘检测,内容上有棱有角的机械感尤其漂亮。
4.3 UI遮罩:模板缓冲的另一种思路
Unity的UGUI早就在做类似的事,RectMask2D组件内部实现就是靠模板测试。它的原理是:每个Mask组件把可选区域写入模板缓冲,子物体渲染时做Comp Equal筛选。这是一层非常轻量的硬件级裁剪,不像RectMask2D每次要重新计算顶点裁切。
如果你想自定义一套不规则形状的UI遮罩,完全可以直接在相机上放一个RawImage或Quad,专门负责往模板缓冲写入区域信息。RenderQueue设为Transparent之前的某层,再让所有UI子物体叠加一层Stencil筛选即可。这样做的好处是:遮罩区域可以来自任意网格、任意材质,无需重新计算矩形顶点,配合任何图片、动画都可以。
这种思路的关键前提是UI Canvas的渲染模式。在Screen Space Overlay模式下,UI是单独一套渲染链路的,模板缓冲的操作会非常麻烦。如果你要用模板做UI遮罩,建议把Canvas切到Screen Space Camera模式,让UI走相机的渲染事件点。
5. 我踩过的坑和优化心得
5.1 模板缓冲位数与ClearFlag
移动端主流的模板缓冲是8位,也就是0到255,这个范围在绝大多数项目里够用,但并不意味着你可以不在乎位宽。比如你要同时实现多个描边效果、传送门、UI遮罩,有可能把自己设定的标记值冲到几十甚至上百,一旦超过255就会触发饱和或回绕,结果完全不可控。
所以我现在的习惯是给项目的模板值做一个全局规划:0保留为“未标记”,1到10预留给通用全局效果,11到50预留给场景级效果,51以上留给后处理类特殊需求。这样即使多个功能叠加,也不至于互相覆盖。另外我在URP Asset里会明确勾选Clear Depth和Clear Stencil,避免上一帧残留的模板值污染下一帧。
5.2 和MSAA、后处理之间的交互
模板测试开了MSAA之后行为会变。模板缓冲在MSAA下是逐样本的,也就是说每个像素内部的采样点模板值可能不同。URP里如果同时开了MSAA和模板测试,理论上模板操作会对每个样本独立执行,但实际很多GPU驱动会对“相同模板值的像素”做压缩优化,导致你在像素边缘看到奇怪的结果。
如果你开启了HDR、Bloom这类后处理,问题更明显。后处理通常会把整个场景渲到一张临时RT上,这张RT不一定有模板缓冲。模板测试只发生在场景渲染阶段,后处理阶段根本不知道模板值是什么。所以别指望在后处理里读模板值来做边缘检测或模糊范围控制。真想在后处理里做区域控制,就得把模板值编码到RGBA通道里传出去,比如把模板值写入颜色缓冲的某个通道。
5.3 模板测试的“性能真相”
最后说下性能。模板测试在GPU里是纯硬件运算,比较和写入通常在一个时钟周期内完成,几乎不占额外开销。某些GPU上,模板测试失败后还会跳过像素着色器的后续操作,所以利用模板测试做早期剔除反而有优化价值。
性能上的真实消耗来自“多个Pass工次渲染”。比如描边方案要渲染两次角色模型,顶点数量翻倍,这才是真正影响帧率的地方。所以每一个Pass前先判断一下:这个Pass是做标记还是做筛选?能不能合并到已有Pass里?实在合并不了,至少让标记Pass的材质使用最简Shader,避免在标记阶段执行昂贵的像素运算。
5.4 一个关于RenderQueue和相机事件点的建议
做模板效果时,RenderQueue和CameraEvent的配合是最大的坑。我发现很多新手写Shader只顾着Stencil块的参数,完全不看Pass的执行顺序。比如你要“先渲染门洞标记、再渲染门内场景”,如果两个Pass的RenderQueue都是默认Opaque,GPU会按照物体的远近和材质排序执行,标记Pass很可能被拆得七零八落,最终结果完全是乱的。
我的建议是:标记类Pass显式设置较高的RenderQueue,比如Queue=Geometry之后的Queue=AlphaTest,甚至直接用Queue=Overlay配合一个单独相机事件。URP里用Renderer Feature的Event下拉菜单指定执行点更可靠。宁可多花几步配置,也不要假设备GPU一定按你Shader在文件里写的顺序执行。
模板测试做久了你会慢慢明白,它本质就是一个“可编程的8位标签系统”。标签打在哪、什么时候查、查什么条件,完全由你决定。学会了这套思维,很多渲染难题都会找到更简练的解法。希望这篇从原理到实战再到排坑的内容,能帮你少走我当初走的弯路。
