《游戏引擎实现原理》17.1 渲染线程模型与命令缓冲

主线程一边跑游戏逻辑一边录渲染命令,逻辑一卡帧就掉。本节把录制与提交拆到渲染线程上:主线程只往命令缓冲里塞命令,渲染线程按帧取走执行,用一个双缓冲的生产消费环把「还没消费完就覆写」变成一条确定性异常,并跑出背压的真实时间线,看清三缓冲比双缓冲多出的那一份缓冲垫换来了什么。

17.1 渲染线程模型与命令缓冲

到第 16 章为止,helio 的每一帧都在一个线程上跑完:算变换、剔除、排序、录命令、提交。这条路对《微光岛》这种量级够用,但只要主线程里多塞一点游戏逻辑——物理、AI、网络回调——渲染命令的录制就会被挤到帧尾,帧时间开始抖。这一章换一种组织方式:把「录命令」和「提交执行」拆到不同的线程上,让它们各跑各的。

本节先把最底层的骨架搭出来:一条主线程到渲染线程的命令缓冲通道。主线程是生产者,把「绑定管线、画多少顶点」这类命令写进缓冲;渲染线程是消费者,按帧把缓冲取走、交给 GPU。中间用一个双缓冲(或三缓冲)的环兜住两者速度差,并在「生产者想覆写消费者还没读完的缓冲」时直接抛错。

这条通道与卷一那套 Job System 是同一条脉络:卷一的线程池与任务队列 讲任务怎么排队,任务图与帧调度 讲任务怎么连成图,与主循环、渲染线程的同步 讲主循环与渲染线程怎么在帧边界对齐。本节把「同步」那一步落成一条可断言的命令缓冲环,第 17.3 节再把它接到 Job System 上。

本章与站内既有内容的分工

多线程渲染最容易写成「GPU 为什么快」的科普。先划清边界:

既有文章它负责什么本节只做什么
/graphics-render-graph/帧图的原理与 Unreal RDG / Unity SRP 案例不重推帧图,只讲它录出来的命令怎么跨线程传递与合并
/graphics-gpu-profiling-debugging/GPU 计时、帧耗时拆解、厂商调试工具不重做 profiling,只用墙钟对比「录制在主线程」与「录制在渲染线程」
/graphics-gpu-driven-rendering-lod/GPU 驱动的剔除、LOD 与间接绘制讲 CPU 侧把录制分块并行、主线程按顺序合并提交

一句话:别人讲「GPU 侧怎么并行」,本章讲「CPU 侧的录制怎么跨线程分工、又怎么保证结果和单线程一模一样」。

为什么要把录制与提交拆开

单线程帧循环里,录制和提交是绑死的:算完这一帧的所有绘制项,立刻录命令、立刻 submit,然后开始下一帧。问题出在 submit 之后的空档——GPU 跑这一帧的同时,主线程已经在为下一帧做逻辑,两件事抢同一个线程。逻辑一重,录制就被推迟,帧率开始掉。

拆线程之后,职责变成:

  • 主线程(生产者):跑游戏逻辑,把「要画什么」翻译成命令,塞进缓冲,然后立刻返回去跑下一帧逻辑。
  • 渲染线程(消费者):只做一件事——把缓冲里的命令取出来提交给 GPU,不掺任何游戏逻辑。

两者通过一个容量有限的环解耦。环的容量就是「允许同时在途的帧数」:容量 2 是双缓冲,容量 3 是三缓冲。主线程要开新一帧时,如果环里没有空闲槽位(渲染线程还没消费完),它就必须等——这就是背压(backpressure)。把背压做成一条明确的异常,比让它悄悄覆写一个还在读的缓冲、然后在屏幕上闪一帧要安全得多。这与第 3.3 节 uniform 三缓冲是同一个「写入前确认上一任已退役」的规则,只不过对象从 uniform buffer 换成了命令缓冲。

命令缓冲的生产/消费环

下面 src/render/thread/command-buffer.ts 是完整可编译文件,层级 L1。它用四个状态——free / recording / ready / consuming——把生产者与消费者的权限分开:生产者只能碰 recording 的槽位,消费者只能碰 consuming 的槽位,任何越界都会立刻抛错。

// file: src/render/thread/command-buffer.ts
// tier: L1
// verify: npx vitest run src/render/thread/command-buffer.test.ts
//
// helio 渲染卷 17.1:渲染线程模型的核心——命令缓冲的生产/消费环。
// 主线程是生产者:录一帧的命令到「录制中」的缓冲,帧末 publish 成「待消费」;
// 渲染线程是消费者:取走「待消费」的缓冲,执行完 release 回「空闲」。
// 环容量 = 允许同时在途的缓冲数;生产者找不到空闲缓冲时直接抛错,
// 把「渲染线程还没消费完、主线程又想录下一帧」这个竞态变成一条确定性异常。

/** 一条后端无关的命令。真实 RHI 里对应 setPipeline / draw / copy 等调用。 */
export type RenderCommand =
  | { readonly op: 'setPipeline'; readonly name: string }
  | { readonly op: 'setBindGroup'; readonly index: number; readonly name: string }
  | { readonly op: 'draw'; readonly vertexCount: number };

export type BufferState = 'free' | 'recording' | 'ready' | 'consuming';

export interface SlotView {
  readonly id: number;
  readonly state: BufferState;
  readonly count: number;
}

export class CommandBufferError extends Error {}

interface Slot {
  readonly id: number;
  state: BufferState;
  commands: RenderCommand[];
}

/**
 * 命令缓冲环。主线程与渲染线程各持一个句柄,互不越界:
 * 生产者只能碰「录制中」的缓冲,消费者只能碰「待消费」的缓冲。
 */
export class CommandBufferRing {
  readonly #slots: Slot[];
  readonly #capacity: number;

  constructor(capacity: number) {
    if (capacity < 2) throw new CommandBufferError('双缓冲至少需要 2 个槽位');
    this.#capacity = capacity;
    this.#slots = [];
    for (let i = 0; i < capacity; i++) {
      this.#slots.push({ id: i, state: 'free', commands: [] });
    }
  }

  get capacity(): number {
    return this.#capacity;
  }

  /** 主线程:申请一个空闲槽位开始录制。无空闲槽位时报错(渲染线程仍占着)。 */
  beginRecord(): number {
    const slot = this.#slots.find((s) => s.state === 'free');
    if (!slot) {
      const busy = this.#slots.map((s) => `${s.id}:${s.state}`).join(',');
      throw new CommandBufferError(`无空闲命令缓冲,渲染线程尚未消费完 [${busy}]`);
    }
    slot.state = 'recording';
    slot.commands = [];
    return slot.id;
  }

  /** 主线程:往正在录制的槽位追加一条命令。 */
  record(id: number, cmd: RenderCommand): void {
    const slot = this.#require(id, 'recording');
    slot.commands.push(cmd);
  }

  /** 主线程:结束录制,把槽位置为「待消费」。 */
  publish(id: number): void {
    const slot = this.#require(id, 'recording');
    slot.state = 'ready';
  }

  /** 渲染线程:取走最早的一个「待消费」槽位;没有则返回 -1。 */
  acquireReady(): number {
    const slot = this.#slots.find((s) => s.state === 'ready');
    if (!slot) return -1;
    slot.state = 'consuming';
    return slot.id;
  }

  /** 渲染线程:读取正在消费的槽位的命令(拷贝,防止消费中被改写)。 */
  readConsuming(id: number): readonly RenderCommand[] {
    return this.#require(id, 'consuming').commands.slice();
  }

  /** 渲染线程:执行完毕,把槽位归还为空闲。 */
  release(id: number): void {
    const slot = this.#require(id, 'consuming');
    slot.commands = [];
    slot.state = 'free';
  }

  /** 诊断视图:每个槽位的 id / 状态 / 命令条数。 */
  snapshot(): readonly SlotView[] {
    return this.#slots.map((s) => ({ id: s.id, state: s.state, count: s.commands.length }));
  }

  #require(id: number, state: BufferState): Slot {
    const slot = this.#slots[id];
    if (!slot) throw new CommandBufferError(`非法槽位 ${id}`);
    if (slot.state !== state) {
      throw new CommandBufferError(`槽位 ${id} 处于 ${slot.state},期望 ${state}`);
    }
    return slot;
  }
}

#require 是整段的核心:它把「这个操作合不合法」编码成一个状态断言,而不是靠调用方自觉。readConsuming 返回的是 slice() 拷贝,这样即使消费者在读,生产者也没法从另一个槽位改到它。

可运行产物:断言不覆盖在途缓冲

单测覆盖五个场景,跑法 npx vitest run src/render/thread/command-buffer.test.ts:

// file: src/render/thread/command-buffer.test.ts
// tier: L1
// verify: npx vitest run src/render/thread/command-buffer.test.ts
import { describe, expect, it } from 'vitest';
import { CommandBufferError, CommandBufferRing, type RenderCommand } from './command-buffer.ts';

const draw = (n: number): RenderCommand => ({ op: 'draw', vertexCount: n });

describe('CommandBufferRing', () => {
  it('双缓冲:生产者录两帧、消费者依次取走,命令原样保留', () => {
    const ring = new CommandBufferRing(2);
    const a = ring.beginRecord();
    ring.record(a, { op: 'setPipeline', name: 'forward' });
    ring.record(a, draw(3));
    ring.publish(a);

    const b = ring.beginRecord();
    ring.record(b, draw(6));
    ring.publish(b);

    const first = ring.acquireReady();
    expect(ring.readConsuming(first)).toEqual([
      { op: 'setPipeline', name: 'forward' },
      { op: 'draw', vertexCount: 3 },
    ]);
    ring.release(first);

    const second = ring.acquireReady();
    expect(ring.readConsuming(second)).toEqual([{ op: 'draw', vertexCount: 6 }]);
    ring.release(second);
    expect(ring.acquireReady()).toBe(-1);
  });

  it('两个槽位都被占满时,第三帧 beginRecord 报错(背压)', () => {
    const ring = new CommandBufferRing(2);
    const a = ring.beginRecord();
    ring.publish(a);
    const b = ring.beginRecord();
    ring.publish(b);
    expect(() => ring.beginRecord()).toThrow(/无空闲命令缓冲/);
  });

  it('消费者读到的命令不会被生产者下一帧改写(无撕裂)', () => {
    const ring = new CommandBufferRing(2);
    const a = ring.beginRecord();
    ring.record(a, draw(10));
    ring.publish(a);

    const consuming = ring.acquireReady();
    const taken = ring.readConsuming(consuming);

    // 生产者在另一个槽位录下一帧,内容与上一帧完全不同。
    const b = ring.beginRecord();
    ring.record(b, draw(999));
    ring.publish(b);

    // 消费者手里的命令不受影响。
    expect(taken).toEqual([{ op: 'draw', vertexCount: 10 }]);
    expect(ring.readConsuming(consuming)).toEqual([{ op: 'draw', vertexCount: 10 }]);
  });

  it('release 后槽位可被复用,且旧命令被清空', () => {
    const ring = new CommandBufferRing(2);
    const a = ring.beginRecord();
    ring.record(a, draw(1));
    ring.publish(a);
    const taken = ring.acquireReady();
    ring.release(taken);

    const reused = ring.beginRecord();
    expect(reused).toBe(a);
    expect(ring.snapshot()[a]!.count).toBe(0);
  });

  it('状态机越界会报错', () => {
    const ring = new CommandBufferRing(2);
    const a = ring.beginRecord();
    expect(() => ring.publish(999)).toThrow(CommandBufferError);
    expect(() => ring.release(a)).toThrow(/期望 consuming/);
    ring.publish(a);
    expect(() => ring.record(a, draw(1))).toThrow(/期望 recording/);
  });
});

本机实跑:npx vitest run src/render/thread/command-buffer.test.ts 全绿,5 条断言通过,npx tsc --noEmit 对本章文件零错误。三条关键断言的实测结果最能说明问题:双缓冲下连续两帧的命令读回各自独立;两个槽位占满后第三次 beginRecord 抛 无空闲命令缓冲,渲染线程尚未消费完;消费者手里的 draw(10) 在生产者为下一帧录了 draw(999) 之后依然不变。

双缓冲、三缓冲与背压

容量该取几,取决于「渲染线程最多落后主线程多少帧」。下面 src/render/thread/command-buffer-demo.ts 用确定性事件循环模拟「主线程每帧都录、渲染线程每 2 帧才消费一帧」,对比容量 2 与容量 3 的背压时刻:

// file: src/render/thread/command-buffer-demo.ts
// tier: L1
// verify: node src/render/thread/command-buffer-demo.ts
//
// 用一个确定性事件循环模拟「主线程录帧、渲染线程慢半拍消费」:
// 主线程每帧都尝试录制并发布;渲染线程每 consumerPeriod 帧才消费一帧。
// 当两个槽位都在途时,主线程 beginRecord 会撞上背压异常。

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

export interface Step {
  readonly frame: number;
  readonly consumed: number | null;
  readonly recorded: number | null;
  readonly backpressure: string | null;
}

/** 模拟 frames 帧:consumerPeriod 帧消费一次,主线程每帧都录。 */
export function simulate(capacity: number, frames: number, consumerPeriod: number): Step[] {
  const ring = new CommandBufferRing(capacity);
  const steps: Step[] = [];
  for (let f = 0; f < frames; f++) {
    let consumed: number | null = null;
    if (f % consumerPeriod === 0) {
      const taken = ring.acquireReady();
      if (taken !== -1) {
        ring.release(taken);
        consumed = taken;
      }
    }
    let recorded: number | null = null;
    let backpressure: string | null = null;
    try {
      const id = ring.beginRecord();
      ring.record(id, { op: 'draw', vertexCount: (f + 1) * 3 });
      ring.publish(id);
      recorded = id;
    } catch (e) {
      backpressure = (e as Error).message;
    }
    steps.push({ frame: f, consumed, recorded, backpressure });
  }
  return steps;
}

function report(capacity: number, frames: number, consumerPeriod: number): void {
  const steps = simulate(capacity, frames, consumerPeriod);
  console.log(`capacity=${capacity} consumerPeriod=${consumerPeriod}`);
  for (const s of steps) {
    const c = s.consumed === null ? '-' : `consume(${s.consumed})`;
    const r = s.recorded === null ? `BACKPRESSURE: ${s.backpressure}` : `record(${s.recorded})`;
    console.log(`  frame ${s.frame}: ${c}  ${r}`);
  }
}

report(3, 8, 2); // 三缓冲:同样的慢消费者,背压来得更晚
report(2, 8, 2); // 双缓冲:同样的慢消费者,背压来得更早

node src/render/thread/command-buffer-demo.ts 的本机输出(同样的消费者速度,只改容量;这里摘出各自的背压帧):

capacity=3 consumerPeriod=2
  frame 5: -  BACKPRESSURE: 无空闲命令缓冲,渲染线程尚未消费完 [0:ready,1:ready,2:ready]
capacity=2 consumerPeriod=2
  frame 3: -  BACKPRESSURE: 无空闲命令缓冲,渲染线程尚未消费完 [0:ready,1:ready]

同样每 2 帧消费一次,双缓冲在第 3 帧就撞上背压,三缓冲推迟到第 5 帧。多出来的那一份槽位不是「更安全一点」,而是把可容忍的在途帧数从 2 抬到 3,正好覆盖常见的「录一帧、跑一帧、再排一帧」节奏。代价是每多一份槽位就多一份命令存储与一次提交机会,三缓冲是社区里被验证过的甜点值。

需要强调的是:背压不是 bug,是设计。容量有限意味着主线程最多只能领先渲染线程固定帧数,超了就必须等——这恰好把「渲染线程别落后太多、否则输入延迟爆炸」这条约束编码进了结构里。真正的 bug 是让生产者无界地写、把内存吃光,或者覆写一个还在读的缓冲、在屏幕上闪一帧。

小结

  • 把录制与提交拆到渲染线程,是为了让主线程的游戏逻辑不再和渲染命令抢同一个线程。
  • 命令缓冲环用 free / recording / ready / consuming 四态把生产者与消费者的权限分开,越界即抛错。
  • 环容量 = 最大在途帧数;双缓冲、三缓冲的差别是能容忍渲染线程落后几帧,超了就是背压。
  • 与第 3.3 节的 uniform 三缓冲同一条规则:写入前必须确认上一任已退役。

有了这条通道,下一个问题是:一帧的命令能不能在多个线程上并行录,而不是只在渲染线程上串行录?这就要用到 OffscreenCanvas——它让渲染表面也能被搬到别的线程。

阅读导航:上一节:回到目录 · 下一节:17.2 OffscreenCanvas 与并行命令录制 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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