纹理和深度/模板测试
摘要:本文详细介绍了WebGPU中纹理和深度/模板测试的核心概念与实现方法。主要内容包括:1)纹理创建与采样的完整流程(从ImageBitmap到着色器访问);2)纹理压缩技术(BCn、ETC2、ASTC)及其设备兼容性检查;3)深度/模板测试的实现步骤,涵盖纹理创建、比较采样器配置、渲染通道设置和管线配置。文章通过代码示例和参数说明,帮助开发者掌握WebGPU中图像处理和深度测试的关键技术。
在大多数应用中,片段着色器需要从图片中取色,也就是从纹理(texture)中读取(采样)像素数据,作为当前片段的颜色。图片数据必须先存储到纹理对象中才能被采样。纹理数据使用自己的一套二维坐标(通常称为 UV 坐标):左上角为 (0, 0),右下角为 (1, 1);这与我们之前接触过的设备坐标(NDC)不同——NDC 左下角为 (-1, -1),右上角为 (1, 1)。两套坐标系的原点位置和取值范围都不一样,使用时不要混淆。
创建纹理和采样器
纹理资源在概念上和缓冲区很像,都是用来向 GPU 传递数据的资源;区别在于纹理专门存放图像数据,GPU 会把它存储在为图像访问优化过的专用纹理内存中,而不是普通缓冲区内存。如果纹理数据来自一张图片文件,使用纹理资源大致需要 6 步:
第一步:创建 ImageBitmap
获取一张图片对应的 ImageBitmap,一般需要三步:
// 1. 发起请求,得到 Response
let resp = await fetch("image.png");
// 2. 调用 Response 的 blob 方法,得到文件内容对应的 Blob 对象
let blob = await resp.blob();
// 3. 将 Blob 转换为 ImageBitmap
let bitmap = await createImageBitmap(blob);
第二步:创建纹理对象
调用 device.createTexture 创建纹理对象,它接收一个最多包含 8 个属性的描述符对象:
| label | 否 | 纹理对象的调试标识 |
| format | 是 | 纹理像素的格式 |
| size | 是 | 纹理的尺寸 |
| usage | 是 | 指定纹理的使用方式 |
| dimension | 否 | 纹理的维度("1d"、"2d" 或 "3d",默认为 "2d") |
| sampleCount | 否 | 多重采样的样本数(1 或 4,默认为 1) |
| mipLevelCount | 否 | mipmap 的层级数(默认为 1) |
| viewFormats | 否 | 额外允许从该纹理创建视图时使用的格式列表 |
如:
const texture = device.createTexture({
size: [imageBitmap.width, imageBitmap.height, 1],
format: "rgba8unorm",
usage:
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.COPY_DST |
GPUTextureUsage.RENDER_ATTACHMENT
});
WebGPU 支持多种像素格式,涵盖压缩和未压缩两大类。如果纹理既不使用压缩,也不用来存储深度/模板值,format 可以设为约 36 种未压缩颜色格式中的任意一种,例如最常用的 "rgba8unorm"(每个像素 4 个通道、每通道 8 位无符号归一化整数)。
size 应设置为一个包含纹理宽、高、深度(或数组层数)的数组或对象,例如 [width, height] 或 { width, height, depthOrArrayLayers: 1 }。usage 的设置方式和缓冲区类似,是若干标志位的按位或组合,WebGPU 为纹理定义了 5 种标志:
| TEXTURE_BINDING | 允许在着色器中作为可采样纹理绑定 |
| COPY_SRC | 允许作为拷贝操作的数据源 |
| COPY_DST | 允许作为拷贝或写入操作的目标 |
| RENDER_ATTACHMENT | 允许在渲染通道中用作颜色或深度/模板附件(attachment) |
| STORAGE_BINDING | 允许在着色器中绑定为存储纹理 |
大多数应用是把图片数据写入纹理、再供着色器采样,这种最常见的场景下,usage 通常设为 TEXTURE_BINDING | COPY_DST | RENDER_ATTACHMENT:COPY_DST 是因为要把数据拷入纹理,RENDER_ATTACHMENT 则是因为拷贝操作在内部要把该纹理当作颜色附件处理。
sampleCount 默认为 1;如果需要抗锯齿,可以创建多重采样纹理,把它设为 4(WebGPU 目前只允许 1 或 4)。
当观察者远离物体时,物体上纹理占据的屏幕面积会变小,理想情况下也应该少读取一些细节,这就是所谓的细节级别(LOD,level of detail)。常见的实现方法是给纹理准备一组 mipmap:每一级图像的分辨率都是上一级的一半,第 0 级分辨率最高。mipLevelCount 用来指定纹理一共有多少级 mipmap,默认为 1(即不使用 mipmap)。
第三步:将ImageBitmap写入纹理
GPUQueue 提供了 writeTexture 和 copyExternalImageToTexture 两个方法,用来把数据拷贝进纹理资源,二者的主要区别在于支持的数据来源类型不同:writeTexture 接受普通的 ArrayBuffer/TypedArray/DataView,而 copyExternalImageToTexture 专门用来接受 ImageBitmap、HTMLVideoElement、VideoFrame、HTMLCanvasElement 或 OffscreenCanvas 这类"外部图像源"。这里我们的数据来源是 ImageBitmap,因此使用 copyExternalImageToTexture,它接受三个必填的对象参数:
| source | 标识纹理数据来源的对象 |
| destination | 标识数据写入到哪个纹理的对象 |
| copySize | 标识本次拷贝的数据尺寸的对象或数组 |
例如:
device.queue.copyExternalImageToTexture(
{ source: imageBitmap },
{ texture: tex },
[imageBitmap.width, imageBitmap.height]
);
source 参数对象有三个属性:
| source | 是 | 包含数据的 ImageBitmap、HTMLVideoElement、VideoFrame、HTMLCanvasElement 或 OffscreenCanvas |
| origin | 否 | 从源图像的哪个偏移量(左上角为原点)开始读取,默认为 (0, 0) |
| flipY | 否 | 是否在拷贝前把源图像沿垂直方向翻转,默认为 false |
destination有六个属性:
| texture | 是 | 目标 GPUTexture 对象 |
| origin | 否 | 写入到纹理内的哪个偏移量(左上角为原点),默认为 (0, 0) |
| aspect | 否 | 拷贝操作影响纹理的哪个方面:"all"、"depth-only" 或 "stencil-only",默认为 "all" |
| mipLevel | 否 | 写入到纹理的第几级 mipmap,默认为 0 |
| premultipliedAlpha | 否 | 标识RGB通道是否应该在渲染前乘以alpha通道,默认为false |
| colorSpace | 否 | 写入数据所使用的颜色空间和编码,默认为 "srgb" |
最后一个参数 copySize 标识本次要写入纹理的数据尺寸:宽、高、深度(或数组层数),既可以是形如 [width, height] 的数组,也可以是形如 { width, height } 的对象。
第四步:创建采样器
着色器要读取纹理中某个坐标处的数据,需要借助一种专门的资源——采样器(sampler)。调用 GPUDevice.createSampler() 即可创建,方法本身没有必传参数,接收的描述符对象所有字段都是可选的:
| label | 否 | 采样器标识 |
| magFilter | 否 | 采样区域大于一个纹素时如何取值("nearest" 或 "linear"),默认为 "nearest" |
| minFilter | 否 | 采样区域小于一个纹素时如何取值("nearest" 或 "linear"),默认为 "nearest" |
| mipmapFilter | 否 | 采样位置介于两级 mipmap 之间时如何取值("nearest" 或 "linear"),默认为 "nearest" |
| maxAnisotropy | 否 | 各向异性过滤的最大采样比例,取值 1–16,默认为 1(即不做各向异性过滤) |
| lodMinClamp | 否 | 采样时允许使用的最小细节级别(mip 层级),默认为 0 |
| lodMaxClamp | 否 | 采样时允许使用的最大细节级别,默认为 32 |
| addressModeU | 否 | 沿宽度方向访问超出纹理边界的区域时如何取值:"clamp-to-edge"、"repeat"、"mirror-repeat",默认为 "clamp-to-edge" |
| addressModeV | 否 | 沿高度方向访问超出纹理边界的区域时如何取值,取值同上 |
| addressModeW | 否 | 沿深度方向(用于 3D 纹理)访问超出纹理边界的区域时如何取值,取值同上 |
| compare | 否 | 设置后该采样器变为比较采样器,用于深度/模板测试(见后文),取值见下表 |
如创建一个简单的线性采样器:
const sampler = device.createSampler({
magFilter: "linear",
minFilter: "linear",
});
片段着色器用一对浮点数坐标去访问纹理中的某个位置。如果这个位置恰好落在多个纹素之间,就需要过滤(filter)策略来决定取哪个值:"nearest" 直接取离得最近的那个纹素,"linear" 则对周围若干纹素做加权平均(即双线性插值,bilinear filtering)。mipmap 可以看作在平面之外多出的第三个维度,如果 magFilter、minFilter、mipmapFilter 都设为 "linear",就构成了三线性过滤(trilinear filtering)。
多数应用在水平和垂直方向读取相同数量的样本,这称为各向同性过滤(isotropic filtering),当观察方向与纹理表面垂直时效果最好。但如果观察者以一定夹角看向带纹理的表面,纹理在屏幕上会呈现出一个方向被拉伸、另一个方向被压缩的效果;为了不损失被拉伸方向上的清晰度,渲染器需要在该方向上多读取一些样本,这种按方向差异化采样的方式称为各向异性过滤(anisotropic filtering)。maxAnisotropy 设置为 N 时,表示在两个方向上读取的样本数量最大可以相差 N 倍。
compare 属性与深度测试、模板测试相关:当采样器从深度/模板纹理中读出一个值(采样值)后,会将其与另一个值(参考值)做比较,比较结果可用于过滤或丢弃片段。取值如下:
| never | 比较测试永不通过 |
| less | 参考值小于采样值时通过 |
| equal | 两个值相等时通过 |
| less-equal | 参考值小于等于采样值时通过 |
| greater | 参考值大于采样值时通过 |
| not-equal | 两个值不相等时通过 |
| greater-equal | 参考值大于等于采样值时通过 |
| always | 比较测试总是通过 |
第五步:更新绑定组
和统一缓冲区一样,纹理和采样器也必须先加入绑定组,才能被着色器访问:
const tex = device.createTexture(/* … */);
const sampler = device.createSampler();
const bindGroup = device.createBindGroup({
layout: bindGroupLayout,
entries: [
{
binding: 0,
resource: sampler
},
{
binding: 1,
resource: tex.createView({
dimension: "2d",
})
}
]
});
注意绑定组条目引用的不是纹理对象本身,而是调用纹理的 createView 方法得到的纹理视图(GPUTextureView)。createView 接受一个可选的描述符对象:
| label | 否 | 视图标识 |
| dimension | 否 | 视图的维度:"1d"、"2d"、"2d-array"、"3d"、"cube"、"cube-array",默认与纹理自身维度一致 |
| aspect | 否 | 视图可以访问纹理的哪个方面:"all"、"depth-only"、"stencil-only",默认为 "all" |
| format | 否 | 视图使用的格式,默认为纹理自身的格式(如需使用不同格式,该格式必须出现在创建纹理时的 viewFormats 列表中) |
| baseArrayLayer | 否 | 视图访问的第一个数组层的索引,默认为 0 |
| arrayLayerCount | 否 | 视图可访问的数组层数量,默认从 baseArrayLayer 到纹理末尾的所有层 |
| baseMipLevel | 否 | 视图访问的第一个 mipmap 层级的索引,默认为 0 |
| mipLevelCount | 否 | 视图可访问的 mipmap 层级数,默认从 baseMipLevel 到纹理末尾的所有层级 |
绑定组创建之后,就可以像统一缓冲区那样和渲染通道编码器关联起来:
renderPass.setBindGroup(0, bindGroup);
第六步:在片段着色器中访问纹理和采样器
在 WGSL 中声明采样器和纹理变量的方式,和统一缓冲区类似,同样使用 @group、@binding:
@group(0) @binding(0) var sam : sampler;
@group(0) @binding(1) var tex : texture_2d<f32>;
WGSL 只提供两种采样器数据类型:一般用途的 sampler,以及配合比较采样器使用的 sampler_comparison(对应创建时设置了 compare 属性的采样器)。
纹理的数据类型则依据维度、是否多重采样、是否是深度纹理而有所不同:
| texture_1d<T> | 一维纹理 |
| texture_2d<T> | 二维纹理,最常用 |
| texture_2d_array<T> | 二维纹理数组 |
| texture_3d<T> | 三维纹理 |
| texture_cube<T> | 立方体贴图(cubemap) |
| texture_cube_array<T> | 立方体贴图数组 |
| texture_multisampled_2d<T> | 多重采样的二维纹理 |
| texture_depth_2d | 二维深度纹理(用于深度测试,见后文) |
| texture_depth_2d_array | 二维深度纹理数组 |
| texture_depth_cube | 立方体深度纹理 |
| texture_depth_cube_array | 立方体深度纹理数组 |
| texture_depth_multisampled_2d | 多重采样的二维深度纹理 |
其中 T 通常是 f32(对应 unorm/snorm/float 等格式),也可以是 i32 或 u32(对应有符号/无符号整数格式)——但要注意,整数格式的纹理一般不支持线性过滤,只能通过 textureLoad 按精确坐标读取,不能用 textureSample 采样。深度纹理类型(texture_depth_*)本身不带类型参数,元素恒为 f32。
WGSL 还提供了若干函数用于访问纹理:
| textureSample(texture, sampler, coords) | 在给定坐标处采样纹理,返回颜色值(vec4f)或深度值(f32) |
| textureSampleBias(texture, sampler, coords, bias) | 与 textureSample 类似,但额外对选取的 mip 层级施加一个偏移 |
| textureSampleCompare(texture, sampler, coords, refValue) | 使用比较采样器采样深度纹理并与参考值比较,见"深度和模板测试"一节 |
| textureLoad(texture, coords, level) | 按整数坐标直接读取纹素,不使用采样器、不做过滤 |
| textureDimensions(texture) | 返回纹理的尺寸 |
| textureNumLayers(texture) | 返回纹理数组的层数 |
| textureNumLevels(texture) | 返回纹理的 mipmap 层级数 |
| textureNumSamples(texture) | 返回多重采样纹理每个纹素的样本数 |
假如绘制一个矩形并覆盖纹理,在片段着色器中可以调用textureSample方法设置片段颜色:
struct DataStruct {
@builtin(position) pos: vec4f,
@location(0) uvPos: vec2f,
}
@group(0) @binding(0) var sam : sampler;
@group(0) @binding(1) var tex : texture_2d<f32>;
@vertex
fn vs_main(@location(0) coords: vec2f, @location(1) uvCoords: vec2f) -> DataStruct {
var outData: DataStruct;
outData.pos = vec4f(coords, 0.0, 1.0);
outData.uvPos = uvCoords;
return outData;
}
@fragment
fn fs_main(fragData: DataStruct) -> @location(0) vec4f {
return textureSample(tex, sam, fragData.uvPos);
}
顶点缓冲区里除了顶点坐标 coords,还额外存了一份 UV 坐标 uvCoords(本文开头提到的、以左上角为原点的纹理坐标),顶点着色器把它原样透传给片段着色器,片段着色器再用它去 textureSample 里查表取色。
纹理压缩
多数简单应用里,每个纹素含红、绿、蓝三个通道,每通道占 8 位,一共 24 位(如果再算上 alpha 通道就是 32 位)。纹理体积越大,从内存传输到 GPU 所需的时间和带宽也越多,为此 WebGPU 支持读取经过压缩的纹理格式,例如:
const texture = device.createTexture({
…
format: "astc-5×4-unorm",
…
});
这里把 format 设为某种 ASTC 格式,即声明该纹理使用压缩数据。WebGPU 支持三大类压缩算法:
- 块压缩(Block Compression,BCn);
- 爱立信纹理压缩(Ericsson Texture Compression,ETC/ETC2/EAC);
- 自适应可伸缩纹理压缩(Adaptive Scalable Texture Compression,ASTC)。
这三类压缩格式都不是 WebGPU 的必选能力,使用前都需要通过 GPUAdapter.features 检查对应的可选特性是否被设备支持;如果没有相应特性,创建这类格式的纹理会失败。
块压缩(BCn) 是历史最悠久的纹理压缩方式,也称为 S3 纹理压缩(S3TC)。WebGPU 提供了从 BC1 到 BC7 的一系列格式标识,它们都是把 4×4 的像素块压缩成 64 位或 128 位整数。块压缩在桌面 GPU 上普遍支持,但在移动端并不常见,使用前应检查:
const adapter = await navigator.gpu.requestAdapter();
if (adapter && adapter.features.has("texture-compression-bc")) {
// 支持块压缩
}
爱立信纹理压缩(ETC) 同样把 4×4 像素块压缩成 64 位数据。第二代 ETC2 压缩效果更好,除了 RGB 图像外还能压缩 RGBA 图像;任何支持 OpenGL ES 3.0 及以上版本的设备都保证支持 ETC2。对于只有一个或两个颜色通道的纹理,可以使用配套的 EAC 压缩,同样在 OpenGL ES 3.x 设备上可用。检测方式:
const adapter = await navigator.gpu.requestAdapter();
if (adapter && adapter.features.has("texture-compression-etc2")) {
// 支持 ETC2 / EAC
}
自适应可伸缩纹理压缩(ASTC) 同样是把一块纹素压缩为一个值,虽不如 ETC2 普及,但压缩质量通常更好,且可以自由选择压缩块的尺寸——块越大压缩率越高,但画质损失也越大;最小的块为 4×4,最大为 12×12。检测方式:
const adapter = await navigator.gpu.requestAdapter();
if (adapter && adapter.features.has("texture-compression-astc")) {
// 支持 ASTC
}
深度和模板测试
比较方式下)的那个片段,其余的应当被丢弃。每个片段的深度取值范围是 0.0(最近)到 1.0(最远)。渲染过程中,GPU 会在每个像素位置维护一个"当前最小深度值",新片段的深度只有通过比较测试(默认是"小于")才会替换掉原来保存的深度值和颜色。保存这份逐像素深度数据的结构叫深度缓冲区,也称 z 缓冲区。
除了深度缓冲区,应用还可以往模板缓冲区(stencil buffer)里写值。片段着色器执行时可以读取模板值、和参考值做比较,并据此决定保留还是丢弃某个片段,因此模板测试常被用来"遮罩"掉渲染区域的某一部分(比如镜面反射、贴花、UI 挖孔等效果)。
模板值通常不需要太多比特位,实践中常常把深度缓冲区和模板缓冲区合并存储在同一份纹理里:一个 32 位的纹素中,用 24 位存储深度值,8 位存储模板值(对应格式 "depth24plus-stencil8")。
在 WebGPU 中实现深度/模板测试大致需要 5 步:
需要说明的是,第 4 步配置好之后,深度测试本身(按像素比较、写入深度缓冲区)是渲染管线自动完成的,不需要在片段着色器里手写代码;第 5 步的 textureSampleCompare 是另一种更主动的用法——让着色器自己去读取(可能是另一张、例如阴影贴图这样的)深度纹理并做比较,常见于阴影映射(shadow mapping)等技术。下面依次展开这 5 步。
创建深度/模板纹理
深度/模板纹理通常不会被写入图像数据(它的内容由 GPU 在渲染时自动生成),所以一般不需要设置 GPUTextureUsage.COPY_DST;如果确实需要从 CPU 端预先写入或从别处拷贝深度/模板数据,才需要加上这个标志。纹理尺寸通常设置为和渲染目标(canvas 或颜色 attachment)一致,以便覆盖每一个像素。纹理的 format 必须是以下几种深度/模板专用格式之一:
| stencil8 | 只存储 8 位模板值,不含深度 |
| depth16unorm | 用 16 位无符号归一化整数存储深度值 |
| depth24plus | 至少用 24 位存储深度值,不含模板 |
| depth24plus-stencil8 | 至少用 24 位存储深度值,另用 8 位存储模板值 |
| depth32float | 用 32 位浮点数存储深度值,不含模板 |
| depth32float-stencil8 | 用 32 位浮点数存储深度值,另用 8 位整数存储模板值 |
注意:depth32float-stencil8 是一个可选格式,需要设备支持同名的可选特性 "depth32float-stencil8" 才能使用,创建纹理前应先用 adapter.features.has("depth32float-stencil8") 检查,避免在不支持的设备上创建失败。
创建深度/模板纹理示例:
const depthStencilTexture = device.createTexture({
size: [400, 200, 1],
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
format: "depth24plus-stencil8"
});
应用需要创建视图才能在渲染通道访问它:
const depthStencilView = depthStencilTexture.createView();
创建比较采样器
如果要在片段着色器中主动读取深度/模板纹理(例如自行实现阴影映射),同样需要通过采样器访问,只不过用的是比较采样器(comparison sampler)。创建方式是在 createSampler 时指定 compare 属性;对应的绑定组布局条目也要把采样器类型声明为 "comparison":
const sampler = device.createSampler({
compare: "less",
});
…
const bindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.FRAGMENT,
sampler: {
type: "comparison",
},
},
{
binding: 1,
visibility: GPUShaderStage.FRAGMENT,
texture: {
sampleType: "depth",
},
},
],
});
其中第二个条目对应深度纹理本身:绑定深度纹理时,texture.sampleType 应设为 "depth"(而不是采样普通颜色纹理时默认的 "float"),这样绑定组布局才能和着色器里声明的 texture_depth_2d 等深度纹理类型正确匹配。
配置渲染通道编码器
为了提升渲染效率,GPU 会用帧缓冲(framebuffer)统一管理一次渲染涉及的各种缓冲区。此前我们在创建渲染通道时传入的 colorAttachments,就是把一个或多个颜色缓冲区关联到帧缓冲上;如果还需要做深度/模板测试,则要额外设置 depthStencilAttachment,把深度/模板缓冲区也关联进来:
const renderPassDescriptor = {
colorAttachments: [
{ /* … */ },
],
depthStencilAttachment: {
view: depthStencilView,
depthClearValue: 1.0,
depthLoadOp: "clear",
depthStoreOp: "store",
stencilClearValue: 0,
stencilLoadOp: "clear",
stencilStoreOp: "store"
}
};
depthStencilAttachment 描述符的字段如下:
| view | 否 | 要读写的深度/模板纹理视图 |
| depthReadOnly | 是 | 深度部分是否只读,默认为 false |
| depthClearValue | 是 | depthLoadOp 为 "clear" 时,深度缓冲区每个元素的初始值 |
| depthLoadOp | 是(含深度方面且非只读时必须提供) | 渲染前如何初始化深度值:"load"(沿用已有内容)或 "clear"(清空为 depthClearValue) |
| depthStoreOp | 是(同上,必须提供) | 渲染后是否保存深度结果:"store" 或 "discard" |
| stencilReadOnly | 是 | 模板部分是否只读,默认为 false |
| stencilClearValue | 是 | stencilLoadOp 为 "clear" 时,模板缓冲区每个元素的初始值 |
| stencilLoadOp | 是(含模板方面且非只读时必须提供) | 渲染前如何初始化模板值,取值同 depthLoadOp |
| stencilStoreOp | 是(同上,必须提供) | 渲染后是否保存模板结果,取值同 depthStoreOp |
提示:depthLoadOp/depthStoreOp(以及模板对应的两个字段)在表中虽标记为"可选",但只要 view 对应的纹理格式含有深度(或模板)方面、且没有把 depthReadOnly(或 stencilReadOnly)设为 true,就必须同时显式提供这一对 load/store 操作,否则会报错——它们并没有隐式默认值。另外,depthClearValue 常见的做法是设为 1.0(代表最远处),这样场景中任何片段的深度默认都能通过后面提到的 "less" 比较测试,不会一开始就被"最远"的初始值挡住。
配置渲染管线
创建渲染管线时,device.createRenderPipeline 接受一个 depthStencil 对象来配置具体的测试规则,它最多有 10 个字段:
| format | 否 | 深度/模板缓冲区的格式,必须和创建对应纹理时的格式一致 |
| depthCompare | 否 | 指定用于将片段深度与 depthStencilAttachment 的深度值进行比较的比较运算:never、less、equal、less-equal、greater、not-equal、greater-equal、always |
| depthWriteEnabled | 否 | 测试通过时,是否允许把新的深度值写回深度缓冲区 |
| depthBias | 是 | 深度测试前,给每个片段的深度额外加上的一个偏移量,默认为 0 |
| depthBiasSlopeScale | 是 | 按片段深度在屏幕空间中的斜率进一步缩放偏移量的系数,默认为 0 |
| depthBiasClamp | 是 | 限制上面两项计算出的偏移量的最大值,默认为 0 |
| stencilFront | 是 | 正面朝向图元的模板测试配置 |
| stencilBack | 是 | 背面朝向图元的模板测试配置 |
| stencilReadMask | 是 | 指定读操作应该访问模板值的哪些比特,默认为0xffffffff |
| stencilWriteMask | 是 | 指定写操作应该更改模板值的哪些比特,默认为0xffffffff |
一般希望"离相机更近"的片段覆盖"更远"的片段,所以 depthCompare 最常用的取值就是 "less":只有新片段深度小于(更靠近相机)缓冲区里已有的值,测试才通过。对于复杂的深度测试,depthWriteEnabled 通常设为 true,这样通过测试的片段才会更新深度缓冲区,供后续片段继续比较。
如果两个物体的深度非常接近甚至相同,光栅化时可能出现两者的三角形交错、闪烁的现象(俗称 z-fighting),常见的缓解办法是给其中一个物体的深度加一点微小偏移,让二者不再"打架":depthBias 提供一个固定偏移量,depthBiasSlopeScale 则会乘以该像素处深度在水平、垂直方向上的最大斜率,斜率越陡(比如与视线夹角很小的地面)偏移越大,depthBiasClamp 用来限制最终偏移量不至于过大。
stencilFront 和 stencilBack 分别针对正面、背面图元配置模板测试,二者的结构相同,都可以设置以下四个字段:
passOp、failOp、depthFailOp 可以取以下 8 个值之一:
- keep:保留当前模板值;
- zero:将模板值置为 0;
- replace:将模板值替换为当前渲染状态设置的参考值;
- invert:按位翻转模板值;
- increment-clamp:模板值加 1,达到最大值后保持不变(钳位);
- increment-wrap:模板值加 1,超出最大值后回绕为 0;
- decrement-clamp:模板值减 1,到 0 后保持不变(钳位);
- decrement-wrap:模板值减 1,小于 0 后回绕为最大值。
一个配置了深度、模板测试的渲染管线大致如下:
const pipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({ /* … */ }),
vertex: { /* … */ },
fragment: { /* … */ },
depthStencil: {
format: "depth24plus-stencil8",
depthCompare: "less",
depthWriteEnabled: true,
stencilFront: {
passOp: "replace",
}
}
});
执行比较测试
前面几步配置好之后,深度/模板测试本身就已经由渲染管线自动执行了。如果还需要在片段着色器中主动读取深度纹理并比较(典型场景是实现阴影映射:先把光源视角的深度渲染进一张深度纹理,再在正常渲染时用当前片段相对光源的深度去和这张纹理比较,判断是否处于阴影中),需要先在着色器中声明比较采样器和对应的深度纹理变量:
@group(0) @binding(0) var comparisonSampler: sampler_comparison;
@group(0) @binding(1) var shadowMap: texture_depth_2d;
然后调用 textureSampleCompare(texture, sampler, coords, refValue) 执行比较:
@fragment
fn fs_main(@location(0) shadowCoord: vec3f) -> @location(0) vec4f {
// shadowCoord.xy 是采样坐标,shadowCoord.z 是待比较的参考深度值
let visibility = textureSampleCompare(
shadowMap, comparisonSampler, shadowCoord.xy, shadowCoord.z
);
// visibility 为 1.0 表示比较通过(不在阴影中),0.0 表示比较失败(在阴影中)
return vec4f(vec3f(visibility), 1.0);
}
textureSampleCompare 返回一个介于 0.0 和 1.0 之间的 f32:0.0 表示比较失败,1.0 表示比较成功。这个结果通常直接被用作光照强度的乘数,或用于其他自定义的片段处理逻辑。

