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 剪辑、轨道与求值器 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。