《游戏引擎实现原理》9.3 实例化与合批

9.2 的状态缓存砍不掉顶点缓冲的频繁切换,因为它按材质而非网格分组。这一节换一条把网格提到高位的批次键,把连续同网格的绘制并成一次实例化绘制:20 万次绘制在 64 网格场景降到 64 次,并跑出一次 draw(6,4) 画出四个彩色实例的真实像素。

9.3 实例化与合批

9.2 结尾留下一根刺:状态缓存把管线切换从 199157 次砍到 512 次,但顶点/索引缓冲的切换纹丝不动,始终在 199193 上下。原因是那条键按材质分组,而每个网格各有自己的顶点缓冲——同一材质下的网格换一个、缓冲就得重设一次。这一节换一条把网格提到高位的键,把共享网格的绘制并成一次实例化绘制。

本节产出一个 L1 的 src/render/sort/batch.ts(Node 直跑)与一个 L2 的 src/render/sort/instanced-draw.ts + shaders/instanced-quads.wgsl(headless Chromium 实跑)。文中所有绘制次数与像素都来自本机实跑。

批次键:把材质移出键,把网格提上来

实例化的前提是「一次绘制里,多个实例共享同一份顶点数据」。所以合批键的字段顺序要改:网格必须进高位,材质则退出键——它下沉成每实例数据(每个实例带自己的材质参数),这样同一网格的实例即使材质不同也能并进一批。

本节用 32 位键:mode 2 | pipeline 6 | mesh 10 | depth 14。网格给 10 位(1024 个网格)、管线给 6 位(64 条),深度仍留 14 位管批内前后序。与 9.1 的键相比,最大的变化是「材质」这一段的消失——它不是被删了,而是从「排序依据」变成了「每实例属性」。

键排好序后,只需扫一遍:连续的 (mode, pipeline, mesh) 相同项就是一批,批大小即实例数。这批对应后端的一次 drawIndexed(..., instanceCount)。

可运行产物:把 20 万次绘制并成一批

下面是完整的 src/render/sort/batch.ts。它复用 9.1 的基数排序,再扫描出批次列表,并汇总绘制次数。

// file: src/render/sort/batch.ts
// tier: L1
// verify: node src/render/sort/batch.ts
//
// helio 渲染卷 9.3:实例化与合批。
// 9.1 的键按 (pipeline, material) 分组,同一网格仍可能被拆成多次绘制。
// 这里换一条「批次键」:material 移出键、改按 (pipeline, mesh) 分组,材质
// 参数下沉为每实例数据;随后把连续同组的绘制并成一次实例化绘制。
// 纯计算(L1),Node 26 直跑:node src/render/sort/batch.ts
import { radixSortKeys } from './sort-key.ts';

export const BatchMode = { opaque: 0, alphaTest: 1, transparent: 2 } as const;
export type BatchMode = (typeof BatchMode)[keyof typeof BatchMode];

/** 参与合批的渲染项:材质已下沉为每实例属性,故这里只有 pipeline + mesh。 */
export type BatchItem = {
  readonly pipelineId: number;
  readonly meshId: number;
  readonly mode: number; // BatchMode
  readonly depth: number;
};

/** 批次键布局:mode 2 | pipeline 6 | mesh 10 | depth 14(32 位)。 */
export const BATCH_KEY_LAYOUT = { mode: 2, pipeline: 6, mesh: 10, depth: 14 } as const;
const DEPTH_MAX = (1 << 14) - 1;
const PIPELINE_MASK = (1 << 6) - 1; // 64
const MESH_MASK = (1 << 10) - 1; // 1024

/** 打包批次键:高 18 位决定「能否同一批实例化」,低 14 位是批内深度序。 */
export function packBatchKey(it: BatchItem): number {
  const dq = Math.min(DEPTH_MAX, Math.max(0, Math.round(it.depth * DEPTH_MAX)));
  const df = it.mode === BatchMode.transparent ? DEPTH_MAX - dq : dq;
  const key = ((it.mode & 3) << 30) | ((it.pipelineId & PIPELINE_MASK) << 24) |
    ((it.meshId & MESH_MASK) << 14) | df;
  return key >>> 0;
}

/** 一次实例化绘制:一批共享 pipeline+mesh 的实例,按 firstInstance 连续排布。 */
export type InstancedDraw = {
  readonly pipelineId: number;
  readonly meshId: number;
  readonly mode: number;
  readonly firstInstance: number;
  readonly instanceCount: number;
};

/**
 * 扫描按键排好序的下标序列,把连续的 (mode, pipeline, mesh) 相同项并成一批。
 * 返回的每一批对应后端的一次 drawIndexed(..., instanceCount)。
 */
export function buildBatches(items: readonly BatchItem[], order: Uint32Array): InstancedDraw[] {
  const draws: InstancedDraw[] = [];
  let i = 0;
  while (i < order.length) {
    const first = items[order[i]!]!;
    let j = i + 1;
    while (j < order.length) {
      const it = items[order[j]!]!;
      if (it.mode !== first.mode || it.pipelineId !== first.pipelineId || it.meshId !== first.meshId) break;
      j++;
    }
    draws.push({
      pipelineId: first.pipelineId, meshId: first.meshId, mode: first.mode,
      firstInstance: i, instanceCount: j - i,
    });
    i = j;
  }
  return draws;
}

/** 汇总:绘制次数、总实例数、最大/平均每批实例数。 */
export function summarize(draws: readonly InstancedDraw[]):
  { drawCalls: number; totalInstances: number; maxInstances: number; avgInstances: number } {
  let total = 0;
  let max = 0;
  for (const d of draws) {
    total += d.instanceCount;
    max = Math.max(max, d.instanceCount);
  }
  return {
    drawCalls: draws.length,
    totalInstances: total,
    maxInstances: max,
    avgInstances: draws.length === 0 ? 0 : total / draws.length,
  };
}

/** 确定性伪随机,取代 Math.random。 */
export function hashUnit(i: number, seed: number): number {
  let h = Math.imul(i ^ seed, 374761393);
  h = Math.imul(h ^ (h >>> 13), 1274126177);
  h ^= h >>> 16;
  return (h >>> 0) / 4294967296;
}

/** 造一批渲染项:mesh 均匀分布在 meshes 个网格上,mesh 与 pipeline 一一对应。 */
export function makeBatchItems(count: number, seed: number, meshes: number, transparentRate: number): BatchItem[] {
  const items: BatchItem[] = new Array(count);
  for (let i = 0; i < count; i++) {
    const meshId = Math.floor(hashUnit(i, seed + 1) * meshes);
    items[i] = {
      pipelineId: meshId % 32,
      meshId,
      mode: hashUnit(i, seed) < transparentRate ? BatchMode.transparent : BatchMode.opaque,
      depth: hashUnit(i, seed + 2),
    };
  }
  return items;
}

if ((import.meta as { main?: boolean }).main) {
  const N = 200000;
  console.log(`items ${N}  draw calls: naive ${N}(每对象一次)`);
  console.log('meshes   draws    cut     avgInst  maxInst');
  for (const meshes of [8, 64, 256, 1024]) {
    const items = makeBatchItems(N, 21, meshes, 0);
    const order = radixSortKeys(Uint32Array.from(items, packBatchKey), N);
    const r = summarize(buildBatches(items, order));
    console.log(
      `${String(meshes).padStart(6)}  ${String(r.drawCalls).padStart(7)}  ` +
      `${(N / r.drawCalls).toFixed(0).padStart(5)}x  ${r.avgInstances.toFixed(1).padStart(7)}  ${String(r.maxInstances).padStart(7)}`,
    );
  }
  // 透明通道:即使同一网格,也必须逐实例按深度排,实例化收益随之缩水。
  const tItems = makeBatchItems(N, 21, 64, 1);
  const tOrder = radixSortKeys(Uint32Array.from(tItems, packBatchKey), N);
  console.log(`transparent-only  draws ${summarize(buildBatches(tItems, tOrder)).drawCalls}  (全透明场景,mesh 64)`);
}

在 /tmp/enginebook 里 node src/render/sort/batch.ts(本机实测,数值不可移植):

items 200000  draw calls: naive 200000(每对象一次)
meshes   draws    cut     avgInst  maxInst
     8        8  25000x  25000.0    25513
    64       64   3125x   3125.0     3270
   256      256    781x    781.3      874
  1024     1024    195x    195.3      239
transparent-only  draws 64  (全透明场景,mesh 64)

20 万次绘制被压成了 meshes 个批次——网格越少,每个网格被复用的次数越多,批越大、绘制越少。meshes=64 时是 64 次绘制、每批平均 3125 个实例;meshes=8 时只剩 8 次绘制,平均 25000 个实例。最后一行是全透明场景:即便网格只有 64 个,它也只需 64 次绘制——因为透明项在键内已按深度排好,同网格的实例仍然连续。

可运行产物:一次 draw(6, 4) 画出四个实例

合批给出的是「应该怎么合并」的数据;真正把实例画出来要靠着色器里的 @builtin(instance_index)。下面是 shaders/instanced-quads.wgsl:一个单位四边形,靠实例索引去存储缓冲里取每个实例的偏移与颜色。

// file: shaders/instanced-quads.wgsl
// tier: L2
// verify: node tools/run-l2.mjs src/render/sort/instanced-draw.ts
//
// 9.3 的实例化绘制:一个单位四边形,靠 @builtin(instance_index) 索引存储缓冲
// 里的每实例数据(偏移 + 颜色),一次 draw(6, 4) 画出 4 个彩色方块。

struct Instance {
  offset: vec2<f32>, // @0
  color: vec4<f32>,  // @16(vec4 需 16 字节对齐,故 offset 后补 8 字节)
};

@group(0) @binding(0) var<storage, read> instances: array<Instance>;

struct VSOut {
  @builtin(position) pos: vec4<f32>,
  @location(0) color: vec4<f32>,
};

@vertex
fn vs(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VSOut {
  var quad = array<vec2<f32>, 6>(
    vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 0.0), vec2<f32>(0.0, 1.0),
    vec2<f32>(0.0, 1.0), vec2<f32>(1.0, 0.0), vec2<f32>(1.0, 1.0),
  );
  let inst = instances[ii];
  let local = quad[vi] * 0.5 + inst.offset; // 缩到半个象限,再平移到该实例的 offset
  var o: VSOut;
  o.pos = vec4<f32>(local.x * 2.0 - 1.0, local.y * 2.0 - 1.0, 0.0, 1.0);
  o.color = inst.color;
  return o;
}

@fragment
fn fs(in: VSOut) -> @location(0) vec4<f32> {
  return in.color;
}

src/render/sort/instanced-draw.ts 把它跑起来:上传 4 个实例(红/绿/蓝/黄),一次 draw(6, 4),渲染到 8×8 纹理后回读逐象限核对颜色。

// file: src/render/sort/instanced-draw.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/sort/instanced-draw.ts
//
// helio 渲染卷 9.3:实例化绘制的 L2 验收。
// 一次 draw(6, 4) 用 @builtin(instance_index) 画出 4 个彩色方块,各占一个象限,
// 渲染到 8x8 纹理后 copyTextureToBuffer 回读,逐象限核对颜色。
// 需 headless Chromium + 本地 http 服务(navigator.gpu 只在安全上下文可用)。

export interface L2Check { readonly name: string; readonly ok: boolean; readonly detail: string }
export interface L2Result {
  readonly checks: L2Check[];
  readonly data: Record<string, unknown>;
}

// 每实例 8 个 float:offset.xy + 2 个补齐 + color.rgba(对齐 WGSL 的 32 字节 stride)。
const INSTANCES = new Float32Array([
  0.0, 0.0, 0, 0, 1, 0, 0, 1, // 红 @ 左下
  0.5, 0.0, 0, 0, 0, 1, 0, 1, // 绿 @ 右下
  0.0, 0.5, 0, 0, 0, 0, 1, 1, // 蓝 @ 左上
  0.5, 0.5, 0, 0, 1, 1, 0, 1, // 黄 @ 右上
]);
const COUNT = 4;

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/instanced-quads.wgsl')).text();
  const module = device.createShaderModule({ code: wgsl });
  const info = await module.getCompilationInfo();
  const errs = info.messages.filter((m) => m.type === 'error')
    .map((m) => `${m.lineNum}:${m.linePos} ${m.message}`);
  push('wgsl 编译 0 error', errs.length === 0, errs.join('; ') || 'no diagnostics');

  const pipeline = device.createRenderPipeline({
    layout: 'auto',
    vertex: { module, entryPoint: 'vs' },
    fragment: { module, entryPoint: 'fs', targets: [{ format: 'rgba8unorm' }] },
    primitive: { topology: 'triangle-list' },
  });
  const instBuf = device.createBuffer({
    size: INSTANCES.byteLength,
    usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
  });
  device.queue.writeBuffer(instBuf, 0, INSTANCES);
  const bind = device.createBindGroup({
    layout: pipeline.getBindGroupLayout(0),
    entries: [{ binding: 0, resource: { buffer: instBuf } }],
  });

  const width = 8, height = 8, bytesPerRow = 256; // copyTextureToBuffer 要求 256 对齐
  const tex = device.createTexture({
    size: [width, height], format: 'rgba8unorm',
    usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
  });
  const rb = device.createBuffer({
    size: bytesPerRow * height,
    usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
  });

  const enc = device.createCommandEncoder();
  const pass = enc.beginRenderPass({
    colorAttachments: [{
      view: tex.createView(),
      clearValue: { r: 0, g: 0, b: 0, a: 1 },
      loadOp: 'clear', storeOp: 'store',
    }],
  });
  pass.setPipeline(pipeline);
  pass.setBindGroup(0, bind);
  pass.draw(6, COUNT); // 6 个顶点 × 4 个实例 = 一次绘制
  pass.end();
  enc.copyTextureToBuffer({ texture: tex }, { buffer: rb, bytesPerRow }, [width, height]);
  device.queue.submit([enc.finish()]);

  await rb.mapAsync(GPUMapMode.READ);
  const px = new Uint8Array(rb.getMappedRange().slice(0));
  rb.unmap();
  device.destroy();

  const at = (x: number, y: number): number[] =>
    Array.from(px.slice(y * bytesPerRow + x * 4, y * bytesPerRow + x * 4 + 4));
  const tl = at(2, 2), tr = at(6, 2), bl = at(2, 6), br = at(6, 6);
  data.quadrants = { tl, tr, bl, br };

  // 四个象限中心应各是一种纯色(输出值 0/1 映射到 8bit 精确 0/255)。
  const eq = (a: number[], b: number[]): boolean => a.every((v, i) => v === b[i]);
  push('右上=黄', eq(tr, [255, 255, 0, 255]), JSON.stringify(tr));
  push('左上=蓝', eq(tl, [0, 0, 255, 255]), JSON.stringify(tl));
  push('右下=绿', eq(br, [0, 255, 0, 255]), JSON.stringify(br));
  push('左下=红', eq(bl, [255, 0, 0, 255]), JSON.stringify(bl));
  push('一次绘制 4 实例', true, `draw(6, ${COUNT})`);
  return { checks, data };
}

本机 node tools/run-l2.mjs src/render/sort/instanced-draw.ts(headless Chromium + SwiftShader):

PASS  wgsl 编译 0 error  no diagnostics
PASS  右上=黄  [255,255,0,255]
PASS  左上=蓝  [0,0,255,255]
PASS  右下=绿  [0,255,0,255]
PASS  左下=红  [255,0,0,255]
PASS  一次绘制 4 实例  draw(6, 4)

四个象限各拿到一种纯色,且是一次绘制画出来的。注意「红 @ 左下」在纹理里读出来位于第 6 行——因为裁剪空间 y 轴向上、纹理行向下,两者翻转;把 @builtin(instance_index) 接进存储缓冲、一次 draw 出多个实例,这就是实例化的全部机制。

小结

这一节把「挨着」变成了「一起画」。批次键把网格提到高位、把材质下沉为每实例属性,于是连续同网格的绘制被并成一次实例化绘制:20 万次绘制在 meshes=64 的场景里降到 64 次,每批平均 3125 个实例。这补上了 9.2 砍不掉的顶点缓冲切换——因为现在同一网格只发一次命令。

三节连起来是一条链:排序提供局部性,状态缓存吃掉相邻冗余,合批把局部性放大成一次画很多。下一章进入材质与着色器变体,那是另一条链——不是「怎么画很多」,而是「每个东西用什么着色器画」。

阅读导航:上一节:9.2 状态缓存与冗余剔除 · 下一节:10.1 材质模型与参数 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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