13.1 阴影贴图的引擎侧实现
光照打通之后,画面还差一样最能决定立体感的东西——阴影。没有阴影,物体像悬浮在空中;有了它,观者才能一眼读出「谁站在谁前面、谁离地多高」。阴影贴图(shadow mapping)用两步把这个关系算出来:先以光源为视点把遮挡物渲染成一张光空间深度图,再从相机视角渲染时把每个片元投影回光源空间,比较它的深度与贴图里记录的值——更远就说明被挡住了。本节不重推阴影贴图的数学,而是把它在 helio 里落成一条能跑出真实像素的管线:一份纯 TS 的光源矩阵与深度比较参考,一份跑在 RHI 上的 WGSL,两趟渲染(阴影趟 + 主趟)在真实 WebGPU 设备上回读,CPU 当期望值、GPU 当被测量。
本章与站内既有内容的分工
第 13 章「阴影」最容易写成一篇阴影技术综述,先划清边界:
| 既有文章 | 它负责什么 | 本节只做什么 |
|---|---|---|
/graphics-lighting-shadows/ | 阴影贴图 / PCF / 深度偏置 / 级联 / PCSS 的原理推导与多引擎对照 | 在 helio 的 RHI 上从零实现阴影贴图,跑出真实深度与像素 |
/posts/graphics/ | 图形学专题 45 篇:渲染原理与多引擎对照 | 本卷只做「在 helio RHI 上从零实现 + 每节可跑出真实产物」 |
一句话:别人讲「阴影贴图为什么长这样」,本节讲「在 helio 的 RHI 上怎么把它搭出来、跑出什么数字」。
从零实现:光源空间投影与深度比较
引擎侧的第一件事是把「世界点」映射到「阴影坐标」。方向光用正交投影:把相机放在场景上方、沿光方向看下去,用一个 halfExtent 见方的正交盒罩住场景。矩阵按列主序存成 number[16],与 WGSL 的 mat4x4 内存布局一致,可以整块上传。投影之后是深度比较:把片元的参考深度与贴图记录值比,reference - bias > sampled 判为被遮挡。bias 是必须的——贴图精度有限,被照亮面自身采样时会出现深度「自遮挡」噪声,得用偏置把它推开。这份参考实现还给出解析遮挡(射线-AABB),后面 L2 用它当期望值。
// file: src/render/shadow/shadow-map.ts
// tier: L1
// verify: npx vitest run src/render/shadow/shadow-map.test.ts
//
// helio 渲染卷 13.1:阴影贴图的引擎侧实现(CPU 参考)。把世界点映射到光源空间
// 的阴影坐标与深度,再用一次带偏置的深度比较得到可见性。矩阵列主序 number[16]。
// 同一套公式稍后原样搬进 WGSL,CPU 算出的值就是 GPU 的期望值。
export type Vec3 = readonly [number, number, number];
export type Mat4 = readonly number[]; // 16 元素,列主序
export function dot(a: Vec3, b: Vec3): number { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
export function sub(a: Vec3, b: Vec3): Vec3 { return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; }
export function cross(a: Vec3, b: Vec3): Vec3 {
return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
}
export function normalize(v: Vec3): Vec3 {
const len = Math.hypot(v[0], v[1], v[2]);
if (len === 0) throw new RangeError('零向量无法归一化');
return [v[0] / len, v[1] / len, v[2] / len];
}
/** 列主序矩阵乘法 result = a * b。 */
export function mulMat4(a: Mat4, b: Mat4): number[] {
const out = new Array<number>(16).fill(0);
for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) {
let s = 0;
for (let k = 0; k < 4; k++) s += a[k * 4 + r]! * b[c * 4 + k]!;
out[c * 4 + r] = s;
}
return out;
}
/** 矩阵乘齐次向量(列主序)。 */
export function mulVec4(m: Mat4, v: readonly [number, number, number, number]): [number, number, number, number] {
return [
m[0]! * v[0] + m[4]! * v[1] + m[8]! * v[2] + m[12]! * v[3],
m[1]! * v[0] + m[5]! * v[1] + m[9]! * v[2] + m[13]! * v[3],
m[2]! * v[0] + m[6]! * v[1] + m[10]! * v[2] + m[14]! * v[3],
m[3]! * v[0] + m[7]! * v[1] + m[11]! * v[2] + m[15]! * v[3],
];
}
/** 右手视图矩阵:相机在 eye、看向 target。 */
export function lookAt(eye: Vec3, target: Vec3, up: Vec3): number[] {
const z = normalize(sub(eye, target)); // 相机沿 -z 看
const x = normalize(cross(up, z));
const y = cross(z, x);
return [x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0,
-dot(x, eye), -dot(y, eye), -dot(z, eye), 1];
}
/** 正交投影,深度映射到 WebGPU 的 [0, 1]。 */
export function ortho(l: number, r: number, b: number, t: number, n: number, f: number): number[] {
return [2 / (r - l), 0, 0, 0, 0, 2 / (t - b), 0, 0, 0, 0, 1 / (n - f), 0,
-(r + l) / (r - l), -(t + b) / (t - b), n / (n - f), 1];
}
export interface ShadowCamera { readonly viewProj: Mat4; readonly mapSize: number }
/** 方向光的正交阴影相机:沿光方向看下去,用 halfExtent 见方的正交盒罩住场景。 */
export function buildDirectionalShadowCamera(
lightDir: Vec3, center: Vec3, halfExtent: number, near: number, far: number, mapSize: number,
): ShadowCamera {
const f = normalize(lightDir);
const mid = (near + far) / 2; // 把场景摆在 near/far 正中,避免贴着远平面
const eye = sub(center, [f[0] * mid, f[1] * mid, f[2] * mid]);
const up: Vec3 = Math.abs(f[1]) > 0.99 ? [0, 0, 1] : [0, 1, 0];
const view = lookAt(eye, center, up);
const proj = ortho(-halfExtent, halfExtent, -halfExtent, halfExtent, near, far);
return { viewProj: mulMat4(proj, view), mapSize };
}
export interface ShadowCoord { readonly u: number; readonly v: number; readonly depth: number }
/** 世界点 -> 光源空间阴影坐标(裁剪 -> NDC -> UV + 深度)。 */
export function worldToShadow(world: Vec3, cam: ShadowCamera): ShadowCoord {
const clip = mulVec4(cam.viewProj, [world[0], world[1], world[2], 1]);
const invW = clip[3] === 0 ? 0 : 1 / clip[3];
return { u: clip[0] * invW * 0.5 + 0.5, v: 0.5 - clip[1] * invW * 0.5, depth: clip[2] * invW };
}
/** 深度比较:参考深度比贴图记录值远出 bias 以上判为遮挡。返回 1 受光 / 0 阴影。 */
export function shadowCompare(referenceDepth: number, sampledDepth: number, bias: number): number {
return referenceDepth - bias > sampledDepth ? 0 : 1;
}
/** 射线-AABB(slab 法):从 origin 沿 dir 在 (eps, tMax) 内是否命中盒。 */
export function rayHitsAabb(origin: Vec3, dir: Vec3, min: Vec3, max: Vec3, tMax: number, eps: number): boolean {
let tmin = eps, tmax = tMax;
for (let i = 0; i < 3; i++) {
const o = origin[i]!, d = dir[i]!;
if (Math.abs(d) < 1e-9) { if (o < min[i]! || o > max[i]!) return false; continue; }
let t1 = (min[i]! - o) / d, t2 = (max[i]! - o) / d;
if (t1 > t2) { const tmp = t1; t1 = t2; t2 = tmp; }
tmin = Math.max(tmin, t1); tmax = Math.min(tmax, t2);
if (tmin > tmax) return false;
}
return true;
}
/** 地面点沿「指向光源」方向(-lightDir)是否被 AABB 遮挡。解析参考。 */
export function analyticOccluded(ground: Vec3, lightDir: Vec3, min: Vec3, max: Vec3): boolean {
const f = normalize(lightDir);
return rayHitsAabb(ground, [-f[0], -f[1], -f[2]], min, max, 1e3, 1e-4);
}
这份实现有 11 条单测:近/远平面分别映射到深度 0 与 1、沿光方向平移时 uv 不变而深度单调增、偏置能吸收贴图自遮挡、射线-AABB 的平行轴判定等。跑法 npx vitest run src/render/shadow/shadow-map.test.ts。
可运行产物:一趟深度 + 一趟主渲染
把同一套矩阵搬进 WGSL。阴影趟从光源视角渲染一个盒子(唯一的遮挡物)到 r32float 颜色目标,片元写自己的光空间深度;主趟用一个全屏三角形,从俯视正交相机把每个像素重建成地面点(y = 0),再投影回光源空间,textureLoad 比较后决定受光还是阴影。
// file: shaders/shadow-map.wgsl
// tier: L2
// verify: node tools/run-l2.mjs src/render/shadow/shadow-map-l2.ts
//
// helio 渲染卷 13.1:阴影贴图的 GPU 版本。vs_shadow/fs_shadow 从光源视角把
// 遮挡物的光空间深度写进 r32float 贴图;vs_main/fs_main 从相机视角重建地面点,
// 投影回光源空间采样比较。矩阵与 src/render/shadow/shadow-map.ts 逐字对应。
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; // 正交投影 w = 1,深度已是 [0, 1]
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
};
@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);
var shadow: f32 = 1.0; // 1 = 受光,0 = 阴影
if (texel.x >= 0 && texel.y >= 0 && texel.x < size && texel.y < size) {
let sd = textureLoad(shadow_tex, texel, 0).r;
shadow = select(0.0, 1.0, depth - mu.params.w <= sd);
}
let c = mix(0.15, 0.7, shadow);
return vec4<f32>(c, c, c, 1.0);
}
下面的驱动把盒子顶点上传,先跑阴影趟、再跑主趟,最后回读阴影贴图深度与主渲染像素,逐点与 CPU 的解析遮挡比对。
// file: src/render/shadow/shadow-map-l2.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/shadow/shadow-map-l2.ts
//
// helio 渲染卷 13.1:在真实 WebGPU 设备上跑一趟阴影贴图 + 一趟主渲染,回读
// 阴影贴图深度与主渲染像素,与 CPU 解析参考比对。需在浏览器中经本地 http
// 服务运行,命令行不可复现。
import { analyticOccluded, buildDirectionalShadowCamera, normalize, worldToShadow, type Vec3 } from './shadow-map.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, MAP = 128, NEAR = 0.1, FAR = 20, MAIN_HALF = 2, W = 64, H = 64, BIAS = 0.002;
/** 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);
}
/** 像素中心 -> 地面世界点(与 WGSL vs_main 同一约定)。 */
function pixelToWorld(px: number, py: number): Vec3 {
const ndcX = ((px + 0.5) / W) * 2 - 1, ndcY = 1 - ((py + 0.5) / H) * 2;
return [ndcX * MAIN_HALF, 0, -ndcY * 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-map.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, NEAR, FAR, MAP);
const lightVP = new Float32Array(cam.viewProj as number[]);
const shadowTex = device.createTexture({ size: [MAP, MAP], format: 'r32float',
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC });
const shadowDepth = device.createTexture({ size: [MAP, MAP], format: 'depth32float', usage: GPUTextureUsage.RENDER_ATTACHMENT });
const shadowU = device.createBuffer({ size: 64, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer(shadowU, 0, lightVP);
const verts = boxVertices();
const boxVB = device.createBuffer({ size: verts.byteLength, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer(boxVB, 0, verts);
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 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(36); sp.end();
const bpr = MAP * 4; // 512,256 的倍数
const smRB = device.createBuffer({ size: bpr * MAP, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
enc.copyTextureToBuffer({ texture: shadowTex }, { buffer: smRB, bytesPerRow: bpr }, [MAP, MAP]);
const target = device.createTexture({ size: [W, H], format: 'rgba8unorm', usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC });
const mainU = device.createBuffer({ size: 80, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
const mu = new ArrayBuffer(80);
new Float32Array(mu, 0, 16).set(lightVP);
new Float32Array(mu, 64, 4).set([MAIN_HALF, MAIN_HALF, MAP, BIAS]);
device.queue.writeBuffer(mainU, 0, mu);
const mainPipe = device.createRenderPipeline({
layout: 'auto',
vertex: { module, entryPoint: 'vs_main' },
fragment: { module, entryPoint: 'fs_main', targets: [{ format: 'rgba8unorm' }] },
primitive: { topology: 'triangle-list' },
});
const mainBG = device.createBindGroup({ layout: mainPipe.getBindGroupLayout(0), entries: [
{ binding: 0, resource: { buffer: mainU } }, { binding: 1, resource: shadowTex.createView() }] });
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();
const mainRB = device.createBuffer({ size: 256 * H, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
enc.copyTextureToBuffer({ texture: target }, { buffer: mainRB, bytesPerRow: 256 }, [W, H]);
device.queue.submit([enc.finish()]);
await smRB.mapAsync(GPUMapMode.READ);
const smap = new Float32Array(smRB.getMappedRange().slice(0)); smRB.unmap();
await mainRB.mapAsync(GPUMapMode.READ);
const px = new Uint8Array(mainRB.getMappedRange().slice(0)); mainRB.unmap();
const top = worldToShadow([0.6, 0.8, 0.6], cam);
const tx = Math.min(MAP - 1, Math.floor(top.u * MAP)), ty = Math.min(MAP - 1, Math.floor(top.v * MAP));
const gpuTop = smap[ty * MAP + tx]!;
data.topTexel = { tx, ty, cpu: +top.depth.toFixed(5), gpu: +gpuTop.toFixed(5) };
push('阴影贴图深度≈CPU(±0.01)', Math.abs(gpuTop - top.depth) <= 0.01, `cpu=${top.depth.toFixed(5)} gpu=${gpuTop.toFixed(5)}`);
let mismatch = 0, shadowed = 0;
const samples: unknown[] = [];
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
const world = pixelToWorld(x, y);
const cpu = analyticOccluded(world, LIGHT, BOX_MIN, BOX_MAX) ? 0 : 1;
const gpu = px[y * 256 + x * 4]! < 102 ? 0 : 1;
if (cpu === 0) shadowed++;
if (cpu !== gpu) mismatch++;
if ((x === 44 && y === 44) || (x === 20 && y === 20) || (x === 8 && y === 8))
samples.push({ px: x, py: y, world: world.map((v) => +v.toFixed(2)), cpu, gpu, byte: px[y * 256 + x * 4] });
}
data.mismatch = mismatch; data.shadowedPixels = shadowed; data.samples = samples;
push('逐像素遮挡一致率≥99%', mismatch <= (W * H) * 0.01, `mismatch=${mismatch}/${W * H}`);
push('阴影覆盖面积非零', shadowed > 50, `shadowed=${shadowed}`);
device.destroy();
return { checks, data };
}
在 headless Chromium(本地 http 服务、带 --enable-unsafe-webgpu)里跑,本机实测:
PASS wgsl 0 error 0
PASS 阴影贴图深度≈CPU(±0.01) cpu=0.48777 gpu=0.48728
PASS 逐像素遮挡一致率≥99% mismatch=14/4096
PASS 阴影覆盖面积非零 shadowed=126
采样像素 世界坐标(x,z) CPU GPU 字节值
(8,8) (-1.47,-1.47) 受光 受光 178
(20,20) (-0.72,-0.72) 受光 受光 178
(44,44) ( 0.78, 0.78) 阴影 阴影 38
三件事得到验证。其一,光源矩阵没错:盒子顶面中心投影到贴图 texel (63,55),GPU 写进去的深度 0.48728 与 CPU 复算的 0.48777 只差 0.00049,是 f32 舍入。其二,深度比较对:像素 (44,44) 落在盒子 +x+z 侧、正对光的阴影里,GPU 判阴影(字节 38 = 环境色 0.15);远处像素判受光(字节 178 ≈ 0.7)。其三,逐像素一致:4096 个像素里只有 14 个不一致(0.34%),全部落在阴影边界那一个 texel 宽、被量化推来推去的窄带上。
(本节所有数字均来自本机实跑,运行环境见附录 C;src/render/shadow/shadow-map-l2.ts 需在浏览器中运行,命令行不可复现。)一张固定分辨率的阴影贴图能覆盖多大场景、近处够不够清楚,是下一节级联阴影要解决的问题。
阅读导航:上一节:回到目录 · 下一节:13.2 级联阴影与投影矩阵拟合 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。