6.3 采样器、寻址与各向异性
6.2 算的是纹理「存下来」的成本,这一节算的是它「读出来」的行为。采样器(sampler)本身不占显存,只是一小组状态,但它决定纹理在三种情况下怎么被读:坐标越界时(寻址模式)、被放大或缩小时(过滤模式)、被斜着看时(各向异性)。这三者都会直接改变画面,而且改动很小、效果很大。
本节产出一个可跑的
src/render/texture/sampler.ts(L1,Node 直跑)与一个 headless Chromium 的 L2 脚本:同一张 4×1 纹理、同一份 WGSL,只换寻址模式,各渲染一帧并回读边缘像素。
寻址模式与坐标回绕
采样时传给 GPU 的是归一化坐标 uv ∈ [0,1],但顶点插值出来的 uv 经常会越界——平铺的地面、重复的墙面、被裁掉一半的精灵,都会算出一个 u = 1.25 这样的值。寻址模式(address mode)就是回答「越界之后取哪个纹素」:
| 模式 | 行为 | 典型用途 |
|---|---|---|
clamp-to-edge | 越界坐标夹到边界纹素 | 单张图、UI、天空盒单面 |
repeat | 按 1 取小数部分,平铺 | 地砖、平铺地形 |
mirror-repeat | 平铺且逐段镜像,接缝无跳变 | 需要无缝平铺的噪声图 |
三种模式在归一化坐标和纹素索引两层都要回绕。下面这个 L1 模块把两层都算清楚,并校验 maxAnisotropy 的合法范围。
// file: src/render/texture/sampler.ts
// tier: L1
// verify: node src/render/texture/sampler.ts
//
// helio 渲染卷:采样器描述与寻址模式。
// 只做纯计算(L1):把「坐标 -> 回绕后的归一化坐标 / 纹素索引」这条链算清楚,
// 并校验 maxAnisotropy 的取值范围。
export type AddressMode = 'clamp-to-edge' | 'repeat' | 'mirror-repeat';
export type FilterMode = 'nearest' | 'linear';
export type SamplerDescriptor = {
readonly addressModeU: AddressMode;
readonly addressModeV: AddressMode;
readonly magFilter: FilterMode;
readonly minFilter: FilterMode;
readonly mipmapFilter: FilterMode;
/** 各向异性上限,WebGPU 规定在 [1, 16]。1 表示关闭。 */
readonly maxAnisotropy: number;
};
/** 缺省采样器:三轴 clamp、最近邻、各向异性关闭。 */
export const DEFAULT_SAMPLER: SamplerDescriptor = {
addressModeU: 'clamp-to-edge',
addressModeV: 'clamp-to-edge',
magFilter: 'nearest',
minFilter: 'nearest',
mipmapFilter: 'nearest',
maxAnisotropy: 1,
};
/** 校验采样器描述:WebGPU 的 maxAnisotropy 必须落在 [1, 16]。 */
export function validateSampler(desc: SamplerDescriptor): void {
const a = desc.maxAnisotropy;
if (!Number.isInteger(a) || a < 1 || a > 16) {
throw new RangeError(`maxAnisotropy must be an integer in [1, 16], got ${a}`);
}
}
/** 归一化坐标按寻址模式回绕,返回落在 [0, 1) 的坐标。 */
export function wrapCoord(u: number, mode: AddressMode): number {
if (mode === 'clamp-to-edge') return Math.min(1, Math.max(0, u));
const base = u - Math.floor(u);
if (mode === 'repeat') return base;
// mirror-repeat:整数部分为奇数的区间做镜像
const k = Math.floor(u);
return (k & 1) === 0 ? base : 1 - base;
}
/** 整数纹素索引按寻址模式回绕到 [0, size)。 */
export function wrapIndex(i: number, size: number, mode: AddressMode): number {
if (size <= 0) throw new RangeError(`size must be positive, got ${size}`);
if (mode === 'clamp-to-edge') return Math.min(size - 1, Math.max(0, i));
const period = mode === 'repeat' ? size : 2 * size;
const m = ((i % period) + period) % period;
if (mode === 'repeat') return m;
return m < size ? m : 2 * size - 1 - m;
}
/**
* 各向异性过滤沿「压缩最厉害的方向」最多取 maxAnisotropy 个样本再合成。
* 这里给出的是「每片元采样点数的上界」,不是实测耗时。
*/
export function anisotropyTaps(desc: SamplerDescriptor): number {
return desc.maxAnisotropy;
}
// ---- 直接运行本文件:打印三种寻址模式在 u≈1.2539(L2 回读像素处)的回绕 ----
if ((import.meta as { main?: boolean }).main) {
const u = 1.25390625; // 64 宽画布上像素 (32,32) 的 uv.u
for (const mode of ['clamp-to-edge', 'repeat', 'mirror-repeat'] as const) {
const w = wrapCoord(u, mode);
const texel = Math.min(3, Math.floor(w * 4));
console.log(`${mode.padEnd(14)} wrapCoord = ${w.toFixed(5)} texel#${texel}`);
}
}
本机实跑(node src/render/texture/sampler.ts,取 u = 1.25390625):
clamp-to-edge wrapCoord = 1.00000 texel#3
repeat wrapCoord = 0.25391 texel#1
mirror-repeat wrapCoord = 0.74609 texel#2
同一个越界坐标 u = 1.25390625,三种模式给出三个不同的纹素:clamp 夹到右边界(texel#3),repeat 取小数部分落到 texel#1,mirror-repeat 因为落在奇偶翻转的区间而落到 texel#2。这就是「一个坐标、三种结果」——也是下一段实测要验证的东西。
可运行产物:同一张纹理,三种寻址模式的像素回读
纸上算完,用 GPU 验一遍。思路:一张 4×1 纹理装红、绿、蓝、白四个纹素,用全屏三角形把它铺开,uv 的 u 从 1.0 扫到 1.5(刻意越界),只换采样器的寻址模式,各渲染一帧并回读同一点 (32,32) 的像素。
// file: shaders/address-mode.wgsl
// tier: L2
// verify: node tools/l2-address-mode.mjs
// 用一张 4x1 纹理铺满屏幕,u 从 1.0 扫到 1.5(刻意越界),
// v 固定 0.5;同一份片元代码配不同寻址模式的采样器,回读边缘像素对比。
struct VSOut {
@builtin(position) pos: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@group(0) @binding(0) var tex: texture_2d<f32>;
@group(0) @binding(1) var samp: sampler;
@vertex
fn vs(@builtin(vertex_index) i: u32) -> VSOut {
var p = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>(3.0, -1.0),
vec2<f32>(-1.0, 3.0),
);
var o: VSOut;
o.pos = vec4<f32>(p[i], 0.0, 1.0);
o.uv = vec2<f32>(1.0 + (p[i].x + 1.0) * 0.25, 0.5);
return o;
}
@fragment
fn fs(in: VSOut) -> @location(0) vec4<f32> {
// 固定 mip 0 + 采样器自身的过滤/寻址,隔离出寻址模式这一个变量
return textureSampleLevel(tex, samp, in.uv, 0.0);
}
// file: tools/l2-address-mode.mjs
// tier: L2
// verify: node tools/l2-address-mode.mjs
//
// 6.3 的 L2 验收:同一张 4x1 纹理 + 同一份 WGSL,只换采样器寻址模式
// (clamp / repeat / mirror),各渲染一帧并回读 u≈1.2539 处的像素;
// 另对 maxAnisotropy=1 与 16 各跑 200 帧,量一次真实相对耗时。需 headless
// Chromium + 本地 http 服务。
import http from 'node:http';
import { readFileSync } from 'node:fs';
import { chromium } from '/opt/homebrew/lib/node_modules/playwright/index.mjs';
const wgsl = readFileSync(new URL('../shaders/address-mode.wgsl', import.meta.url), 'utf8');
const PAGE = `<!doctype html><meta charset=utf-8>
<script type="module">
const WGSL = ${JSON.stringify(wgsl)};
window.__run = async () => {
const out = {};
const device = await (await navigator.gpu.requestAdapter()).requestDevice();
const fmt = 'rgba8unorm'; // 固定 RGBA 字节序,回读值可直接比对
const mod = device.createShaderModule({ code: WGSL });
const info = await mod.getCompilationInfo();
out.wgslErrors = info.messages.filter((m) => m.type === 'error').map((m) => m.message);
const TEX = new Uint8Array([255,0,0,255, 0,255,0,255, 0,0,255,255, 255,255,255,255]); // 红绿蓝白
const tex = device.createTexture({ size: [4, 1], format: 'rgba8unorm',
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST });
device.queue.writeTexture({ texture: tex }, TEX, { bytesPerRow: 16 }, [4, 1]);
const pipe = device.createRenderPipeline({ layout: 'auto',
vertex: { module: mod, entryPoint: 'vs' },
fragment: { module: mod, entryPoint: 'fs', targets: [{ format: fmt }] },
primitive: { topology: 'triangle-list' } });
const W = 64, H = 64, bpr = 256;
const readPixel = async (mode) => {
const s = device.createSampler({ addressModeU: mode, addressModeV: mode,
magFilter: 'nearest', minFilter: 'nearest', mipmapFilter: 'nearest' });
const bg = device.createBindGroup({ layout: pipe.getBindGroupLayout(0),
entries: [{ binding: 0, resource: tex.createView() }, { binding: 1, resource: s }] });
const target = device.createTexture({ size: [W, H], format: fmt,
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC });
const rb = device.createBuffer({ size: bpr * H, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
const enc = device.createCommandEncoder();
const pass = enc.beginRenderPass({ colorAttachments: [{ view: target.createView(),
clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: 'clear', storeOp: 'store' }] });
pass.setPipeline(pipe); pass.setBindGroup(0, bg); pass.draw(3); pass.end();
enc.copyTextureToBuffer({ texture: target }, { buffer: rb, bytesPerRow: bpr }, [W, H]);
device.queue.submit([enc.finish()]);
await rb.mapAsync(GPUMapMode.READ);
const px = new Uint8Array(rb.getMappedRange().slice(0)); rb.unmap();
const o = 32 * bpr + 32 * 4; // 像素 (32,32),此处 u≈1.2539
return Array.from(px.slice(o, o + 4));
};
out.clamp = await readPixel('clamp-to-edge');
out.repeat = await readPixel('repeat');
out.mirror = await readPixel('mirror-repeat');
// 各向异性:256² 带 mip 纹理,u 方向强压缩,量 aniso=1 与 16 的相对耗时
const bmod = device.createShaderModule({ code: \`
struct VSOut { @builtin(position) pos: vec4<f32>, @location(0) uv: vec2<f32> };
@group(0) @binding(0) var t: texture_2d<f32>;
@group(0) @binding(1) var s: sampler;
@vertex fn vs(@builtin(vertex_index) i: u32) -> VSOut {
var p = array<vec2<f32>,3>(vec2<f32>(-1.0,-1.0), vec2<f32>(3.0,-1.0), vec2<f32>(-1.0,3.0));
var o: VSOut; o.pos = vec4<f32>(p[i], 0.0, 1.0);
o.uv = vec2<f32>(p[i].x * 40.0 + 0.5, p[i].y * 4.0 + 0.5); return o; }
@fragment fn fs(in: VSOut) -> @location(0) vec4<f32> { return textureSample(t, s, in.uv); }\` });
const big = device.createTexture({ size: [256, 256], format: 'rgba8unorm', mipLevelCount: 9,
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST });
const checker = new Uint8Array(256 * 256 * 4);
for (let y = 0; y < 256; y++) for (let x = 0; x < 256; x++) {
const o = (y * 256 + x) * 4; checker[o] = checker[o+1] = checker[o+2] = ((x >> 3) + (y >> 3)) & 1 ? 255 : 0; checker[o+3] = 255;
}
device.queue.writeTexture({ texture: big }, checker, { bytesPerRow: 1024 }, [256, 256]);
const bpipe = device.createRenderPipeline({ layout: 'auto',
vertex: { module: bmod, entryPoint: 'vs' },
fragment: { module: bmod, entryPoint: 'fs', targets: [{ format: fmt }] },
primitive: { topology: 'triangle-list' } });
const btarget = device.createTexture({ size: [256, 256], format: fmt, usage: GPUTextureUsage.RENDER_ATTACHMENT });
const bench = (aniso) => {
const s = device.createSampler({ magFilter: 'linear', minFilter: 'linear', mipmapFilter: 'linear', maxAnisotropy: aniso });
const bg = device.createBindGroup({ layout: bpipe.getBindGroupLayout(0),
entries: [{ binding: 0, resource: big.createView() }, { binding: 1, resource: s }] });
const enc = device.createCommandEncoder();
for (let f = 0; f < 200; f++) {
const pass = enc.beginRenderPass({ colorAttachments: [{ view: btarget.createView(),
loadOp: 'clear', storeOp: 'store', clearValue: { r: 0, g: 0, b: 0, a: 1 } }] });
pass.setPipeline(bpipe); pass.setBindGroup(0, bg); pass.draw(3); pass.end();
}
device.queue.submit([enc.finish()]);
};
await device.queue.onSubmittedWorkDone();
const t0 = performance.now(); bench(1); await device.queue.onSubmittedWorkDone();
const t1 = performance.now(); bench(16); await device.queue.onSubmittedWorkDone();
out.aniso = { frames: 200, aniso1Ms: +(t1 - t0).toFixed(1), aniso16Ms: +(performance.now() - t1).toFixed(1) };
return out;
};
</script>`;
const srv = http.createServer((_req, res) => {
res.setHeader('content-type', 'text/html; charset=utf-8');
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
res.end(PAGE);
});
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
let failures = 0;
const check = (name, ok, detail) => { if (!ok) failures++; console.log(`${ok ? 'PASS' : 'FAIL'} ${name} ${detail ?? ''}`); };
const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const browser = await chromium.launch({ headless: true,
args: ['--enable-unsafe-webgpu', '--use-webgpu-adapter=swiftshader', '--enable-unsafe-swiftshader'] });
try {
const page = await browser.newPage({ viewport: { width: 64, height: 64 } });
await page.goto(`http://127.0.0.1:${srv.address().port}/`);
const r = await page.evaluate(() => window.__run());
console.log('clamp ', JSON.stringify(r.clamp));
console.log('repeat', JSON.stringify(r.repeat));
console.log('mirror', JSON.stringify(r.mirror));
console.log('aniso ', JSON.stringify(r.aniso));
check('wgsl.0error', r.wgslErrors.length === 0, `errors=${r.wgslErrors.length}`);
check('clamp=white', eq(r.clamp, [255, 255, 255, 255]), JSON.stringify(r.clamp));
check('repeat=green', eq(r.repeat, [0, 255, 0, 255]), JSON.stringify(r.repeat));
check('mirror=blue', eq(r.mirror, [0, 0, 255, 255]), JSON.stringify(r.mirror));
} finally {
await browser.close();
srv.close();
}
console.log(`\n=== l2-address-mode: ${failures === 0 ? 'ALL PASS' : failures + ' FAILED'} ===`);
process.exit(failures === 0 ? 0 : 1);
本机实跑(SwiftShader,64×64 画布,回读像素 (32,32),此处 u ≈ 1.2539):
clamp [255,255,255,255]
repeat [0,255,0,255]
mirror [0,0,255,255]
aniso {"frames":200,"aniso1Ms":447.9,"aniso16Ms":657}
PASS wgsl.0error errors=0
PASS clamp=white [255,255,255,255]
PASS repeat=green [0,255,0,255]
PASS mirror=blue [0,0,255,255]
回读结果和 L1 的纸面计算一一对上:clamp 读到白色(第 4 个纹素,u 被夹到右边界)、repeat 读到绿色(第 2 个纹素)、mirror 读到蓝色(第 3 个纹素)。同一张纹理、同一份片元代码,唯一变量是采样器的寻址模式,边缘像素就换了三个颜色。
注意渲染目标用的是 rgba8unorm 而不是 getPreferredCanvasFormat():后者在多数平台是 bgra8unorm,回读时蓝色会被读成 [255,0,0,255],看起来「镜像模式返回了红色」,其实只是字节序不同。做像素级验收时,先把目标格式固定成 rgba8unorm,回读值才能直接比对。
各向异性的收益与代价
各向异性过滤(anisotropic filtering)针对的是「斜着看」的场景:地面向远处延伸时,屏幕上同一个像素覆盖的纹理区域是细长的——沿视线方向很长、横向很窄。只按 minFilter 取一个 mip 层会把它压成一个方形采样,导致远处地面糊成一片。各向异性沿「压缩最厉害的方向」多取几个样本再合成,把远处地面的清晰度找回来。
WebGPU 把它表达成一个数 maxAnisotropy ∈ [1, 16],1 表示关闭。代价是每片元最多取 maxAnisotropy 个样本,采样成本随它上升。上面脚本对同一张 256² 棋盘纹理、u 方向强压缩,各跑 200 帧量真实耗时。两次独立运行的读数差别很大:一次是 aniso=1 用 447.9 ms、aniso=16 用 657 ms(约 1.47×),另一次是 201.6 ms 对 993 ms(约 4.9×)。软件光栅化器(SwiftShader)上这个倍数并不稳定,但方向是确定的:aniso=16 总是更慢。量级仅供参考,结论是各向异性不是免费的正方形质量——每片元最多多取 maxAnisotropy 个样本,成本随之上升。
工程上的做法通常是:默认开 4× 或 8×,对地面/道路这类强斜视资源单独开到 16×,对 UI、天空盒这类本就正对着看的资源关掉。因为各向异性的收益只在强压缩方向上体现,正对着看的纹理开它纯属浪费采样。
小结
采样器不占显存,却是「纹理怎么被读」的总开关:寻址模式决定越界坐标取哪个纹素,过滤模式决定放大缩小怎么插值,各向异性决定斜视时多取几个样本。三者叠加起来,就是同一张纹理在不同参数下呈现出完全不同画面的原因。helio 把这三组参数收进一个 SamplerDescriptor,加载器按资源的用途填默认值——地形用 repeat + 各向异性,UI 用 clamp + 最近邻。
到这里,顶点(6.1)、纹理(6.2)、采样器(6.3)三块资源都齐了,它们会被绑定到渲染管线上。下一章开始,进入场景侧:节点如何组织成场景图、变换如何逐级传播。
阅读导航:上一节:6.2 纹理格式、压缩与 mipmap · 下一节:7.1 场景图与变换 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。