13.2 级联阴影与投影矩阵拟合
13.1 用一张 128×128 的阴影贴图罩住整个场景,凑合能用。但真实场景里,近处的角色需要毫米级的阴影精度,远处的山脉只需要大致轮廓——一张固定分辨率的贴图罩住整条视锥,近处必然糊、远处必然浪费。级联阴影(cascaded shadow maps, CSM)的答案是把视锥沿深度切成 3~4 级,每级用一张独立贴图:近处级联覆盖小、texel 密,远处级联覆盖大、texel 疏。
切完之后,每一级都要一个光源空间的正交投影——它必须刚好罩住该级子视锥的 8 个角点,既不能裁掉阴影,也不能留太多空白浪费分辨率。这一步叫投影矩阵拟合,是 CSM 实现里最容易出错的地方。本节只做引擎侧实现:切分、角点、拟合、texel 对齐,最后把拟合搬到 GPU 上复算。
从零实现:切分、角点与正交拟合
三件事串成一条链。切分:把 [near, far] 切成 N 段,用对数分布(近处密)与线性分布(均匀)按 lambda 混合,lambda 越大越偏对数。角点:给定相机的正交基(eye、right、up、forward)与 tan(fovY/2),子视锥 [nearZ, farZ] 的 8 个角点就是 eye + forward·d ± right·(w) ± up·(h)。拟合:把这 8 个点投到光源空间的三根正交轴上,取轴对齐包围盒——(min.x, max.x, min.y, max.y, min.z, max.z) 就是正交矩阵的 (left, right, bottom, top, near, far)。
// file: src/render/shadow/cascade-split.ts
// tier: L1
// verify: npx vitest run src/render/shadow/cascade-split.test.ts
// helio 渲染卷 13.2:级联阴影与投影矩阵拟合(CPU 参考)。
// 四件事:把视锥沿深度切成 N 级(对数-线性混合)、取每级子视锥的 8 个世界
// 角点、把角点拟合到光源空间的正交盒(投影矩阵拟合)、把正交盒平移量对齐到
// texel 网格(消除相机移动时的阴影抖动)。矩阵列主序 number[16]。
export type Vec3 = readonly [number, number, number];
export type Mat4 = readonly number[];
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];
}
/**
* 视锥深度切分:把 [near, far] 切成 count 段,返回 count+1 个分割距离。
* 每段用对数分布与线性分布按 lambda 混合——lambda 越大近处越密。
*/
export function cascadeSplits(count: number, near: number, far: number, lambda: number): number[] {
if (!(count >= 1)) throw new RangeError(`count 必须 ≥ 1,收到 ${count}`);
if (!(near > 0 && far > near)) throw new RangeError(`要求 0 < near < far,收到 ${near}/${far}`);
const splits = [near];
for (let i = 1; i <= count; i++) {
const t = i / count;
const logSplit = near * Math.pow(far / near, t);
const linSplit = near + (far - near) * t;
splits.push(lambda * logSplit + (1 - lambda) * linSplit);
}
splits[count] = far;
return splits;
}
export interface FrustumBasis {
readonly eye: Vec3;
readonly right: Vec3;
readonly up: Vec3;
readonly forward: Vec3; // 视线方向(单位)
readonly tanY: number; // tan(fovY / 2)
readonly aspect: number;
}
/** 子视锥 [nearZ, farZ] 的 8 个世界角点(先近平面 4 个,再远平面 4 个)。 */
export function subFrustumCorners(b: FrustumBasis, nearZ: number, farZ: number): Vec3[] {
const corners: Vec3[] = [];
for (const d of [nearZ, farZ]) {
const h = b.tanY * d;
const w = h * b.aspect;
for (const sy of [-1, 1]) {
for (const sx of [-1, 1]) {
corners.push([
b.eye[0] + b.forward[0] * d + b.right[0] * w * sx + b.up[0] * h * sy,
b.eye[1] + b.forward[1] * d + b.right[1] * w * sx + b.up[1] * h * sy,
b.eye[2] + b.forward[2] * d + b.right[2] * w * sx + b.up[2] * h * sy,
]);
}
}
}
return corners;
}
export interface LightBasis {
readonly right: Vec3;
readonly up: Vec3;
readonly forward: Vec3;
}
/** 由光方向构造正交光基(forward = 光传播方向,up 尽量贴世界 +Y)。 */
export function lightBasisFromDir(lightDir: Vec3): LightBasis {
const forward = normalize(lightDir);
const worldUp: Vec3 = Math.abs(forward[1]) > 0.99 ? [0, 0, 1] : [0, 1, 0];
const right = normalize(cross(worldUp, forward));
const up = cross(forward, right);
return { right, up, forward };
}
export interface Aabb {
readonly min: Vec3;
readonly max: Vec3;
}
/** 把一组世界点拟合到光源空间的正交盒(轴对齐包围盒)。 */
export function fitLightSpaceAabb(basis: LightBasis, points: readonly Vec3[]): Aabb {
let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
for (const p of points) {
const x = dot(p, basis.right);
const y = dot(p, basis.up);
const z = dot(p, basis.forward);
minX = Math.min(minX, x); maxX = Math.max(maxX, x);
minY = Math.min(minY, y); maxY = Math.max(maxY, y);
minZ = Math.min(minZ, z); maxZ = Math.max(maxZ, z);
}
return { min: [minX, minY, minZ], max: [maxX, maxY, maxZ] };
}
/** 正交盒在 x/y 上的直径(texel 尺寸 = 直径 / 贴图边长)。 */
export function aabbExtent(aabb: Aabb): Vec3 {
return [aabb.max[0] - aabb.min[0], aabb.max[1] - aabb.min[1], aabb.max[2] - aabb.min[2]];
}
/**
* texel 对齐:把「世界原点」在光源裁剪空间的 x/y 吸附到 texel 网格整数倍,
* 消除相机连续移动导致的阴影抖动。正交投影 w = 1,故裁剪 x = 平移列 m[12]。
*/
export function snapLightMatrix(viewProj: Mat4, mapSize: number): number[] {
const texel = 2 / mapSize; // NDC 范围 [-1, 1] 的 texel 尺寸
const ox = viewProj[12]!;
const oy = viewProj[13]!;
const out = viewProj.slice();
out[12] = Math.floor(ox / texel) * texel;
out[13] = Math.floor(oy / texel) * texel;
return out;
}
这份实现有 13 条单测:切分首尾恰为 near/far 且单调、lambda=0 退化为线性、lambda=1 退化为对数、角点沿视线深度恰为 near/far、拟合盒包含所有点、近处级联的盒比全局小、texel 对齐后原点坐标落在网格上、亚 texel 抖动被吸附成同一矩阵等。跑法 npx vitest run src/render/shadow/cascade-split.test.ts。
texel 对齐与级联稳定性
级联最容易翻车的地方不是拟合,而是稳定性。相机平移一点点,子视锥角点跟着动,拟合出的正交盒也跟着动;如果盒的边界连续滑动,阴影贴图的 texel 就会与场景相对滑动,画面里的阴影边缘会像水波一样抖动。解决办法是把「世界原点在光源裁剪空间的 x/y」吸附到 texel 的整数倍——也就是 snapLightMatrix 做的事。
它的正确性依赖一个事实:光源用正交投影,w = 1,所以裁剪空间的 x 就等于矩阵的平移列 m[12]。把 m[12] 吸附到 texel = 2 / mapSize 的整数倍,等于把整张阴影贴图钉在固定的世界网格上;相机只要没跨过一个 texel,floor 的结果就不变,阴影纹丝不动。这条性质在 L1 里用两条断言守住:吸附后的原点坐标 % 1 ≈ 0,以及相差 0.3 个 texel 的两个矩阵吸附后完全相等。
可运行产物:在 GPU 上复算投影拟合
拟合本身是纯几何:把 8 个角点投到光基三轴取 min/max。这正好适合放 GPU 上验证——CPU 先把每级子视锥的角点算好上传,compute 着色器一个线程负责一级联,重算同一套包围盒,两边逐级联比对。
// file: shaders/cascade-fit.wgsl
// tier: L2
// verify: node tools/run-l2.mjs src/render/shadow/cascade-fit-l2.ts
// helio 渲染卷 13.2:级联投影矩阵拟合的 GPU 版本。
// 输入每级联子视锥的 8 个世界角点与一组正交光基,一个 compute 线程负责一级联,
// 把角点投影到光源空间取轴对齐包围盒——这个包围盒就是该级联正交矩阵的
// (left,right,bottom,top,near,far)。与 src/render/shadow/cascade-split.ts 对应。
struct Uniforms {
right: vec4<f32>,
up: vec4<f32>,
forward: vec4<f32>,
info: vec4<u32>, // x=cascadeCount y=cornersPerCascade
};
@group(0) @binding(0) var<uniform> u: Uniforms;
@group(0) @binding(1) var<storage, read> corners: array<vec4<f32>>; // 世界坐标 xyz
@group(0) @binding(2) var<storage, read_write> bounds: array<vec4<f32>>; // 每级联 min, max
@compute @workgroup_size(1)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let ci = gid.x;
if (ci >= u.info.x) { return; }
let n = u.info.y;
var mn = vec3<f32>(1e30, 1e30, 1e30);
var mx = vec3<f32>(-1e30, -1e30, -1e30);
for (var k: u32 = 0u; k < n; k = k + 1u) {
let p = corners[ci * n + k].xyz;
let ls = vec3<f32>(dot(p, u.right.xyz), dot(p, u.up.xyz), dot(p, u.forward.xyz));
mn = min(mn, ls);
mx = max(mx, ls);
}
bounds[ci * 2u] = vec4<f32>(mn, 0.0);
bounds[ci * 2u + 1u] = vec4<f32>(mx, 0.0);
}
驱动把 4 级级联的角点(每级 8 个)铺成一个存储缓冲,跑完 compute 回读每级的 min/max,与 CPU 的 fitLightSpaceAabb 逐项比对。
// file: src/render/shadow/cascade-fit-l2.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/shadow/cascade-fit-l2.ts
// helio 渲染卷 13.2:在真实 WebGPU 设备上复算级联投影矩阵拟合。
// 把每级联子视锥的 8 个世界角点上传 GPU,用 compute 逐级联求光源空间包围盒,
// 再与 CPU 参考逐级联比对。需在浏览器中经本地 http 服务运行,命令行不可复现。
import {
aabbExtent, cascadeSplits, fitLightSpaceAabb, lightBasisFromDir, normalize,
subFrustumCorners, type FrustumBasis, type Vec3,
} from './cascade-split.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 CASCADES = 4;
const NEAR = 0.1;
const FAR = 100;
const LAMBDA = 0.7;
const MAP_SIZE = 2048;
const CAM: FrustumBasis = {
eye: [0, 2, 0], right: [1, 0, 0], up: [0, 1, 0], forward: [0, 0, -1],
tanY: Math.tan(Math.PI / 6), aspect: 1,
};
const LIGHT = normalize([0.4, -1, 0.6]);
export async function runL2(): Promise<L2Result> {
const checks: L2Check[] = [];
const 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 wgsl = await (await fetch('/shaders/cascade-fit.wgsl')).text();
const module = device.createShaderModule({ code: wgsl });
const info = await module.getCompilationInfo();
const errors = info.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 basis = lightBasisFromDir(LIGHT);
const splits = cascadeSplits(CASCADES, NEAR, FAR, LAMBDA);
const cornerArrays: Vec3[][] = [];
for (let i = 0; i < CASCADES; i++) cornerArrays.push(subFrustumCorners(CAM, splits[i]!, splits[i + 1]!));
const perCascade = cornerArrays[0]!.length; // 8
const cornerBuf = new Float32Array(CASCADES * perCascade * 4);
let off = 0;
for (const arr of cornerArrays) {
for (const p of arr) { cornerBuf.set([p[0], p[1], p[2], 0], off); off += 4; }
}
const uniform = new ArrayBuffer(64);
new Float32Array(uniform, 0, 4).set([...basis.right, 0]);
new Float32Array(uniform, 16, 4).set([...basis.up, 0]);
new Float32Array(uniform, 32, 4).set([...basis.forward, 0]);
new Uint32Array(uniform, 48, 4).set([CASCADES, perCascade, 0, 0]);
const uBuf = device.createBuffer({ size: 64, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer(uBuf, 0, uniform);
const cBuf = device.createBuffer({ size: cornerBuf.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer(cBuf, 0, cornerBuf);
const bBuf = device.createBuffer({ size: CASCADES * 2 * 16, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC });
const rb = device.createBuffer({ size: CASCADES * 2 * 16, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
const pipeline = device.createComputePipeline({ layout: 'auto', compute: { module, entryPoint: 'main' } });
const bg = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [
{ binding: 0, resource: { buffer: uBuf } },
{ binding: 1, resource: { buffer: cBuf } },
{ binding: 2, resource: { buffer: bBuf } },
] });
const enc = device.createCommandEncoder();
const pass = enc.beginComputePass();
pass.setPipeline(pipeline); pass.setBindGroup(0, bg); pass.dispatchWorkgroups(CASCADES); pass.end();
enc.copyBufferToBuffer(bBuf, 0, rb, 0, CASCADES * 2 * 16);
device.queue.submit([enc.finish()]);
await rb.mapAsync(GPUMapMode.READ);
const gpu = new Float32Array(rb.getMappedRange().slice(0)); rb.unmap();
const rows: unknown[] = [];
let maxDiff = 0;
let prevExtentX = -1;
let monotone = true;
for (let i = 0; i < CASCADES; i++) {
const cpu = fitLightSpaceAabb(basis, cornerArrays[i]!);
const gMin = [gpu[i * 8]!, gpu[i * 8 + 1]!, gpu[i * 8 + 2]!];
const gMax = [gpu[i * 8 + 4]!, gpu[i * 8 + 5]!, gpu[i * 8 + 6]!];
const d = Math.max(
Math.abs(gMin[0]! - cpu.min[0]), Math.abs(gMin[1]! - cpu.min[1]), Math.abs(gMin[2]! - cpu.min[2]),
Math.abs(gMax[0]! - cpu.max[0]), Math.abs(gMax[1]! - cpu.max[1]), Math.abs(gMax[2]! - cpu.max[2]),
);
maxDiff = Math.max(maxDiff, d);
const ext = aabbExtent(cpu);
if (ext[0] <= prevExtentX) monotone = false;
prevExtentX = ext[0];
rows.push({
cascade: i, splitNear: +splits[i]!.toFixed(2), splitFar: +splits[i + 1]!.toFixed(2),
extentX: +ext[0].toFixed(2), extentY: +ext[1].toFixed(2),
texelWorld: +(ext[0] / MAP_SIZE).toFixed(4), diff: +d.toExponential(2),
});
}
data.rows = rows; data.maxDiff = maxDiff; data.splits = splits.map((s) => +s.toFixed(2));
push('GPU 拟合≈CPU(±0.01)', maxDiff <= 0.01, `maxDiff=${maxDiff.toExponential(2)}`);
push('近处级联拟合盒更小(单调)', monotone, `extentX 逐级增大=${monotone}`);
const globalBox = fitLightSpaceAabb(basis, subFrustumCorners(CAM, NEAR, FAR));
const globalExtent = aabbExtent(globalBox);
data.globalExtentX = +globalExtent[0].toFixed(2);
data.globalTexelWorld = +(globalExtent[0] / MAP_SIZE).toFixed(4);
push('近级联 texel 密度优于全局单张', (globalExtent[0] / MAP_SIZE) > (aabbExtent(fitLightSpaceAabb(basis, cornerArrays[0]!))[0] / MAP_SIZE), '单张 2048 覆盖整锥会明显变糊');
device.destroy();
return { checks, data };
}
在 headless Chromium 里跑,本机实测(maxDiff = 4.17e-6):
PASS wgsl 0 error 0
PASS GPU 拟合≈CPU(±0.01) maxDiff=4.17e-6
PASS 近处级联拟合盒更小(单调) extentX 逐级增大=true
PASS 近级联 texel 密度优于全局单张 单张 2048 覆盖整锥会明显变糊
级联 split[near,far] extentX extentY texel 世界尺寸 GPU-CPU 偏差
0 [ 0.10, 7.92] 8.19 10.06 0.0040 4.07e-07
1 [ 7.92, 17.23] 17.24 21.31 0.0084 9.12e-07
2 [17.23, 34.96] 34.90 43.14 0.0170 1.99e-06
3 [34.96,100.00] 100.91 124.53 0.0493 4.17e-06
全局单张(4 级合一张) 103.50 0.0505
这张表把 CSM 的账算清楚了。其一,每级 texel 世界尺寸随级联单调增大:第 0 级 0.004、第 3 级 0.0493,相差 12 倍——近处密、远处疏,正是切分的目的。其二,全局单张的代价:如果 4 级合并成一张 2048 贴图,texel 世界尺寸是 0.0505,比第 0 级的 0.0040 粗 12.6 倍,近处阴影会糊成一片。其三,拟合是对的:GPU 与 CPU 的最大偏差 4.17e-6,是 f32 与 f64 的舍入差;4 级、每级 8 个角点,逐级联一致。
(本节所有数字均来自本机实跑,运行环境见附录 C;src/render/shadow/cascade-fit-l2.ts 需在浏览器中运行,命令行不可复现。)级联把「一张贴图罩全场」拆成「分级贴图罩分段」,但每级用什么分辨率、PCF 开多大、偏置取多少,仍是成本与质量的直接权衡——这正是下一节要量化的事。
阅读导航:上一节:13.1 阴影贴图的引擎侧实现 · 下一节:13.3 阴影性能与质量权衡 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。