《游戏引擎实现原理》17.3 与 Job System 的对接

命令缓冲通道有了、独立编码器拆分也验证了,但谁来切块、谁来合并还没着落。本节把「分块 → 并行录 → 合并」接到卷一那套 Job System 上:用确定性调度模型算 makespan,再用 worker_threads 真跑,实测 4 worker 把 4096 个绘制项的录制墙钟压到约 2.7 分之一,且校验和与串行完全一致。

17.3 与 Job System 的对接

前两节把三块料备齐了:17.1 节给了主线程到渲染线程的命令缓冲通道,17.2 节证明了「一帧的命令可以拆成多条独立编码器、按顺序合并」在语义上与串行录制等价。但还有一个悬而未决的问题——谁来切块、谁来把块分给线程、谁来合并。这三件事本质上是「把一批互相独立的工作分给一组 worker,再按顺序收结果」,正好是卷一那套 Job System 的本职。

本节把渲染的并行录制接到 Job System 上。分工是这样的:主线程把一帧的绘制项按材质分组、切成若干连续分块;每个分块作为一个「录制作业」派给 worker;worker 在自己的线程上把这个分块录成一段命令(这里用「算打包排序键」来代表录制的 CPU 成本);主线程收齐后按分块顺序合并。正确性靠「合并后的绘制序列 == 单线程顺序录制」来守,收益靠墙钟来量。

录制作业:分块、录制、合并

三个动作对应三个函数。partitionEvenly 把绘制项切成连续的 k 块——连续是有意的,因为同一块内部材质变化少,setPipeline 的切换也少。recordItems 把一块绘制项录成命令流,材质一变就插一条 setPipeline。mergeCommands 把各块命令首尾相接,顺序即分块顺序。

这里有一个必须说清楚的细节:分块边界会让相邻两块各录一次相同的 setPipeline。比如第 1 块末尾是 material-2、第 2 块开头也是 material-2,串行录制只会设一次管线,并行录制会设两次。这不是 bug,是并行录制的固有开销——块切得越细,冗余越多。所以断言的口径不能是「命令流逐条相等」,而应是「绘制序列逐条相等」:每条命令流里的 draw 与 setBindGroup 必须一一对应、顺序一致,setPipeline 允许在块边界重复。

可运行产物:正确性由断言守,收益由墙钟量

下面 src/render/thread/render-jobs.ts 是完整可编译文件,层级 L1。它把上面三个函数、绘制序列抽取、以及一个确定性调度模型放在一起。

// file: src/render/thread/render-jobs.ts
// tier: L1
// verify: npx vitest run src/render/thread/render-jobs.test.ts
//
// helio 渲染卷 17.3:把一帧的绘制项切成若干「录制作业」,分给 Job System 的
// worker 并行录成各自的命令缓冲,再由主线程按顺序合并提交。本节只做两件事:
//   1) 合并的正确性——并行录出的分块合并后必须与单线程顺序录制逐条相等;
//   2) 调度的代价——用一个确定性 list-scheduling 模型算出 makespan。
// 与卷一 14/15 章共用同一套「任务 → 依赖 → 合并」的思路,这里换成渲染命令。

import type { RenderCommand } from './command-buffer.ts';

export interface DrawItem {
  readonly id: number;
  readonly material: number;
  readonly vertexCount: number;
}

/** 单线程顺序录制一批绘制项:材质变化时插一条 setPipeline。 */
export function recordItems(items: readonly DrawItem[]): RenderCommand[] {
  const out: RenderCommand[] = [];
  let current = -1;
  for (const it of items) {
    if (it.material !== current) {
      out.push({ op: 'setPipeline', name: `material-${it.material}` });
      current = it.material;
    }
    out.push({ op: 'setBindGroup', index: 0, name: `bg-${it.id}` });
    out.push({ op: 'draw', vertexCount: it.vertexCount });
  }
  return out;
}

/** 把数组按顺序切成至多 k 个连续分块(前若干块各多 1 个,不丢不重)。 */
export function partitionEvenly<T>(items: readonly T[], k: number): T[][] {
  if (k < 1) throw new RangeError('k 至少为 1');
  const n = items.length;
  const parts = Math.min(k, n);
  const chunks: T[][] = [];
  let start = 0;
  for (let i = 0; i < parts; i++) {
    const size = Math.floor(n / parts) + (i < n % parts ? 1 : 0);
    chunks.push(items.slice(start, start + size));
    start += size;
  }
  return chunks;
}

/** 合并各分块录出的命令:按分块顺序首尾相接。 */
export function mergeCommands(chunks: readonly (readonly RenderCommand[])[]): RenderCommand[] {
  const out: RenderCommand[] = [];
  for (const c of chunks) out.push(...c);
  return out;
}

/**
 * 取出命令流里的绘制序列(draw / setBindGroup)。分块边界可能让相邻两块
 * 各录一次相同的 setPipeline(材质没变也重设一次),这是并行录制的预期开销;
 * 但绘制序列本身必须与单线程顺序录制逐条相等。
 */
export function drawSequence(cmds: readonly RenderCommand[]): RenderCommand[] {
  return cmds.filter((c) => c.op === 'draw' || c.op === 'setBindGroup');
}

/**
 * 模拟「录一个分块」的真实 CPU 成本:逐项算一个打包排序键(多次位运算),
 * 再累加成一个校验和。串行录整帧与分块并行录的校验和必须相等。
 */
export function recordChunkWork(items: readonly DrawItem[], iterations: number): number {
  let acc = 0;
  for (const it of items) {
    let h = (it.id + 1) * 2654435761;
    for (let i = 0; i < iterations; i++) h = (h ^ (h >>> 13)) * 1274126177;
    acc = (acc + (h >>> 0)) % 2147483647;
  }
  return acc;
}

export interface Job {
  readonly id: number;
  readonly cost: number;
}

export interface JobPlacement {
  readonly id: number;
  readonly worker: number;
  readonly start: number;
  readonly end: number;
}

export interface Schedule {
  readonly placements: readonly JobPlacement[];
  readonly makespan: number;
}

/** 确定性 list-scheduling:每个作业放到最早空闲的 worker 上。 */
export function listSchedule(jobs: readonly Job[], workers: number): Schedule {
  if (workers < 1) throw new RangeError('workers 至少为 1');
  const free = new Array<number>(workers).fill(0);
  const placements: JobPlacement[] = [];
  for (const job of jobs) {
    let pick = 0;
    for (let w = 1; w < workers; w++) if (free[w]! < free[pick]!) pick = w;
    const start = free[pick]!;
    const end = start + job.cost;
    free[pick] = end;
    placements.push({ id: job.id, worker: pick, start, end });
  }
  return { placements, makespan: Math.max(0, ...free) };
}

npx vitest run src/render/thread/render-jobs.test.ts 本机全绿,6 条断言:合并后的绘制序列与串行一致(k 取 1/2/3/5 都验)、分块不丢不重、分块校验和等于整帧、单 worker 的 makespan 等于总成本、worker 足够多时 makespan 等于最长作业、每个作业都落到当时最早空闲的 worker。npx tsc --noEmit 对本章文件零错误。

这里刻意把「录制」抽象成 recordChunkWork 这个纯计算函数,而不是真的去调 WebGPU:这样并行录制的正确性与收益都能在 Node 里用 worker_threads 直跑、可断言、可复现,不依赖 GPU。真实引擎里这个函数的内部会换成往 GPUCommandEncoder 里写命令,但分块、派发、合并的骨架一模一样——把易变的 GPU 部分隔离出去,才让这套编排能被单独验证。

真跑:worker_threads 的墙钟

正确性有了,接着量收益。下面两个文件把「分块录制」真派到 worker_threads 上:record-worker.ts 是 worker,收到一个分块就录、回传校验和;render-jobs-demo.ts 是主线程,跑一次串行、再跑一次 4 worker 并行,比墙钟。

// file: src/render/thread/record-worker.ts
// tier: L1
// verify: node src/render/thread/render-jobs-demo.ts
//
// Job System 的 worker:收到一个绘制项分块与迭代次数,在子线程里「录制」
// 这个分块(算打包排序键),把校验和回传主线程。主线程负责按分块顺序合并。

import { parentPort } from 'node:worker_threads';
import { recordChunkWork, type DrawItem } from './render-jobs.ts';

interface WorkRequest {
  readonly chunk: readonly DrawItem[];
  readonly iterations: number;
}

parentPort?.on('message', (raw: unknown) => {
  const req = raw as WorkRequest;
  const checksum = recordChunkWork(req.chunk, req.iterations);
  parentPort?.postMessage({ checksum });
});
// file: src/render/thread/render-jobs-demo.ts
// tier: L1
// verify: node src/render/thread/render-jobs-demo.ts
//
// 真跑:把一帧的绘制项分成 W 块,用 worker_threads 并行「录制」,与单线程
// 顺序录制对比墙钟耗时。校验和相等说明并行录制没有改变结果;耗时差说明
// 分块并行确实把 CPU 录制的墙钟压了下来。

import { availableParallelism } from 'node:os';
import { performance } from 'node:perf_hooks';
import { Worker } from 'node:worker_threads';
import {
  partitionEvenly, recordChunkWork, type DrawItem,
} from './render-jobs.ts';

const ITEMS = 4096;
const ITERATIONS = 20000;

function makeItems(n: number): DrawItem[] {
  const out: DrawItem[] = [];
  for (let i = 0; i < n; i++) out.push({ id: i, material: i % 7, vertexCount: 3 + (i % 5) });
  return out;
}

function serial(items: readonly DrawItem[]): { ms: number; checksum: number } {
  const t0 = performance.now();
  const checksum = recordChunkWork(items, ITERATIONS);
  return { ms: performance.now() - t0, checksum };
}

async function parallel(items: readonly DrawItem[], workers: number): Promise<{ ms: number; checksum: number }> {
  const chunks = partitionEvenly(items, workers);
  const t0 = performance.now();
  const results = await Promise.all(chunks.map((chunk) => new Promise<number>((resolve, reject) => {
    const w = new Worker(new URL('./record-worker.ts', import.meta.url));
    w.once('message', (m: unknown) => { resolve((m as { checksum: number }).checksum); void w.terminate(); });
    w.once('error', reject);
    w.postMessage({ chunk, iterations: ITERATIONS });
  })));
  const ms = performance.now() - t0;
  const checksum = results.reduce((a, b) => (a + b) % 2147483647, 0);
  return { ms, checksum };
}

const items = makeItems(ITEMS);
const workers = Math.min(4, availableParallelism());
const s = serial(items);
const p = await parallel(items, workers);
console.log(`items=${ITEMS} iterations=${ITERATIONS} workers=${workers}`);
console.log(`serial:   ${s.ms.toFixed(1)} ms  checksum=${s.checksum}`);
console.log(`parallel: ${p.ms.toFixed(1)} ms  checksum=${p.checksum}`);
console.log(`checksum equal: ${s.checksum === p.checksum}`);
console.log(`speedup: ${(s.ms / p.ms).toFixed(2)}x`);

node src/render/thread/render-jobs-demo.ts 的本机输出(本机 availableParallelism() 为 10,故取 4 个 worker):

items=4096 iterations=20000 workers=4
serial:   576.4 ms  checksum=359992271
parallel: 212.4 ms  checksum=359992271
checksum equal: true
speedup: 2.71x

两件事同时成立:checksum equal: true 说明并行录制与串行录制算出了同一个结果,speedup: 2.71x 说明 4 个 worker 确实把墙钟压了下来。加速比只有 2.71 而不是 4,是因为每次并行都要现场起 4 个 worker、把分块 postMessage 过去、再等消息回来——这部分固定开销不随分块大小缩小,分得越碎摊得越薄。多次实跑这个比值在 2.4x 到 3.0x 之间波动,取决于当时的系统负载。

调度模型与边界

真实引擎不会每次都现场起 worker,而是常驻一个线程池,作业从队列里取。作业分到哪个 worker、什么时候开始,就是调度问题。listSchedule 给了一个确定性模型:每个作业放到当时最早空闲的 worker 上,跑完的 makespan 就是整帧录制的墙钟下界。

用这个模型能看清两条边界。第一条是下限:无论多少 worker,makespan 都不会小于最长单个作业——如果一个分块的录制时间就超过了其它所有块之和,加再多线程也没用,说明分块本身不均衡,该改的是切法而不是线程数。第二条是上限收益:当 worker 数超过分块数时,多出来的 worker 只会空转,收益封顶在分块数上。所以「块数」和「线程数」要一起定:块数略多于线程数,既让调度有回旋余地,又不至于把每块切得太碎。

这套「作业 → 依赖 → 合并」的骨架与卷一是一致的:卷一的依赖 DAG 把任务连成图、线程池与任务队列 负责把就绪的任务派给空闲 worker;本节的渲染录制只是换了一种「任务」——它的输入是绘制项、输出是一段命令流,任务之间没有数据依赖,退化成一棵「扇出再扇入」的树。正因为没有依赖,才能放心地全并行。

把渲染录制接进 Job System 时,有一条线程安全的红线必须守住:命令缓冲与绑定组不可跨线程共享。worker 只能操作自己那一块的局部数据(绘制项、临时命令数组),录制产出的命令流要通过消息回传或共享内存交回主线程,由主线程统一 submit。任何试图让两个 worker 同时往同一个 GPUCommandEncoder 里写命令的做法都会立刻出错——WebGPU 的编码器不是线程安全的。所以并行的边界是「一块一个编码器」,而不是「一个编码器多线程填」。

这也解释了本节 demo 为什么把「录制」建模成一个纯计算函数而不是真去建编码器:编码器只能在拥有设备的那个线程上用,跨线程的是数据而不是设备对象。worker 算出的是「这一块要录哪些命令」的描述,主线程拿到描述后在自己的线程上真正建编码器、录命令、提交。分工的边界就落在这里:worker 负责算,主线程负责录与交。

小结

  • 并行录制的编排是「主线程分块 → worker 各自录 → 主线程按序合并」,正好落在 Job System 的职责范围内。
  • 正确性口径是绘制序列相等:setPipeline 允许在分块边界重复,但每条 draw 必须一一对应。
  • 本机实测:4 worker 把 4096 个绘制项的录制从 576.4 ms 压到 212.4 ms(2.71x),校验和与串行一致;加速比受 worker 启动与消息往返的固定开销限制,多次实跑在 2.4x–3.0x。
  • 调度模型给出两条边界:makespan 不小于最长单块(分块要均衡),收益不超过分块数(块数别少于线程数)。
  • 线程安全红线:一块一个编码器,命令缓冲与绑定组不可跨线程共享,合并与 submit 只在主线程做。

到这里,多线程渲染的三层——命令缓冲通道、并行录制、作业编排——都跑通了,而且每一层都有一条本机可复现的断言守着:通道不覆盖在途缓冲、并行录制与串行逐字节等价、并行作业与串行同校验和。下一章把渲染器的所有部件装配起来,真正把《微光岛》渲染出来。

阅读导航:上一节:17.2 OffscreenCanvas 与并行命令录制 · 下一节:18.1 渲染器装配与场景接入 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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