《游戏引擎实现原理》13.1 阴影贴图的引擎侧实现

光照决定物体长什么样,阴影决定它站在哪。本节在 helio 上从零实现阴影贴图:先从光源视角把遮挡物渲染成一张光空间深度图,再把相机视角的地面点投影回光源空间做深度比较,最后在真实 WebGPU 设备上回读像素,与 CPU 解析参考逐点比对。

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 级联阴影与投影矩阵拟合 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径