《游戏引擎实现原理》2.3 流式加载与区块(《微光岛》的岛)

《微光岛》的岛不是一整块资源:本节用 TypeScript 把它切成 4x4 个区块,用相机距离排加载优先级、用每帧字节预算限流、用加载与卸载双半径做滞回防抖,写出一个可逐帧推进的流式调度器,并实测单块字节数、峰值驻留与单帧最大派发量,全部来自本机实跑。

2.3 流式加载与区块(《微光岛》的岛)

2.1 与 2.2 处理的是「离线怎么产出、构建怎么复用」。但到了运行时还有第三个尺度的问题:内存装不下整个世界。《微光岛》的岛就算只有 32×32 世界单位,全精度网格也要 53760 字节,真实项目里这是几 GB 与几百 MB 的差距。做法是流式:把世界切成区块,玩家走到哪就把哪一带装进来、把走远了的卸掉。

本节产出一个可跑的 src/subsys/asset/streaming.ts(L1,Node 直跑)与它的 vitest 用例。区块字节数复用渲染卷的高度场真实生成,不是估的。

流式和 2.1 的「异步加载」看着像,其实管的是两件事:2.1 的加载器回答「怎么把一个资源从磁盘搬进内存」,本节的调度器回答「这一帧该搬哪些、不该搬哪些」。前者是传输,后者是策略——策略错了,传输再快也会掉帧。

区块化:把岛切成 4x4

区块化的第一步是切。用渲染卷 src/render/mesh/procedural-mesh.ts 的 generateIsland 生成整岛网格,再按网格坐标切出 4×4 个子网格。关键细节:相邻区块共享顶点——左块最右列与右块最左列是同一个世界顶点,所以拼起来没有缝。切的时候只搬顶点、重映射索引,不重算高度,保证接缝处坐标逐位一致。

每个区块记录三样东西:

  • 世界中心 (cx, cz):算距离用,决定优先级;
  • 包围半径:与中心一起划出「进圈」的范围;
  • 两级 LOD 的字节数:LOD0 用 8×8 单元(3360 B)、LOD1 用 4×4 单元(992 B),远处的块用低精度,省的不只是内存,还有顶点处理带宽。

区块粒度是个取舍。块太大(整岛只切 2×2),流式分辨率就粗——想装 8 单位宽的一带,实际得整块 16 单位装进来;块太小(切到 8×8 共 64 块),每块的固定开销(头部、draw call、调度条目)占比又上去了。4×4 是在 32 单位的岛上折中的结果:每块 8×8 世界单位,粒度够细又不至于碎片化。

三条约束:预算、优先级、滞回

流式调度器每帧只做三件事,但每一件都有陷阱:

  • 预算:一帧只准开始加载有限字节。解码与上传都要吃主线程,一次涌进来几十个区块必然掉帧。预算耗尽的区块留到下一帧。
  • 优先级:同样是待加载,离相机近的先来。把待加载集合按距离升序排,预算不够就截断——近的天然排前面,远的自动顺延,不需要额外的权重系统。
  • 滞回:加载半径与卸载半径必须分开(这里 12 与 18)。若共用一个半径,玩家站在边界上时区块会在「进圈 → 出圈 → 进圈」之间疯狂抖动,每帧都加载卸载。两个半径之间的缓冲区就是防抖带。
  • 取消:区块在加载途中若相机走远,必须当场取消,否则白花预算、还把内存拖到下一帧才释放。update() 返回值里的 cancelled 就是这条。
  • 确定性:所有排序都要有稳定 tie-break(这里用区块 id)。不引入随机、不看时间戳,同一段相机轨迹才跑得出同一份逐帧报告——确定性是能写进单测的前提。

可运行产物:可帧进的流式调度器

下面是完整的 src/subsys/asset/streaming.ts。update(camX, camZ) 每帧按「先收尾、再淘汰、后卸载、最后派发」推进:到期的在飞请求转正、超硬上限的驻留块按最远优先淘汰、越过卸载半径的驻留块卸掉(在飞的直接取消)、剩下的按距离升序在预算内开始加载。

// file: src/subsys/asset/streaming.ts
// tier: L1
// verify: npx vitest run src/subsys/asset/streaming.test.ts
//
// 《微光岛》的岛不是一整块资源,而是被切成 4x4 的**区块**(chunk)。玩家走到哪,
// 引擎就把哪一带的区块异步加载进来、把走远了的卸掉。难点不在「加载」本身,而在三条约束:
//   1. 预算:一帧只准开始加载有限字节,否则解码 / 上传会顶穿帧预算;
//   2. 优先级:同样是待加载,离相机近的先来(远处的等下一帧甚至永远不来);
//   3. 滞回:加载半径与卸载半径必须分开,否则站在边界上会疯狂加载 / 卸载抖动。
// 本节把这三条做成一个可帧进的调度器,并复用渲染卷的高度场,让区块字节数是真的。

import { generateIsland, packInterleaved } from '../../render/mesh/procedural-mesh.ts';
import type { Mesh } from '../../render/mesh/procedural-mesh.ts';

/** 从整岛网格里切出 perSide x perSide 个区块,相邻块共享顶点,拼起来无缝。 */
export function chunkMeshes(extent: number, perSide: number, cells: number, seed: number, peak: number): Mesh[] {
  const full = generateIsland({ cols: perSide * cells, rows: perSide * cells, extent, peak, seed });
  const nx = perSide * cells + 1, n = cells + 1, out: Mesh[] = [];
  for (let j = 0; j < perSide; j++) for (let i = 0; i < perSide; i++) {
    const pos = new Float32Array(n * n * 3), nor = new Float32Array(n * n * 3), uv = new Float32Array(n * n * 2);
    const idx = new Uint16Array(cells * cells * 6);
    for (let b = 0; b < n; b++) for (let a = 0; a < n; a++) {
      const g = (j * cells + b) * nx + (i * cells + a), v = b * n + a;
      pos.set(full.positions.subarray(g * 3, g * 3 + 3), v * 3);
      nor.set(full.normals.subarray(g * 3, g * 3 + 3), v * 3);
      uv.set(full.uvs.subarray(g * 2, g * 2 + 2), v * 2);
    }
    let t = 0;
    for (let b = 0; b < cells; b++) for (let a = 0; a < cells; a++) {
      const p = b * n + a, q = p + 1, r = p + n, s = r + 1;
      idx.set([p, r, s, p, s, q], t); t += 6;
    }
    out.push({ positions: pos, normals: nor, uvs: uv, indices: idx, vertexCount: n * n, triangleCount: cells * cells * 2, stride: full.stride, min: full.min, max: full.max });
  }
  return out;
}

/** 一个区块的描述:世界中心、包围半径、两级 LOD 的字节数(顶点 + 索引)。 */
export type Chunk = {
  readonly id: string;
  readonly cx: number;
  readonly cz: number;
  readonly radius: number;
  readonly bytes: readonly [number, number];
};

/** 把 extent 见方的岛切成 perSide^2 个区块;LOD1 的网格单元数减半。 */
export function chunkIsland(extent: number, perSide: number, cells: number, seed = 7, peak = 3): Chunk[] {
  const lod0 = chunkMeshes(extent, perSide, cells, seed, peak);
  const lod1 = chunkMeshes(extent, perSide, Math.max(1, cells >> 1), seed, peak);
  const span = extent / perSide, chunks: Chunk[] = [];
  for (let j = 0; j < perSide; j++) for (let i = 0; i < perSide; i++) {
    const a = lod0[j * perSide + i]!, b = lod1[j * perSide + i]!;
    chunks.push({
      id: `chunk_${i}_${j}`,
      cx: (i + 0.5) * span, cz: (j + 0.5) * span, radius: span * Math.SQRT1_2,
      bytes: [packInterleaved(a).byteLength + a.indices.byteLength, packInterleaved(b).byteLength + b.indices.byteLength],
    });
  }
  return chunks;
}

export type StreamingConfig = {
  readonly loadRadius: number;
  readonly unloadRadius: number; // 必须 > loadRadius,形成滞回
  readonly budgetBytesPerFrame: number;
  readonly latencyFrames: number;
  readonly lodRadius: number; // 距离小于此值用 LOD0,否则 LOD1
  readonly maxResidentBytes?: number; // 驻留硬上限,超出即淘汰最远块;缺省不限
  readonly pinnedRadius?: number; // 此半径内的区块被钉住,永不淘汰
};

export type ChunkState = 'unloaded' | 'loading' | 'resident';
export type FrameReport = {
  readonly frame: number;
  readonly requested: readonly string[];
  readonly completed: readonly string[];
  readonly cancelled: readonly string[];
  readonly unloaded: readonly string[];
  readonly evicted: readonly string[];
  readonly resident: number;
  readonly inFlight: number;
  readonly residentBytes: number;
  readonly requestedBytes: number;
};

type Pending = { readonly readyAt: number; readonly bytes: number; readonly lod: 0 | 1 };

/** 流式调度器:每帧「先收尾、再卸载、后派发」;派发按距离升序,天然实现近的优先。 */
export class StreamingManager {
  private readonly chunks: readonly Chunk[];
  private readonly cfg: StreamingConfig;
  private readonly byId: Map<string, Chunk>;
  private readonly pending = new Map<string, Pending>();
  private readonly resident = new Map<string, Pending>();
  private frameNo = 0;
  private bytes = 0;
  private readonly peak = { residentBytes: 0, inFlight: 0, loaded: 0, unloaded: 0, evicted: 0 };

  constructor(chunks: readonly Chunk[], cfg: StreamingConfig) {
    if (cfg.unloadRadius <= cfg.loadRadius) throw new Error('unloadRadius 必须大于 loadRadius');
    this.chunks = chunks; this.cfg = cfg;
    this.byId = new Map(chunks.map((c) => [c.id, c]));
  }

  state(id: string): ChunkState {
    if (this.resident.has(id)) return 'resident';
    return this.pending.has(id) ? 'loading' : 'unloaded';
  }

  private d2(c: Chunk, x: number, z: number): number { const dx = c.cx - x, dz = c.cz - z; return dx * dx + dz * dz; }

  /** 推进一帧,返回本帧调度明细,供 HUD / 测试断言。 */
  update(camX: number, camZ: number): FrameReport {
    this.frameNo++;
    const completed: string[] = [], cancelled: string[] = [], unloaded: string[] = [], evicted: string[] = [];
    // 1) 收尾:延迟到期的在飞加载转正。
    for (const [id, p] of [...this.pending]) {
      if (p.readyAt <= this.frameNo) { this.pending.delete(id); this.resident.set(id, p); this.bytes += p.bytes; completed.push(id); }
    }
    // 2) 淘汰:驻留超硬上限时,反复丢掉「最远且未被钉住」的块,直到回到上限内。
    const cap = this.cfg.maxResidentBytes ?? Infinity;
    const pinR2 = (this.cfg.pinnedRadius ?? 0) ** 2;
    while (this.bytes > cap && this.resident.size > 0) {
      let victim: string | null = null, worst = -1;
      for (const [id] of this.resident) {
        const d = this.d2(this.byId.get(id)!, camX, camZ);
        if (d <= pinR2) continue; // 钉住的不动
        if (d > worst) { worst = d; victim = id; }
      }
      if (victim === null) break; // 剩下的全被钉住,宁可超限也不丢
      this.bytes -= this.resident.get(victim)!.bytes;
      this.resident.delete(victim);
      evicted.push(victim);
    }
    const loadR2 = this.cfg.loadRadius ** 2, unloadR2 = this.cfg.unloadRadius ** 2;
    // 3) 卸载:越过卸载半径的驻留块卸掉;在飞但已越界的请求直接取消。
    for (const [id, p] of [...this.resident]) if (this.d2(this.byId.get(id)!, camX, camZ) > unloadR2) { this.bytes -= p.bytes; this.resident.delete(id); unloaded.push(id); }
    for (const [id] of [...this.pending]) if (this.d2(this.byId.get(id)!, camX, camZ) > unloadR2) { this.pending.delete(id); cancelled.push(id); }
    // 4) 派发:加载半径内、既非驻留也非在飞的区块,按距离升序、预算内开始加载。
    const want = this.chunks
      .filter((c) => this.d2(c, camX, camZ) <= loadR2 && !this.resident.has(c.id) && !this.pending.has(c.id))
      .sort((a, b) => this.d2(a, camX, camZ) - this.d2(b, camX, camZ) || a.id.localeCompare(b.id));
    const requested: string[] = [];
    let requestedBytes = 0;
    for (const c of want) {
      const lod: 0 | 1 = this.d2(c, camX, camZ) <= this.cfg.lodRadius ** 2 ? 0 : 1;
      const bytes = c.bytes[lod];
      if (requested.length > 0 && requestedBytes + bytes > this.cfg.budgetBytesPerFrame) break;
      this.pending.set(c.id, { readyAt: this.frameNo + this.cfg.latencyFrames, bytes, lod });
      requested.push(c.id); requestedBytes += bytes;
      if (requestedBytes >= this.cfg.budgetBytesPerFrame) break; // 单块就吃满预算,本帧到此
    }
    this.peak.residentBytes = Math.max(this.peak.residentBytes, this.bytes);
    this.peak.inFlight = Math.max(this.peak.inFlight, this.pending.size);
    this.peak.loaded += requested.length; this.peak.unloaded += unloaded.length; this.peak.evicted += evicted.length;
    return { frame: this.frameNo, requested, completed, cancelled, unloaded, evicted, resident: this.resident.size, inFlight: this.pending.size, residentBytes: this.bytes, requestedBytes };
  }

  summary(): { resident: number; residentBytes: number; peakResidentBytes: number; peakInFlight: number; totalLoaded: number; totalUnloaded: number; totalEvicted: number; pending: number } {
    return { resident: this.resident.size, residentBytes: this.bytes, peakResidentBytes: this.peak.residentBytes, peakInFlight: this.peak.inFlight, totalLoaded: this.peak.loaded, totalUnloaded: this.peak.unloaded, totalEvicted: this.peak.evicted, pending: this.pending.size };
  }
}

// ---- 直接运行:造一座岛,让相机沿 z=16 横向扫过,观察预算排队、滞回卸载与超限淘汰。----
if ((import.meta as { main?: boolean }).main) {
  const EXTENT = 32, SIDE = 4, CELLS = 8;
  const chunks = chunkIsland(EXTENT, SIDE, CELLS);
  const mgr = new StreamingManager(chunks, { loadRadius: 12, unloadRadius: 18, budgetBytesPerFrame: 4096, latencyFrames: 3, lodRadius: 7, maxResidentBytes: 12000, pinnedRadius: 4 });
  console.log(`区块数 ${chunks.length}  单块 LOD0 ${chunks[0]!.bytes[0]}B / LOD1 ${chunks[0]!.bytes[1]}B  全岛 LOD0 合计 ${chunks.length * chunks[0]!.bytes[0]}B  驻留上限 12000B`);
  let maxFrame = 0;
  for (let f = 0; f < 60; f++) {
    const r = mgr.update((f * 1.2) % EXTENT, 16);
    maxFrame = Math.max(maxFrame, r.requestedBytes);
    if (f % 6 === 0 || f === 59) console.log(`f${String(f).padStart(2)} req=${r.requested.length} done=${r.completed.length} unload=${r.unloaded.length} evict=${r.evicted.length} resident=${r.resident} bytes=${r.residentBytes}`);
  }
  const s = mgr.summary();
  console.log(`峰值驻留 ${s.peakResidentBytes}B  峰值在飞 ${s.peakInFlight}  单帧最大派发 ${maxFrame}B  累计加载 ${s.totalLoaded} 次 / 卸载 ${s.totalUnloaded} 次 / 淘汰 ${s.totalEvicted} 次`);
}

驻留上限、淘汰与抖动

距离与滞回解决了「哪些该在内存里」,但没给总量设限。真实项目还要一条硬上限:maxResidentBytes。超了就从最远且未被钉住的块开始丢,直到回到上限内;pinnedRadius 里的块(比如玩家脚下的地形)永不淘汰,宁可短暂超限。update() 的返回值里多了一个 evicted 列表,与 unloaded(距离卸载)分开——两者原因不同,HUD 上要分开画。

这里有个必须知道的坑:被淘汰的块如果仍在加载半径内,当帧就会被重新派发——淘汰与派发互相拉扯,形成抖动。demo 里 60 帧淘汰了 4 次,正是这种拉扯。解法有三条,按代价从低到高:

  • 把 maxResidentBytes 调到「加载半径内的块装得下」,让淘汰只在极端情况发生;
  • 给被淘汰块一个冷却期(N 帧内不再派发),用时间换抖动;
  • 用真正的 LRU:按「最后一次可见」排序,而不是只看当前距离,避免相机来回摆时反复丢近块。

本节实现的是第一种(靠参数配合)并留出第二种的接口(evicted 列表让上层能加冷却),第三种留作练习。

还有两件本节的模型没覆盖、但上线必须处理的:

  • LOD 过渡:区块从 LOD1 升到 LOD0 时顶点数翻倍,直接换会跳变(popping)。做法是在过渡带做几何混合或淡入淡出;本节的 LOD 只在派发时定死,不升降。
  • 失败重试:加载可能失败(文件缺失、解码异常)。一个只会 pending 的调度器会把失败的块永久卡在 loading,所以要有超时与退避重试,并且失败计数要计入预算,否则重试风暴会顶穿帧预算。

流式的账

在工程目录里 node src/subsys/asset/streaming.ts(本机实测):

区块数 16  单块 LOD0 3360B / LOD1 992B  全岛 LOD0 合计 53760B  驻留上限 12000B
f 0 req=1 done=0 unload=0 evict=0 resident=0 bytes=0
f 6 req=0 done=0 unload=0 evict=0 resident=4 bytes=8704
f12 req=0 done=0 unload=0 evict=0 resident=6 bytes=10688
f18 req=0 done=0 unload=1 evict=0 resident=7 bytes=6944
f24 req=0 done=0 unload=0 evict=0 resident=6 bytes=5952
f30 req=2 done=1 unload=0 evict=0 resident=1 bytes=3360
f36 req=0 done=0 unload=0 evict=0 resident=6 bytes=10688
f42 req=0 done=0 unload=1 evict=0 resident=6 bytes=10688
f48 req=0 done=0 unload=0 evict=0 resident=6 bytes=5952
f54 req=1 done=0 unload=6 evict=0 resident=0 bytes=0
f59 req=0 done=2 unload=0 evict=0 resident=4 bytes=8704
峰值驻留 11680B  峰值在飞 5  单帧最大派发 3360B  累计加载 26 次 / 卸载 18 次 / 淘汰 4 次

相机沿 z=16 从 x=0 扫到 x=32,每帧走 1.2 单位,共 60 帧。几条值得记的:

  • 单帧最大派发 3360 B,始终没超过预算 4096 B——预算真的把住了;因为一个 LOD0 块就 3360 B,所以每帧最多起一个新块。
  • 峰值驻留 11680 B,压在硬上限 12000 B 之下,也只有全岛的约 22%。这就是流式的收益:世界可以很大,内存只按视野付。
  • 峰值在飞 5:延迟 3 帧、每帧起一块,队列最深压到 5,既没饿死也没堆积。
  • 累计加载 26 次、卸载 18 次、淘汰 4 次,说明区块确实在进进出出——滞回半径 12 / 18 吃掉边界抖动,硬上限则在极端时兜底。

流式还有一个容易被忽略的收益:首帧时间。整岛一次性加载时,玩家要等全部 53760 B 读完才能进游戏;流式之后,第一帧只需最靠近相机的一两块(3360 B 量级),进游戏的门槛被压到最低。代价是运行时要持续调度——但那是每帧几微秒的事,远比让玩家干等划算。

调度器是确定性的:同一段相机轨迹跑两遍,逐帧报告逐字节相同。这一点比性能更重要——确定性才能写进单测、才能在 CI 里复现。update() 每帧返回的 FrameReport 就是给 HUD 和测试用的:requestedBytes 可直接画成预算曲线,residentBytes 画成内存曲线,inFlight 画成队列深度,evicted 单独画成淘汰曲线。

到这里,资源管线三件套齐了:离线导入(2.1)产出产物、增量构建(2.2)决定复用、流式加载(2.3)决定运行时装谁。三者共用同一份元数据与内容哈希:导入器写下 id@version,构建器用它算键,调度器用区块字节数算预算——一条数据流贯穿离线到运行时。

下一章换到「让岛上的角色动起来」——动画。

阅读导航:上一节:2.2 缓存、热重载与增量构建 · 下一节:3.1 剪辑、轨道与求值器 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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