13.3 阴影性能与质量权衡
前两节把阴影做出来了:13.1 一张贴图、13.2 分级贴图。但阴影从来不是「开不开」的问题,而是「开到什么档」的问题。两个旋钮贯穿所有阴影实现——每像素采样数(PCF 半径决定)换边缘柔化,深度偏置换自遮挡的抑制程度。前者是成本与质量的直接交换:半径 2 意味着每像素 25 次纹理比较,是半径 0 的 25 倍。后者更微妙:偏置太小,掠射面上会出现摩尔纹一样的「阴影粉刺」(shadow acne);偏置太大,本该落地的阴影会浮起来脱离接触点(Peter Panning)。
这一节把两个旋钮都实现出来,然后在 GPU 上跑两条曲线,把「多少采样换多少柔化」「多少偏置换多少粉刺」量出来。
从零实现:PCF 采样核与斜率缩放偏置
PCF(percentage-closer filtering)的做法很朴素:不是只比一次深度,而是在贴图坐标周围取 (2r+1)² 个邻域 texel,每个各比一次,受光的比例就是该像素的可见性。半径 0 退化为单点硬阴影,半径 1 是 3×3 共 9 次,半径 2 是 5×5 共 25 次——成本按半径平方涨。
斜率缩放偏置(slope-scale bias)解决掠射面的自遮挡:表面越贴着光方向,一个 texel 覆盖的世界范围在深度上摊得越开,越容易把自己判成阴影。偏置应随表面斜率(tan(acos(N·L)))增大,再用 maxBias 封顶,防止偏置过大让阴影脱离接触点。
// file: src/render/shadow/pcf-kernel.ts
// tier: L1
// verify: npx vitest run src/render/shadow/pcf-kernel.test.ts
// helio 渲染卷 13.3:阴影性能与质量权衡(CPU 参考)。
// 两件可量化的东西:PCF 采样核(决定每像素的采样次数 = 成本,以及阴影边缘
// 的柔化程度 = 质量),和斜率缩放偏置(掠射面用更大偏置压住阴影粉刺)。
export interface Tap {
readonly dx: number;
readonly dy: number;
}
/** 生成 (2r+1)² 个 PCF 采样偏移;半径 0 退化为单点硬阴影。 */
export function pcfTaps(radius: number): Tap[] {
if (!Number.isInteger(radius) || radius < 0) {
throw new RangeError(`radius 必须是非负整数,收到 ${radius}`);
}
const taps: Tap[] = [];
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) taps.push({ dx, dy });
}
return taps;
}
/** 每像素的采样次数(成本指标)。 */
export function tapCount(radius: number): number {
return (2 * radius + 1) ** 2;
}
/**
* 斜率缩放偏置:表面越掠射(N·L 越小、斜率越大)越容易自遮挡,
* 需要的深度偏置越大;用 maxBias 封顶避免阴影脱离接触点(Peter Panning)。
*/
export function slopeScaleBias(nDotL: number, baseBias: number, maxBias: number): number {
const c = Math.min(1, Math.max(1e-3, nDotL));
const slope = Math.tan(Math.acos(c)); // = sqrt(1 - c²) / c
return Math.min(maxBias, baseBias * slope);
}
/** 阴影可见性 = 命中受光的采样比例,取值 [0, 1],1 = 完全受光。 */
export function pcfVisibility(samplesLit: readonly boolean[]): number {
if (samplesLit.length === 0) throw new RangeError('采样数不能为 0');
let lit = 0;
for (const s of samplesLit) if (s) lit++;
return lit / samplesLit.length;
}
这份实现有 11 条单测:半径 0/1/2 的采样数恰为 1/9/25 且含中心、偏移不越界、正对光时斜率与偏置都为零、越掠射偏置越大、maxBias 封顶生效、可见性按比例柔化、空采样抛错等。跑法 npx vitest run src/render/shadow/pcf-kernel.test.ts。
偏置的取舍:粉刺与脱离
偏置是阴影里唯一没有「正确值」的参数。它的作用是把参考深度整体往后推一点,让自遮挡的像素不再被判成阴影。推多少?取决于深度缓冲的精度、texel 在世界空间的尺寸、以及表面的斜率——所以固定的常量偏置在任何场景下都只是折中。
这正是「粉刺」和「脱离」两头堵的来源。偏置为零时,地面在掠射光下大面积自遮挡,出现密集的条纹状粉刺;偏置增大,粉刺被压下去,但阴影与投射物接触的地方开始出现一条亮缝——阴影脱离了接触点。斜率缩放偏置是常见的缓解:正对光的表面几乎不需要偏置,掠射面才需要大的偏置,用 N·L 调制就能把偏置花在刀刃上。但即便如此,掠射面上粉刺与脱离的边界仍然很窄。
可运行产物:量化两条权衡曲线
把两个旋钮各扫一遍。主渲染改为可变半径的 PCF,半径由 uniform 传入;场景分两种:只放遮挡物盒子(测 PCF 半径对柔化的影响),或盒子加地面(让地面参与阴影贴图,暴露自遮挡粉刺)。每条曲线都逐像素与射线-AABB 的解析参考比对。
// file: shaders/shadow-pcf.wgsl
// tier: L2
// verify: node tools/run-l2.mjs src/render/shadow/shadow-perf-l2.ts
// helio 渲染卷 13.3:阴影性能与质量权衡的 GPU 版本。与 13.1 同一场景,主渲染改为
// 可变半径的 PCF:半径 r 决定每像素 (2r+1)² 次采样比较(成本),也决定阴影边缘被
// 柔化的像素数(质量)。半径 0 退化为单点硬阴影。
struct ShadowU { light_vp: mat4x4<f32>, };
@group(0) @binding(0) var<uniform> su: ShadowU;
struct ShadowOut { @builtin(position) pos: vec4<f32>, @location(0) depth: f32 };
@vertex
fn vs_shadow(@location(0) p: vec3<f32>) -> ShadowOut {
var o: ShadowOut;
let clip = su.light_vp * vec4<f32>(p, 1.0);
o.pos = clip;
o.depth = clip.z / clip.w;
return o;
}
@fragment
fn fs_shadow(in: ShadowOut) -> @location(0) f32 { return in.depth; }
struct MainU {
light_vp: mat4x4<f32>,
params: vec4<f32>, // x=halfX y=halfZ z=mapSize w=bias
pcf: vec4<u32>, // x=radius
};
@group(0) @binding(0) var<uniform> mu: MainU;
@group(0) @binding(1) var shadow_tex: texture_2d<f32>;
struct MainOut { @builtin(position) pos: vec4<f32>, @location(0) world: vec3<f32> };
@vertex
fn vs_main(@builtin(vertex_index) i: u32) -> MainOut {
var tri = array<vec2<f32>, 3>(vec2<f32>(-1.0, -1.0), vec2<f32>(3.0, -1.0), vec2<f32>(-1.0, 3.0));
let ndc = tri[i];
var o: MainOut;
o.pos = vec4<f32>(ndc, 0.0, 1.0);
o.world = vec3<f32>(ndc.x * mu.params.x, 0.0, -ndc.y * mu.params.y);
return o;
}
@fragment
fn fs_main(in: MainOut) -> @location(0) vec4<f32> {
let clip = mu.light_vp * vec4<f32>(in.world, 1.0);
let invw = 1.0 / clip.w;
let uv = vec2<f32>(clip.x * invw * 0.5 + 0.5, 0.5 - clip.y * invw * 0.5);
let depth = clip.z * invw;
let size = i32(mu.params.z);
let texel = vec2<i32>(uv * mu.params.z);
let r = i32(mu.pcf.x);
var lit: f32 = 0.0;
var taps: f32 = 0.0;
for (var dy: i32 = -r; dy <= r; dy = dy + 1) {
for (var dx: i32 = -r; dx <= r; dx = dx + 1) {
let t = texel + vec2<i32>(dx, dy);
var s: f32 = 1.0; // 落在贴图外视为受光(无遮挡物)
if (t.x >= 0 && t.y >= 0 && t.x < size && t.y < size) {
let sd = textureLoad(shadow_tex, t, 0).r;
s = select(0.0, 1.0, depth - mu.params.w <= sd);
}
lit = lit + s;
taps = taps + 1.0;
}
}
let shadow = lit / max(taps, 1.0);
let c = mix(0.15, 0.7, shadow);
return vec4<f32>(c, c, c, 1.0);
}
驱动把两轴各扫一遍,逐像素统计「柔化像素数」(灰度落在亮暗之间的像素)与「自遮挡粉刺像素数」(GPU 判阴影而解析参考判受光的像素)。
// file: src/render/shadow/shadow-perf-l2.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/shadow/shadow-perf-l2.ts
// helio 渲染卷 13.3:在真实 WebGPU 设备上量化阴影的成本/质量权衡。两条曲线:
// 固定偏置换 PCF 半径(成本 = 每像素采样数,质量 = 边缘柔化像素数);含地面场景
// 换深度偏置(质量 = 地面自遮挡「粉刺」像素数)。需经本地 http 服务在浏览器运行。
import { analyticOccluded, buildDirectionalShadowCamera, normalize, type Vec3 } from './shadow-map.ts';
import { tapCount } from './pcf-kernel.ts';
export interface L2Check { readonly name: string; readonly ok: boolean; readonly detail: string }
export interface L2Result { readonly checks: L2Check[]; readonly data: Record<string, unknown> }
const LIGHT = normalize([0.4, -1, 0.6]);
const BOX_MIN: Vec3 = [0.4, 0, 0.4];
const BOX_MAX: Vec3 = [0.8, 0.8, 0.8];
const SCENE_CENTER: Vec3 = [0.5, 0.4, 0.5];
const HALF_EXTENT = 2.5, GROUND_HALF = 2.5, MAIN_HALF = 2, W = 64, H = 64, BPR = 256;
/** 盒子几何:12 个三角形(带深度测试时绕序无关)。 */
function boxVertices(): Float32Array {
const [x0, y0, z0] = BOX_MIN, [x1, y1, z1] = BOX_MAX;
const c: Vec3[] = [[x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0],
[x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1]];
const faces = [[0, 1, 2, 3], [4, 5, 6, 7], [0, 1, 5, 4], [1, 2, 6, 5], [2, 3, 7, 6], [3, 0, 4, 7]];
const out: number[] = [];
for (const [a, b, cc, d] of faces)
for (const t of [[a!, b!, cc!], [a!, cc!, d!]]) for (const vi of t) out.push(...c[vi!]!);
return new Float32Array(out);
}
/** 地面(2 三角形)。地面参与阴影贴图才能暴露自遮挡粉刺。 */
function groundVertices(): Float32Array {
const g = GROUND_HALF;
const quad: Vec3[] = [[-g, 0, -g], [g, 0, -g], [g, 0, g], [-g, 0, -g], [g, 0, g], [-g, 0, g]];
return new Float32Array(quad.flat());
}
/** 像素中心 -> 地面世界点(与 WGSL vs_main 同一约定)。 */
function pixelToWorld(px: number, py: number): Vec3 {
return [(((px + 0.5) / W) * 2 - 1) * MAIN_HALF, 0, -(1 - ((py + 0.5) / H) * 2) * MAIN_HALF];
}
export async function runL2(): Promise<L2Result> {
const checks: L2Check[] = [], data: Record<string, unknown> = {};
const push = (name: string, ok: boolean, detail: unknown): void => { checks.push({ name, ok, detail: String(detail) }); };
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) throw new Error('requestAdapter() returned null');
const device = await adapter.requestDevice();
const module = device.createShaderModule({ code: await (await fetch('/shaders/shadow-pcf.wgsl')).text() });
const errors = (await module.getCompilationInfo()).messages.filter((m) => m.type === 'error').map((m) => `${m.lineNum}:${m.linePos} ${m.message}`);
data.wgslErrors = errors;
push('wgsl 0 error', errors.length === 0, errors.length);
const cam = buildDirectionalShadowCamera(LIGHT, SCENE_CENTER, HALF_EXTENT, 0.1, 20, 128);
const lightVP = new Float32Array(cam.viewProj as number[]);
const boxVerts = boxVertices(), groundVerts = groundVertices();
const mkVB = (a: Float32Array): GPUBuffer => {
const b = device.createBuffer({ size: a.byteLength, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer(b, 0, a); return b;
};
const boxVB = mkVB(boxVerts), groundVB = mkVB(groundVerts);
const shadowU = device.createBuffer({ size: 64, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer(shadowU, 0, lightVP);
const shadowPipe = device.createRenderPipeline({ layout: 'auto',
vertex: { module, entryPoint: 'vs_shadow', buffers: [{ arrayStride: 12, attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x3' }] }] },
fragment: { module, entryPoint: 'fs_shadow', targets: [{ format: 'r32float' }] },
primitive: { topology: 'triangle-list' },
depthStencil: { format: 'depth32float', depthWriteEnabled: true, depthCompare: 'less' } });
const shadowBG = device.createBindGroup({ layout: shadowPipe.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: shadowU } }] });
const mainPipe = device.createRenderPipeline({ layout: 'auto',
vertex: { module, entryPoint: 'vs_main' },
fragment: { module, entryPoint: 'fs_main', targets: [{ format: 'rgba8unorm' }] },
primitive: { topology: 'triangle-list' } });
const mainU = device.createBuffer({ size: 96, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
const mainRB = device.createBuffer({ size: BPR * H, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
const renderConfig = async (mapSize: number, radius: number, bias: number, withGround: boolean): Promise<Uint8Array> => {
const shadowTex = device.createTexture({ size: [mapSize, mapSize], format: 'r32float',
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING });
const shadowDepth = device.createTexture({ size: [mapSize, mapSize], format: 'depth32float', usage: GPUTextureUsage.RENDER_ATTACHMENT });
const target = device.createTexture({ size: [W, H], format: 'rgba8unorm', usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC });
const muBytes = new ArrayBuffer(96);
new Float32Array(muBytes, 0, 16).set(lightVP);
new Float32Array(muBytes, 64, 4).set([MAIN_HALF, MAIN_HALF, mapSize, bias]);
new Uint32Array(muBytes, 80, 4).set([radius, 0, 0, 0]);
device.queue.writeBuffer(mainU, 0, muBytes);
const mainBG = device.createBindGroup({ layout: mainPipe.getBindGroupLayout(0), entries: [
{ binding: 0, resource: { buffer: mainU } }, { binding: 1, resource: shadowTex.createView() }] });
const enc = device.createCommandEncoder();
const sp = enc.beginRenderPass({
colorAttachments: [{ view: shadowTex.createView(), clearValue: { r: 1, g: 1, b: 1, a: 1 }, loadOp: 'clear', storeOp: 'store' }],
depthStencilAttachment: { view: shadowDepth.createView(), depthClearValue: 1, depthLoadOp: 'clear', depthStoreOp: 'store' } });
sp.setPipeline(shadowPipe); sp.setBindGroup(0, shadowBG);
sp.setVertexBuffer(0, boxVB); sp.draw(boxVerts.length / 3);
if (withGround) { sp.setVertexBuffer(0, groundVB); sp.draw(groundVerts.length / 3); }
sp.end();
const mp = enc.beginRenderPass({ colorAttachments: [{ view: target.createView(), clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: 'clear', storeOp: 'store' }] });
mp.setPipeline(mainPipe); mp.setBindGroup(0, mainBG); mp.draw(3); mp.end();
enc.copyTextureToBuffer({ texture: target }, { buffer: mainRB, bytesPerRow: BPR }, [W, H]);
device.queue.submit([enc.finish()]);
await mainRB.mapAsync(GPUMapMode.READ);
const px = new Uint8Array(mainRB.getMappedRange().slice(0)); mainRB.unmap();
return px;
};
const analyze = (px: Uint8Array): { soft: number; falseShadow: number; missed: number } => {
let soft = 0, falseShadow = 0, missed = 0;
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
const byte = px[y * BPR + x * 4]!;
if (byte > 39 && byte < 177) soft++; // 部分覆盖 = 柔化像素
const gpu = byte < 102 ? 0 : 1;
const cpu = analyticOccluded(pixelToWorld(x, y), LIGHT, BOX_MIN, BOX_MAX) ? 0 : 1;
if (gpu === 0 && cpu === 1) falseShadow++; // 误判为阴影 = 自遮挡粉刺
if (gpu === 1 && cpu === 0) missed++; // 漏判 = 偏置过大导致阴影脱离
}
return { soft, falseShadow, missed };
};
const pcfRows: unknown[] = [];
for (const radius of [0, 1, 2]) pcfRows.push({ radius, tapsPerPixel: tapCount(radius), softPixels: analyze(await renderConfig(128, radius, 0.002, false)).soft });
data.pcfRows = pcfRows;
const r = pcfRows as { softPixels: number }[];
push('PCF 半径越大柔化像素越多', r[0]!.softPixels < r[1]!.softPixels && r[1]!.softPixels < r[2]!.softPixels, r.map((x) => x.softPixels).join(' < '));
push('硬阴影无柔化像素', r[0]!.softPixels === 0, `soft=${r[0]!.softPixels}`);
const biasRows: unknown[] = [];
for (const bias of [0, 0.0002, 0.0005, 0.001, 0.002]) {
const m = analyze(await renderConfig(128, 0, bias, true));
biasRows.push({ bias, falseShadow: m.falseShadow, missed: m.missed });
}
data.biasRows = biasRows;
const b = biasRows as { falseShadow: number }[];
push('偏置增大自遮挡粉刺减少', b[0]!.falseShadow > b[4]!.falseShadow, `bias=0:${b[0]!.falseShadow} → bias=0.002:${b[4]!.falseShadow}`);
push('偏置为零时存在明显粉刺', b[0]!.falseShadow > 50, `falseShadow=${b[0]!.falseShadow}`);
device.destroy();
return { checks, data };
}
在 headless Chromium 里跑,本机实测:
PASS wgsl 0 error 0
PASS PCF 半径越大柔化像素越多 0 < 65 < 133
PASS 硬阴影无柔化像素 soft=0
PASS 偏置增大自遮挡粉刺减少 bias=0:1972 → bias=0.002:3
PASS 偏置为零时存在明显粉刺 falseShadow=1972
轴一(PCF 半径,遮挡物场景) 半径 每像素采样数 柔化像素数
0 1 0
1 9 65
2 25 133
轴二(深度偏置,含地面场景) 偏置 自遮挡粉刺 漏判(阴影脱离)
0 1972 0
0.0002 1406 1
0.0005 608 5
0.001 3 8
0.002 3 11
两条曲线把权衡摆明了。轴一:采样数按 (2r+1)² 涨(1 → 9 → 25),柔化像素数随之从 0 增到 65、133——半径每加 1,成本涨约 2.8 倍,柔化像素约翻倍,性价比递减。轴二更值得看:偏置为零时地面有 1972 个粉刺像素(画面大面积条纹);偏置升到 0.0005 时粉刺降到 608,但开始出现 5 个「漏判」像素——阴影开始脱离;偏置 0.001 是拐点,粉刺几乎清零(3 个),脱离只有 8 个;再往上加偏置,粉刺不再降,脱离却涨到 11 个。这就是偏置的甜区:把粉刺压到接近零的那一刻,脱离刚开始抬头——再推一步纯亏。
(本节所有数字均来自本机实跑,运行环境见附录 C;src/render/shadow/shadow-perf-l2.ts 需在浏览器中运行,命令行不可复现。)13 章到这里把阴影的引擎侧讲完了:13.1 从零搭起阴影贴图与深度比较,13.2 用级联与投影拟合解决精度分布,13.3 量化了采样与偏置两个旋钮。下一章离开「光照」这个主题,进入渲染管线里更底层的调度问题——Pass 之间的依赖与资源生命周期。
阅读导航:上一节:13.2 级联阴影与投影矩阵拟合 · 下一节:14.1 Pass 依赖与资源生命周期 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。