《游戏引擎实现原理》12.3 集群光照的引擎侧装配

上一节把单个光源的衰减算清楚,但场景里往往有上百个光源,逐物体遍历全部光源太贵。集群光照把视图空间切成三维网格,每个集群预存一份命中的光源列表。本节在 helio 里实现集群网格与光源装配的 CPU 参考,再用 compute 着色器在真实 WebGPU 设备上复算并逐集群比对。

12.3 集群光照的引擎侧装配

12.2 解决了「一个光源怎么衰减」,但没解决「一个片元要遍历几个光源」。场景里放 24 个点光,如果每个片元都遍历全部 24 个,绝大多数计算都浪费在够不到它的光源上。集群光照(clustered forward)的答案是把视图空间切成三维网格:每个格子叫一个集群(cluster),预存一份「命中它的光源列表」,片元着色时只遍历自己所在集群的那份短列表。

原理层面(Forward+ / Tiled / Clustered 的对比、分桶结构、性能取舍)见分工表外链,本节只做引擎侧装配:怎么把视图空间切成集群、怎么算出每个集群的包围盒、怎么把光源分配进去。这份「每个集群的光源索引列表」就是集群着色器要吃的输入。

集群网格与光源装配

集群网格是「屏幕瓦片 × 深度切片」的乘积:屏幕按 tilesX × tilesY 切成瓦片,深度按 slicesZ 切成片。深度切片用对数分布——近处片薄、远处片厚,因为透视投影下远处的深度区间本来就对应更大的世界距离。每个集群在视图空间里是一个轴对齐盒(AABB),由四个屏幕角和两个切片深度共 8 个角点取包围盒得到。

光源是球体(中心 + 半径),装配就是逐个集群做球-AABB 相交测试,命中就把光源下标塞进该集群的列表。下面这份 CPU 参考把整条链实现出来。

// file: src/render/lighting/cluster-assign.ts
// tier: L1
// verify: npx vitest run src/render/lighting/cluster-assign.test.ts
//
// helio 渲染卷 12.3:集群光照的引擎侧装配(CPU 参考实现)。
// 把视图空间按「屏幕瓦片 × 对数深度切片」切成三维集群,算出每个集群的
// 视图空间 AABB,再把点光源球体分配进相交的集群,产出一份「每个集群的
// 光源索引列表」——这就是集群前向着色时片元要遍历的那份数据。

export interface ClusterGrid { readonly tilesX: number; readonly tilesY: number; readonly slicesZ: number }
export interface ProjectionParams {
  readonly fovY: number; readonly aspect: number; readonly near: number; readonly far: number;
}
export interface ClusterAabb {
  readonly min: readonly [number, number, number]; readonly max: readonly [number, number, number];
}
export interface SphereLight { readonly center: readonly [number, number, number]; readonly radius: number }

/** 集群在扁平数组里的下标:x 最快、z 最慢。 */
export function clusterIndex(cx: number, cy: number, cz: number, grid: ClusterGrid): number {
  return cx + cy * grid.tilesX + cz * grid.tilesX * grid.tilesY;
}

/** 屏幕像素坐标 -> 瓦片坐标。 */
export function screenToTile(px: number, py: number, width: number, height: number, grid: ClusterGrid): [number, number] {
  const tx = Math.min(grid.tilesX - 1, Math.max(0, Math.floor((px / width) * grid.tilesX)));
  const ty = Math.min(grid.tilesY - 1, Math.max(0, Math.floor((py / height) * grid.tilesY)));
  return [tx, ty];
}

/** 视图空间深度 -> 对数切片下标,让远处切片更厚。 */
export function depthSlice(viewZ: number, proj: ProjectionParams, slices: number): number {
  const z = Math.min(proj.far, Math.max(proj.near, viewZ));
  const t = Math.log(z / proj.near) / Math.log(proj.far / proj.near);
  return Math.min(slices - 1, Math.max(0, Math.floor(t * slices)));
}

/**
 * 一个集群在视图空间里的 AABB。相机在原点朝 -Z 看,用正深度 d 表示再转成 -d。
 * 四个 ndc 角 × 两个切片深度共 8 个角取包围盒。
 */
export function clusterAabb(cx: number, cy: number, cz: number, grid: ClusterGrid, proj: ProjectionParams): ClusterAabb {
  const tanY = Math.tan(proj.fovY / 2);
  const tanX = tanY * proj.aspect;
  const dNear = proj.near * Math.pow(proj.far / proj.near, cz / grid.slicesZ);
  const dFar = proj.near * Math.pow(proj.far / proj.near, (cz + 1) / grid.slicesZ);
  const ndcX = (i: number): number => ((i + 0.5) / grid.tilesX) * 2 - 1;
  const ndcY = (j: number): number => 1 - ((j + 0.5) / grid.tilesY) * 2;
  let minX = Infinity, minY = Infinity, minZ = Infinity, maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
  for (const d of [dNear, dFar]) {
    for (const dx of [ndcX(cx) - 1 / grid.tilesX, ndcX(cx) + 1 / grid.tilesX]) {
      for (const dy of [ndcY(cy) - 1 / grid.tilesY, ndcY(cy) + 1 / grid.tilesY]) {
        minX = Math.min(minX, dx * tanX * d); maxX = Math.max(maxX, dx * tanX * d);
        minY = Math.min(minY, dy * tanY * d); maxY = Math.max(maxY, dy * tanY * d);
        minZ = Math.min(minZ, -d); maxZ = Math.max(maxZ, -d);
      }
    }
  }
  return { min: [minX, minY, minZ], max: [maxX, maxY, maxZ] };
}

/** 球与 AABB 相交(含包含)。 */
export function sphereIntersectsAabb(light: SphereLight, box: ClusterAabb): boolean {
  let dist2 = 0;
  for (let a = 0; a < 3; a++) {
    const clamped = Math.min(box.max[a]!, Math.max(box.min[a]!, light.center[a]!));
    const d = light.center[a]! - clamped;
    dist2 += d * d;
  }
  return dist2 <= light.radius * light.radius;
}

export interface ClusterAssignment {
  readonly clusters: readonly (readonly number[])[]; // 每集群命中的光源下标
  readonly pairs: number; // 光源-集群相交对总数
  readonly maxPerCluster: number; // 单集群最大光源数
}

/** 把所有光源分配进与之相交的集群。 */
export function assignLightsToClusters(
  lights: readonly SphereLight[], grid: ClusterGrid, proj: ProjectionParams,
): ClusterAssignment {
  const total = grid.tilesX * grid.tilesY * grid.slicesZ;
  const clusters: number[][] = Array.from({ length: total }, () => []);
  let pairs = 0;
  let maxPerCluster = 0;
  for (let cz = 0; cz < grid.slicesZ; cz++) {
    for (let cy = 0; cy < grid.tilesY; cy++) {
      for (let cx = 0; cx < grid.tilesX; cx++) {
        const box = clusterAabb(cx, cy, cz, grid, proj);
        const list = clusters[clusterIndex(cx, cy, cz, grid)]!;
        lights.forEach((light, li) => { if (sphereIntersectsAabb(light, box)) list.push(li); });
        pairs += list.length;
        maxPerCluster = Math.max(maxPerCluster, list.length);
      }
    }
  }
  return { clusters, pairs, maxPerCluster };
}

这份实现有 10 条单测:集群下标 x 最快、屏幕坐标夹进范围、对数切片近密远疏、球-AABB 相交、远光不命中近处集群等。跑法 npx vitest run src/render/lighting/cluster-assign.test.ts。

可运行产物:在 GPU 上复算装配

把装配里最贵的一步——逐集群的球-AABB 测试——搬到 GPU。CPU 先算好每个集群的 AABB(矩阵相关的几何),上传成存储缓冲;compute 着色器一个线程负责一个集群,遍历全部光源做球-AABB 测试,写回命中数。这样切分是刻意的:几何由 CPU 定、装配由 GPU 算,正好验证两边对同一套 AABB 与光源给出同样的结果。

// file: shaders/cluster-light.wgsl
// tier: L2
// verify: node tools/run-l2.mjs src/render/lighting/cluster-assign-l2.ts
//
// helio 渲染卷 12.3:集群光照装配的 GPU 版本。
// 输入是 CPU 侧算好的每个集群的视图空间 AABB,以及全部点光源球体。
// 一个 compute 线程负责一个集群,遍历所有光源做球-AABB 测试,写回该
// 集群命中的光源数——这就是集群前向着色时片元要遍历的列表长度。
struct Uniforms {
  dims: vec4<u32>, // x=tilesX y=tilesY z=slicesZ w=clusterCount
  info: vec4<u32>, // x=lightCount
};
@group(0) @binding(0) var<uniform> u: Uniforms;
@group(0) @binding(1) var<storage, read> cluster_min: array<vec4<f32>>;
@group(0) @binding(2) var<storage, read> cluster_max: array<vec4<f32>>;
@group(0) @binding(3) var<storage, read> lights: array<vec4<f32>>; // xyz=中心 w=半径
@group(0) @binding(4) var<storage, read_write> counts: array<u32>;

fn sphere_hits(center: vec3<f32>, radius: f32, lo: vec3<f32>, hi: vec3<f32>) -> bool {
  let d = center - clamp(center, lo, hi);
  return dot(d, d) <= radius * radius;
}
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
  let ci = gid.x;
  if (ci >= u.dims.w) { return; }
  let lo = cluster_min[ci].xyz;
  let hi = cluster_max[ci].xyz;
  var n: u32 = 0u;
  let light_count = u.info.x;
  for (var i: u32 = 0u; i < light_count; i = i + 1u) {
    let l = lights[i];
    if (sphere_hits(l.xyz, l.w, lo, hi)) { n = n + 1u; }
  }
  counts[ci] = n;
}

下面的脚本用 8 × 8 × 16(共 1024 个集群)的网格、24 个沿螺旋分布的点光源,把 CPU 与 GPU 的结果逐个集群比对。注意它还做了一件事:用与 WGSL 相同的 f32 精度在 CPU 上复算一遍,因为球恰好相切于集群边界时,f64 的 CPU 与 f32 的 GPU 会给出不同的判定。

// file: src/render/lighting/cluster-assign-l2.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/lighting/cluster-assign-l2.ts
//
// helio 渲染卷 12.3:把 CPU 算好的集群 AABB 与点光源上传到 GPU,用 compute
// 逐集群统计命中光源数,再与 CPU 的分配结果比对。需在浏览器中经本地 http
// 服务运行,命令行不可复现。
import {
  assignLightsToClusters, clusterAabb, clusterIndex,
  type ClusterGrid, type ProjectionParams, type SphereLight,
} from './cluster-assign.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 GRID: ClusterGrid = { tilesX: 8, tilesY: 8, slicesZ: 16 };
const PROJ: ProjectionParams = { fovY: Math.PI / 3, aspect: 1, near: 0.1, far: 100 };

/** 24 个确定性点光源:沿两条螺旋分布在视图空间前半球。 */
function makeLights(): SphereLight[] {
  const lights: SphereLight[] = [];
  for (let i = 0; i < 24; i++) {
    const t = i / 24;
    const angle = t * Math.PI * 4;
    const radius = 2 + 6 * t;
    lights.push({ center: [Math.cos(angle) * radius, Math.sin(angle) * radius * 0.5, -(1 + 40 * t)], radius: 0.6 + 1.4 * ((i % 3) / 2) });
  }
  return lights;
}

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/cluster-light.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 total = GRID.tilesX * GRID.tilesY * GRID.slicesZ;
  const lights = makeLights();
  const minArr = new Float32Array(total * 4);
  const maxArr = new Float32Array(total * 4);
  for (let cz = 0; cz < GRID.slicesZ; cz++) for (let cy = 0; cy < GRID.tilesY; cy++) for (let cx = 0; cx < GRID.tilesX; cx++) {
    const idx = clusterIndex(cx, cy, cz, GRID);
    const box = clusterAabb(cx, cy, cz, GRID, PROJ);
    minArr.set([box.min[0], box.min[1], box.min[2], 0], idx * 4);
    maxArr.set([box.max[0], box.max[1], box.max[2], 0], idx * 4);
  }
  const cpu = assignLightsToClusters(lights, GRID, PROJ);
  const cpuCounts = cpu.clusters.map((c) => c.length);

  const uniform = device.createBuffer({ size: 32, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
  device.queue.writeBuffer(uniform, 0, new Uint32Array([GRID.tilesX, GRID.tilesY, GRID.slicesZ, total, lights.length, 0, 0, 0]));
  const mk = (d: Float32Array): GPUBuffer => {
    const b = device.createBuffer({ size: d.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
    device.queue.writeBuffer(b, 0, d); return b;
  };
  const lightArr = new Float32Array(lights.length * 4);
  lights.forEach((l, i) => lightArr.set([l.center[0], l.center[1], l.center[2], l.radius], i * 4));
  const countBuf = device.createBuffer({ size: total * 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC });
  const rb = device.createBuffer({ size: total * 4, 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: uniform } }, { binding: 1, resource: { buffer: mk(minArr) } },
    { binding: 2, resource: { buffer: mk(maxArr) } }, { binding: 3, resource: { buffer: mk(lightArr) } },
    { binding: 4, resource: { buffer: countBuf } },
  ] });
  const enc = device.createCommandEncoder();
  const pass = enc.beginComputePass();
  pass.setPipeline(pipeline); pass.setBindGroup(0, bg); pass.dispatchWorkgroups(Math.ceil(total / 64)); pass.end();
  enc.copyBufferToBuffer(countBuf, 0, rb, 0, total * 4);
  device.queue.submit([enc.finish()]);
  await rb.mapAsync(GPUMapMode.READ);
  const gpuCounts = new Uint32Array(rb.getMappedRange().slice(0)); rb.unmap();

  // CPU 侧用与 WGSL 相同的 f32 精度复算球-AABB,作为 GPU 的精确期望值。
  const fr = Math.fround;
  const hitsF32 = (li: number, o: number): boolean => {
    let dist = 0;
    for (let a = 0; a < 3; a++) {
      const v = lightArr[li * 4 + a]!;
      const d = fr(v - Math.min(maxArr[o + a]!, Math.max(minArr[o + a]!, v)));
      dist = fr(dist + fr(d * d));
    }
    return dist <= fr(lightArr[li * 4 + 3]! * lightArr[li * 4 + 3]!);
  };
  let mismatchF32 = 0, mismatchF64 = 0, refPairs = 0, gpuPairs = 0, gpuMax = 0;
  for (let i = 0; i < total; i++) {
    let ref = 0;
    for (let li = 0; li < lights.length; li++) if (hitsF32(li, i * 4)) ref++;
    if (gpuCounts[i] !== ref) mismatchF32++;
    if (gpuCounts[i] !== cpuCounts[i]) mismatchF64++;
    refPairs += ref; gpuPairs += gpuCounts[i]!; gpuMax = Math.max(gpuMax, gpuCounts[i]!);
  }
  data.clusters = total; data.lights = lights.length; data.cpuPairsF64 = cpu.pairs;
  data.cpuPairsF32 = refPairs; data.gpuPairs = gpuPairs; data.maxPerCluster = gpuMax;
  data.mismatchF32 = mismatchF32; data.mismatchF64 = mismatchF64;
  push('GPU 与 f32 参考逐集群一致', mismatchF32 === 0, `mismatch=${mismatchF32}/${total}`);
  push('相交对总数一致', gpuPairs === refPairs, `f32=${refPairs} gpu=${gpuPairs}`);
  push('单集群最大光源数一致', gpuMax === cpu.maxPerCluster, `cpu=${cpu.maxPerCluster} gpu=${gpuMax}`);
  push('f64 分歧只在边界(<=4)', mismatchF64 <= 4, `mismatchF64=${mismatchF64}`);
  device.destroy();
  return { checks, data };
}

在 headless Chromium 里跑,本机实测:

PASS  wgsl 0 error  0
PASS  GPU 与 f32 参考逐集群一致  mismatch=0/1024
PASS  相交对总数一致  f32=321 gpu=321
PASS  单集群最大光源数一致  cpu=5 gpu=5
PASS  f64 分歧只在边界(<=4)  mismatchF64=2

1024 个集群、24 个光源,GPU 与 f32 参考逐集群完全一致(mismatch 0)。相交对总数 321,单集群最多命中 5 个光源——也就是说,原本每个片元要遍历 24 个光源,装配之后平均只遍历 321 / 1024 ≈ 0.31 个,最坏也就 5 个。这份「每集群的光源索引列表」就是集群着色器的直接输入。

有意思的是最后一行的 2 个分歧:f64 版 CPU 数出 319 对,GPU(以及 f32 复算)数出 321 对,差的 2 对出现在球恰好相切于集群边界处——f64 判定「不相交」、f32 判定「相交」。这不是 bug,而是浮点精度的必然:装配这类边界敏感的测试,参考实现必须用与 GPU 相同的精度,否则会把舍入差误当成逻辑错。

(本节所有数字均来自本机实跑,运行环境见附录 C;src/render/lighting/cluster-assign-l2.ts 需在浏览器中运行,命令行不可复现。)

光照到这里就装配好了:有模型、有光源、有空间分桶。但还缺一样最影响立体感的东西——阴影。下一章从阴影贴图的引擎侧实现讲起。

阅读导航:上一节:12.2 光源类型与衰减 · 下一节:13.1 阴影贴图的引擎侧实现 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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