《游戏引擎实现原理》8.3 GPU 驱动剔除与 LOD 协同(compute + indirect)

把视锥剔除与 LOD 选择整体搬到 GPU:一个 compute 通道按包围球剔除、按距离选档,用原子计数器追加进对应 LOD 的实例缓冲,第二个通道写出三组 indirect 绘制参数;CPU 每帧只发三个 drawIndirect,并回读掩码与 CPU 逐对象比对验证一致。

8.3 GPU 驱动剔除与 LOD 协同(compute + indirect)

前两节的剔除都在 CPU 上:CPU 遍历 BVH、算出可见列表、再逐批 draw。这一节把剔除与 LOD 选择整个搬到 GPU——一个 compute 通道读场景、自己判断谁可见、按距离选档,用原子计数器把存活对象追加进对应 LOD 的实例缓冲;第二个通道写出三组 indirect 绘制参数;CPU 只在最后发三个 drawIndirect,全程不碰实例列表。

剔除与选档写进 compute,绘制只用 indirect

数据流:宿主上传对象中心与半径、相机矩阵与六个平面、两个 LOD 距离阈值。cullMain 每个线程处理一个对象——用六个平面对包围球做剔除(球心到平面距离 < -r 即完全在外),存活的按 distance 选 0/1/2 档,atomicAdd 拿到本档的写入槽位,把中心、半径、档位写进该档的实例缓冲。finalize 单线程读计数器,把三档的 vertexCount(10/6/5)与 instanceCount 写进 indirect 缓冲。渲染时三档共用一份几何,靠 firstVertex / vertexCount 选档。着色器 shaders/gpu-driven-cull-lod.wgsl:

// file: shaders/gpu-driven-cull-lod.wgsl
// tier: L2
// verify: node tools/run-l2.mjs src/render/culling/gpu-driven-cull-lod.ts
//
// 8.3 的 L2 验收:把「剔除 + LOD 选择」全部搬到 GPU。
// 一个 compute 通道逐对象做包围球-视锥剔除并按距离选 LOD,用原子计数器把
// 存活对象追加进对应 LOD 的实例缓冲;第二个通道据计数器写出 3 组 indirect 绘制参数。
// 渲染阶段只用 drawIndirect 画三档 LOD,CPU 全程不碰实例列表。
// 每档 LOD 一个独立实例缓冲(偏移 0),不依赖 firstInstance 的实例基址。
// MAX_OBJ 必须与宿主一致(4096)。
const MAX_OBJ: u32 = 4096u;
struct Camera {
  vp: mat4x4<f32>,
  planes: array<vec4<f32>, 6>,
  camPos: vec4<f32>,     // xyz = 相机世界坐标
  lodDist: vec4<f32>,    // x = LOD0 距离上限,y = LOD1 距离上限
};
struct Obj { center: vec4<f32> };   // xyz = 中心,w = 半径
struct IndirectArgs {
  vertexCount: u32,
  instanceCount: u32,
  firstVertex: u32,
  firstInstance: u32,
};
@group(0) @binding(0) var<uniform> cam: Camera;
@group(0) @binding(1) var<storage, read> objs: array<Obj>;
@group(0) @binding(2) var<storage, read_write> lodOf: array<u32>;
@group(0) @binding(3) var<storage, read_write> counters: array<atomic<u32>, 3>;
@group(0) @binding(4) var<storage, read_write> indirect: array<IndirectArgs, 3>;
@group(0) @binding(5) var<storage, read_write> inst0: array<f32>;
@group(0) @binding(6) var<storage, read_write> inst1: array<f32>;
@group(0) @binding(7) var<storage, read_write> inst2: array<f32>;
@compute @workgroup_size(64)
fn cullMain(@builtin(global_invocation_id) gid: vec3<u32>) {
  let i = gid.x;
  if (i >= arrayLength(&objs)) { return; }
  let c = objs[i].center;
  var vis = true;
  for (var p = 0u; p < 6u; p = p + 1u) {
    let pl = cam.planes[p];
    if (dot(pl.xyz, c.xyz) + pl.w < -c.w) { vis = false; }
  }
  if (!vis) { lodOf[i] = 0xFFFFFFFFu; return; }
  let d = distance(cam.camPos.xyz, c.xyz);
  var lod = 0u;
  if (d > cam.lodDist.y) { lod = 2u; } else if (d > cam.lodDist.x) { lod = 1u; }
  lodOf[i] = lod;
  let slot = atomicAdd(&counters[lod], 1u);
  let base = slot * 8u;   // 每实例 8 个 float:中心 xyz + 半径 + lod + 3 个填充
  if (lod == 0u) {
    inst0[base + 0u] = c.x; inst0[base + 1u] = c.y; inst0[base + 2u] = c.z; inst0[base + 3u] = c.w;
    inst0[base + 4u] = 0.0; inst0[base + 5u] = 0.0; inst0[base + 6u] = 0.0; inst0[base + 7u] = 0.0;
  } else if (lod == 1u) {
    inst1[base + 0u] = c.x; inst1[base + 1u] = c.y; inst1[base + 2u] = c.z; inst1[base + 3u] = c.w;
    inst1[base + 4u] = 1.0; inst1[base + 5u] = 0.0; inst1[base + 6u] = 0.0; inst1[base + 7u] = 0.0;
  } else {
    inst2[base + 0u] = c.x; inst2[base + 1u] = c.y; inst2[base + 2u] = c.z; inst2[base + 3u] = c.w;
    inst2[base + 4u] = 2.0; inst2[base + 5u] = 0.0; inst2[base + 6u] = 0.0; inst2[base + 7u] = 0.0;
  }
}
// 单线程收尾:据计数器把三档 LOD 的绘制参数写进 indirect 缓冲。
@compute @workgroup_size(1)
fn finalize() {
  for (var l = 0u; l < 3u; l = l + 1u) {
    var a: IndirectArgs;
    a.instanceCount = atomicLoad(&counters[l]);
    a.firstInstance = 0u;
    if (l == 0u) { a.firstVertex = 0u; a.vertexCount = 10u; }
    else if (l == 1u) { a.firstVertex = 10u; a.vertexCount = 6u; }
    else { a.firstVertex = 16u; a.vertexCount = 5u; }
    indirect[l] = a;
  }
}
// ---- 渲染:三档 LOD 共用一份几何,靠 firstVertex/vertexCount 选档 ----
struct VSOut {
  @builtin(position) pos: vec4<f32>,
  @location(0) color: vec3<f32>,
};
fn lodColor(lod: u32) -> vec3<f32> {
  if (lod == 0u) { return vec3<f32>(0.9, 0.2, 0.2); }
  if (lod == 1u) { return vec3<f32>(0.2, 0.9, 0.2); }
  return vec3<f32>(0.2, 0.2, 0.9);
}
@vertex
fn vs(
  @location(0) corner: vec2<f32>,
  @location(1) center: vec3<f32>,
  @location(2) radius: f32,
  @location(3) lodF: f32,   // 实例缓冲里存的是 float,故按 float 读再转 u32
) -> VSOut {
  var o: VSOut;
  let p = center + vec3<f32>(corner * radius, 0.0);
  o.pos = cam.vp * vec4<f32>(p, 1.0);
  o.color = lodColor(u32(lodF));
  return o;
}
@fragment
fn fs(in: VSOut) -> @location(0) vec4<f32> {
  return vec4<f32>(in.color, 1.0);
}

可运行产物:4096 对象、三档 LOD

src/render/culling/gpu-driven-cull-lod.ts(L2)在 CPU 侧用同一套判定算出基准,再与 GPU 的 lodOf 掩码逐对象比对,并核对 indirect 参数与像素:

// file: src/render/culling/gpu-driven-cull-lod.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/culling/gpu-driven-cull-lod.ts
//
// 8.3 的 L2 验收:GPU 驱动剔除 + LOD 协同。
// compute 通道逐对象做包围球-视锥剔除、按距离选 LOD,用原子计数器把存活对象
// 追加进对应 LOD 的实例缓冲;收尾通道写出 3 组 indirect 参数;渲染只用 drawIndirect。
// 回读 lodOf 掩码与 CPU 逐对象结果比对,并核对 indirect 参数与像素。
// 需 headless Chromium + 本地 http 服务,命令行不可复现。
import { extractFrustumPlanes, lookAt, multiply, perspective, sphereInFrustum, type Frustum, type Vec3 } from './frustum.ts';
const N = 4096, W = 512, H = 512, MAX_OBJ = 4096;
const LOD_DIST = [8, 20] as const;                 // LOD0 < 8 <= LOD1 < 20 <= LOD2
const CULLED = 0xffffffff;
const CLEAR: readonly [number, number, number] = [0.05, 0.05, 0.1];
export interface GpuDrivenResult {
  readonly objects: number;
  readonly cpuVisible: number;
  readonly gpuVisible: number;
  readonly lodMismatch: number;
  readonly perLod: readonly [number, number, number];
  readonly indirectCounts: readonly [number, number, number];
  readonly vertexCounts: readonly [number, number, number];
  readonly lodPixels: readonly [number, number, number];
  readonly wgslErrors: number;
}
/** 三档 LOD 的三角扇几何:8/4/3 个三角形,顶点数 10/6/5,拼进同一顶点缓冲。 */
function discGeometry(): Float32Array {
  const out: number[] = [];
  for (const t of [8, 4, 3]) {
    out.push(0, 0);
    for (let k = 0; k <= t; k++) out.push(Math.cos((k * 2 * Math.PI) / t), Math.sin((k * 2 * Math.PI) / t));
  }
  return new Float32Array(out);
}
export async function runL2(): Promise<{ checks: { name: string; ok: boolean; detail: string }[]; data: GpuDrivenResult }> {
  const adapter = await navigator.gpu.requestAdapter();
  if (!adapter) throw new Error('拿不到 WebGPU adapter');
  const device = await adapter.requestDevice();
  const code = await (await fetch('/shaders/gpu-driven-cull-lod.wgsl')).text();
  const module = device.createShaderModule({ code });
  const info = await module.getCompilationInfo();
  const wgslErrors = info.messages.filter((m) => m.type === 'error').length;
  // ---- 场景与 CPU 基准 ----
  let s = 5 >>> 0;
  const next = (): number => ((s = (Math.imul(s, 1664525) + 1013904223) >>> 0) / 4294967296);
  const objs = new Float32Array(N * 4);
  for (let i = 0; i < N; i++) objs.set([(next() - 0.5) * 40, (next() - 0.5) * 40, (next() - 0.5) * 40, 0.6], i * 4);
  const eye: Vec3 = [0, 0, 0];
  const vp = multiply(perspective((60 * Math.PI) / 180, 1, 0.1, 40), lookAt(eye, [0, 0, -1], [0, 1, 0]));
  const f: Frustum = extractFrustumPlanes(vp);
  const expected = new Uint32Array(N);
  const expPerLod = [0, 0, 0];
  for (let i = 0; i < N; i++) {
    const c: Vec3 = [objs[i * 4]!, objs[i * 4 + 1]!, objs[i * 4 + 2]!];
    if (!sphereInFrustum({ center: c, radius: objs[i * 4 + 3]! }, f)) { expected[i] = CULLED; continue; }
    const d = Math.hypot(c[0], c[1], c[2]);
    const lod = d > LOD_DIST[1] ? 2 : d > LOD_DIST[0] ? 1 : 0;
    expected[i] = lod; expPerLod[lod]!++;
  }
  // ---- 缓冲 ----
  const mk = (bytes: number, usage: GPUBufferUsageFlags, data?: Float32Array | Uint32Array): GPUBuffer => {
    const b = device.createBuffer({ size: Math.max(4, bytes), usage: usage | GPUBufferUsage.COPY_DST });
    if (data) device.queue.writeBuffer(b, 0, data);
    return b;
  };
  const R = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC;
  const objBuf = mk(N * 16, GPUBufferUsage.STORAGE, objs);
  const lodOfBuf = mk(N * 4, R);
  const inst = [0, 1, 2].map(() => mk(MAX_OBJ * 32, GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX));
  const cntBuf = mk(16, R, new Uint32Array(4));
  const indBuf = mk(3 * 16, GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_SRC, new Uint32Array(12));
  const geo = discGeometry();
  const geoBuf = mk(geo.byteLength, GPUBufferUsage.VERTEX, geo);
  const cam = new Float32Array(48);
  cam.set(vp, 0);
  f.forEach((p, i) => cam.set(p, 16 + i * 4));
  cam.set([...eye, 0], 40);
  cam.set([LOD_DIST[0], LOD_DIST[1], 0, 0], 44);
  const camBuf = mk(192, GPUBufferUsage.UNIFORM, cam);
  const comp = (binding: number, type: GPUBufferBindingType) => ({ binding, visibility: GPUShaderStage.COMPUTE, buffer: { type } });
  const bindLayout = device.createBindGroupLayout({ entries: [
    { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform', minBindingSize: 192 } },
    comp(1, 'read-only-storage'), comp(2, 'storage'), comp(3, 'storage'), comp(4, 'storage'),
    comp(5, 'storage'), comp(6, 'storage'), comp(7, 'storage'),
  ] });
  const pl = device.createPipelineLayout({ bindGroupLayouts: [bindLayout] });
  const bind = device.createBindGroup({ layout: bindLayout, entries: [
    { binding: 0, resource: { buffer: camBuf } },
    { binding: 1, resource: { buffer: objBuf } },
    { binding: 2, resource: { buffer: lodOfBuf } },
    { binding: 3, resource: { buffer: cntBuf } },
    { binding: 4, resource: { buffer: indBuf } },
    { binding: 5, resource: { buffer: inst[0]! } },
    { binding: 6, resource: { buffer: inst[1]! } },
    { binding: 7, resource: { buffer: inst[2]! } },
  ] });
  const pipe = (entryPoint: string) => device.createComputePipeline({ layout: pl, compute: { module, entryPoint } });
  const cullPipe = pipe('cullMain'), finPipe = pipe('finalize');
  const rpipe = device.createRenderPipeline({
    layout: 'auto',
    vertex: {
      module, entryPoint: 'vs',
      buffers: [
        { arrayStride: 8, attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x2' }] },
        { arrayStride: 32, stepMode: 'instance', attributes: [
          { shaderLocation: 1, offset: 0, format: 'float32x3' },
          { shaderLocation: 2, offset: 12, format: 'float32' },
          { shaderLocation: 3, offset: 16, format: 'float32' },
        ] },
      ],
    },
    fragment: { module, entryPoint: 'fs', targets: [{ format: 'rgba8unorm' }] },
    primitive: { topology: 'triangle-list', cullMode: 'none' },
  });
  const rbind = device.createBindGroup({ layout: rpipe.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: camBuf } }] });
  const tex = device.createTexture({ size: [W, H], format: 'rgba8unorm', usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC });
  const enc = device.createCommandEncoder();
  const cp = enc.beginComputePass();
  cp.setPipeline(cullPipe); cp.setBindGroup(0, bind); cp.dispatchWorkgroups(Math.ceil(N / 64)); cp.end();
  const fp = enc.beginComputePass();
  fp.setPipeline(finPipe); fp.setBindGroup(0, bind); fp.dispatchWorkgroups(1); fp.end();
  const rp = enc.beginRenderPass({
    colorAttachments: [{ view: tex.createView(), clearValue: { r: CLEAR[0], g: CLEAR[1], b: CLEAR[2], a: 1 }, loadOp: 'clear', storeOp: 'store' }],
  });
  rp.setPipeline(rpipe); rp.setBindGroup(0, rbind); rp.setVertexBuffer(0, geoBuf);
  for (let l = 0; l < 3; l++) {
    rp.setVertexBuffer(1, inst[l]!);
    rp.drawIndirect(indBuf, l * 16);
  }
  rp.end();
  const read = (src: GPUBuffer, bytes: number): GPUBuffer => {
    const rb = device.createBuffer({ size: bytes, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
    enc.copyBufferToBuffer(src, 0, rb, 0, bytes);
    return rb;
  };
  const lodRb = read(lodOfBuf, N * 4), cntRb = read(cntBuf, 12), indRb = read(indBuf, 48);
  const bpr = W * 4;
  const pxRb = device.createBuffer({ size: bpr * H, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
  enc.copyTextureToBuffer({ texture: tex }, { buffer: pxRb, bytesPerRow: bpr, rowsPerImage: H }, [W, H]);
  device.queue.submit([enc.finish()]);
  await Promise.all([lodRb.mapAsync(GPUMapMode.READ), cntRb.mapAsync(GPUMapMode.READ), indRb.mapAsync(GPUMapMode.READ), pxRb.mapAsync(GPUMapMode.READ)]);
  const lodOf = new Uint32Array(lodRb.getMappedRange().slice(0));
  const cnt = new Uint32Array(cntRb.getMappedRange().slice(0));
  const ind = new Uint32Array(indRb.getMappedRange().slice(0));
  const px = new Uint8Array(pxRb.getMappedRange().slice(0));
  lodRb.unmap(); cntRb.unmap(); indRb.unmap(); pxRb.unmap();
  let lodMismatch = 0, gpuVisible = 0;
  for (let i = 0; i < N; i++) {
    if (lodOf[i] !== expected[i]) lodMismatch++;
    if (lodOf[i] !== CULLED) gpuVisible++;
  }
  const perLod: [number, number, number] = [cnt[0]!, cnt[1]!, cnt[2]!];
  const indirectCounts: [number, number, number] = [ind[1]!, ind[5]!, ind[9]!];
  const vertexCounts: [number, number, number] = [ind[0]!, ind[4]!, ind[8]!];
  const lodColors = [[0.9, 0.2, 0.2], [0.2, 0.9, 0.2], [0.2, 0.2, 0.9]];
  const lodPixels = lodColors.map((c) => {
    const want = c.map((v) => Math.round(v * 255));
    let n = 0;
    for (let i = 0; i < px.length; i += 4) {
      if (Math.abs(px[i]! - want[0]!) <= 2 && Math.abs(px[i + 1]! - want[1]!) <= 2 && Math.abs(px[i + 2]! - want[2]!) <= 2) n++;
    }
    return n;
  }) as [number, number, number];
  device.destroy();
  const data: GpuDrivenResult = {
    objects: N, cpuVisible: expPerLod.reduce((a, b) => a + b, 0), gpuVisible, lodMismatch,
    perLod, indirectCounts, vertexCounts, lodPixels, wgslErrors,
  };
  const checks = [
    { name: 'wgsl.0error', ok: wgslErrors === 0, detail: `errors=${wgslErrors}` },
    { name: 'lod.match.cpu', ok: lodMismatch === 0, detail: `mismatch=${lodMismatch}` },
    { name: 'indirect.sum==visible', ok: indirectCounts[0]! + indirectCounts[1]! + indirectCounts[2]! === data.cpuVisible, detail: indirectCounts.join('+') },
    { name: 'indirect.vertexCounts', ok: vertexCounts[0] === 10 && vertexCounts[1] === 6 && vertexCounts[2] === 5, detail: vertexCounts.join(',') },
    { name: 'all.lods.drawn', ok: lodPixels.every((n) => n > 0), detail: lodPixels.join(',') },
  ];
  return { checks, data };
}

node tools/run-l2.mjs src/render/culling/gpu-driven-cull-lod.ts(本机实测):

PASS  wgsl.0error  errors=0
PASS  lod.match.cpu  mismatch=0
PASS  indirect.sum==visible  8+204+62
PASS  indirect.vertexCounts  10,6,5
PASS  all.lods.drawn  9199,87713,4394

4096 个对象撒在 40³ 的立方体里,相机在原点:CPU 与 GPU 都判出 274 个可见(lodMismatch=0,逐对象一致)。三档 LOD 分别命中 8 / 204 / 62 个对象,indirect 参数里读回的 instanceCount 正好是 8+204+62=274,vertexCount 是 10 / 6 / 5;三档颜色的像素数分别为 9199 / 87713 / 4394,都非零,说明三档几何都真的画出来了。

为什么不能靠 firstInstance 选实例区

一个坑值得记下:最初想让三档共用一个实例缓冲、用 firstInstance 指定各档起点,结果在 SwiftShader 上非零偏移读出的是错位数据。改成每档一个独立实例缓冲、绑定偏移恒为 0,才稳定——这也是本节 inst0/inst1/inst2 三个绑定的由来。另一个坑:实例缓冲里 LOD 存的是 float,顶点属性若声明成 uint32,会把 1.0 的位模式读成 1065353216,必须声明 float32 再在着色器里 u32(lodF)。此外,indirect 缓冲漏了 COPY_SRC 会让整个命令缓冲被静默拒绝、回读全零,务必用 pushErrorScope('validation') 兜底。

小结

第 8 章到此收束:从 CPU 视锥剔除(8.1)、BVH 层次剔除(8.2),到把剔除与 LOD 全搬上 GPU(8.3)。三者共用同一套平面与包围体判定,只是执行位置不同。GPU 驱动后,CPU 每帧只发三个 drawIndirect——对象数再多,CPU 侧的提交开销也不涨。下一章处理另一个规模问题:把绘制调用按状态排序,进一步压低提交成本。

阅读导航:上一节:8.2 空间划分与 BVH · 下一节:9.1 排序键与前后绘制 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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