《游戏引擎实现原理》7.2 关卡与内容管线

关卡是引擎与内容之间的第一道契约:编辑器导出什么、运行时就必须能读什么。本节给出《微光岛》的带版本号关卡 JSON、只做三件事的加载器(解析校验 / 程序化生成 / 翻译成渲染实例),并用本机实跑的种子生成、越界拒绝与剔除统计,证明内容管线全程确定、可复现。

7.2 关卡与内容管线

上一节把玩法链跑通了,但玩家的位置、微光的坐标都是从代码里硬编码出来的。真实的切片必须让内容从代码里出来:关卡由编辑器产出、由运行时加载,改一个数字不该重新编译引擎。

内容管线的第一道契约,就是编辑器产出什么、运行时就必须能读什么。它和卷三讲过的编辑器序列化(helio-subsys-08-3 场景序列化格式 )是同一件事的两端:编辑器那端负责把场景树写出来,运行时这端负责把它读回来并变成引擎认识的东西。本节只做运行时这端,并且把「读回来」拆成三个互不越界的步骤。

《微光岛》的关卡刻意保持最小:一个小岛、一批微光、一批树、两个手工摆件。范围一旦膨胀成「可编辑的完整世界」,就会滑向引擎编辑器本身——那是卷三的主题,不是这一节。

本节的目标可以概括成一句可验证的话:给同一个种子,任何机器上都长出同一个岛。这一句同时覆盖了三件事——格式必须带版本号(否则旧文件读不了)、生成必须确定(否则同种子不同结果)、装配必须可统计(否则无从验证)。下面每一节都对着这一句展开,并且全部在本机真跑。

需要提前说明的是:本节讲的「内容」只有几何位置,不含美术资产。贴图、模型、音频这些资源的导入与压缩是第 1 章的主题,这里不重复;本节关心的只有一件事——位置数据从文件到引擎的那条路径。

把「位置」和「资产」分开,也是刻意的:位置数据小、可读、可手写,适合用来验证管线的正确性;资产数据大、二进制、需要压缩,适合用来验证管线的吞吐。两者是同一套「文件 → 运行时」思路的两个极端,本节先用最简单的一端把管线打通,另一端在第 1 章已经打通。

关卡数据格式:版本号 + 参数 + 摆件

关卡文档是一份 JSON,只有四个顶层字段:version、island(种子 / 尺寸 / 密度)、spawn(出生点)、props(手工摆件)。它有意做得极扁,因为扁平的格式最容易校验、最容易做版本迁移,也最容易让非程序员手改。

三个设计决定值得说明:

  • 种子而不是坐标。微光和树的位置不写进文件,只写一个 seed。运行时用种子程序化生成——文件因此可以只有几百字节,而岛屿可以任意大。代价是生成算法一旦改动,旧种子的布局就会变,所以生成算法本身也要版本化(本节的 LEVEL_VERSION 就是为此预留的)。
  • 校验前置于使用。加载器不信任文件里的任何数字:尺寸必须为正、种子必须是整数、出生点和摆件必须落在小岛范围内。任何一条不过就抛错,绝不「读进来一半」。这和卷三讲导入器的思路一致(见 helio-subsys-02-1 离线导入 ):坏内容必须在加载期就死,不能拖到运行时才崩。
  • 摆件与生成物分开。props 是编辑器里手工摆的(宝箱、路牌),glimmers/trees 是程序生成的。两者来源不同,所以运行时也分开保存,只有在「翻译成渲染实例」时才汇到一条流里。

字段表如下,每个字段都对应加载器里的一条校验:

字段类型校验越界的后果
version整数必须等于 LEVEL_VERSION未知版本直接拒绝加载
island.seed整数必须是整数非整数种子会让生成不可复现
island.size正数必须 > 0尺寸为 0 会让整座岛退化成一点
island.glimmerCount非负整数不能为负负数会让生成循环越界
island.treeCount非负整数不能为负同上
spawn二维点落在 [-size/2, size/2] 内出生点落到岛外,玩家一进场就掉下去
props[].kind非空字符串不能为空空类型名无法映射到任何 mesh

一份最小的关卡文档长这样,微光和树一个坐标都没写,全靠种子生成:

// snippet: 编辑器导出的关卡文档(SAMPLE_LEVEL)
{
  "version": 1,
  "island": { "seed": 20261010, "size": 20, "glimmerCount": 9, "treeCount": 12 },
  "spawn": { "x": 0, "z": 0 },
  "props": [
    { "kind": "chest", "x": -7, "z": 6 },
    { "kind": "sign", "x": 5, "z": -6 }
  ]
}

为什么不让编辑器直接导出微光的绝对坐标,而是只给种子?因为「绝对坐标」把生成算法锁死在了导出那一刻——你没法在不重新导出全部关卡的前提下调整生成规则。给种子则相反:文件描述意图(这里要 9 个微光),运行时负责实现。这条「数据描述意图、代码负责实现」的原则,和第 7.1 节里「输入只写速度、移动负责积分」是同一种分工。

还有一点:校验失败的信息必须是可定位的。加载器抛出的每一条错误都带上了出错的字段名(island.size、spawn、prop chest),而不是一句笼统的「关卡非法」。理由和第 5.3 节插件加载失败一样——错误信息是给内容作者看的,不是给日志看的。作者看到「prop chest 越界」,立刻知道是那个宝箱摆到岛外了;看到「关卡非法」,只能反过来读引擎源码。

加载器只做三件事

运行时的加载器被刻意限制成三个纯函数,每一步都不碰 GPU、不碰 ECS:

  1. parseLevel:解析 + 校验,返回一个被信任的 LevelDoc。
  2. buildLevel:把文档里的种子喂给 scatter,程序化生成微光与树,合并手工摆件,返回一个 Level。
  3. toSceneInstances + assembleLevel:把 Level 翻译成渲染器能消费的实例列表,并送进渲染卷的装配流水线(剔除 + 排序,见 helio-render-18-1 渲染器装配 ),返回绘制统计。

「只做三件事」是刻意的限制。加载器不生成网格、不上传 GPU、不碰 ECS——它只把「文件里的意图」变成「内存里的数据」,至于这份数据怎么变成画面,是渲染器的事。这条界线一旦模糊,加载器就会长成一个什么都干的怪物,而它恰恰是最需要保持简单、最需要能被单测轰击的那一层。

这条链的好处是每一段都可单独测试:解析器可以拿坏 JSON 反复轰,生成器可以断言「同种子逐位相同」,装配段可以断言剔除数量。下一节的 L2 渲染拿到的,就是这个 assembleLevel 产出的实例列表。

把「解析 + 生成 + 翻译」拆开还有一个工程上的好处:每一段都能被独立替换。将来如果生成算法要从「抖动网格」升级成「泊松盘采样」,只改 scatter 一个函数,解析器和装配段一行都不用动;如果关卡格式要从 JSON 换成二进制,只改 parseLevel。三段之间没有共享的可变状态,所以替换永远是局部的。

三段之间用数据而不是调用串起来,是刻意的:parseLevel 只认识字符串,buildLevel 只认识 LevelDoc,toSceneInstances 只认识 Level。任何一段想偷看别的段的内部状态,都会在类型上被挡住。这和第 5.1 节的模块边界检查器是同一种约束——只不过这里靠的是类型系统,而不是脚本。

还有一个容易被忽略的细节:assembleLevel 复用的 assembleFrame 是渲染卷的装配流水线。内容管线没有自己写一套剔除,而是把「内容」直接喂给「渲染」,这正说明垂直切片的意义——每一层都被真实使用,而不是各写各的玩具。

可运行产物:程序化小岛生成与装配

整个内容管线在下面这个文件里,纯 TS、Node 26 直跑。scatter 用 hashUnit(来自渲染卷的精灵批次工具)做确定性抖动:点铺在抖动网格上,抖动限制在格子的 60% 内,保证不跨格、不重叠。

// file: src/game/level-pipeline.ts
// tier: L1
// verify: node src/game/level-pipeline.ts
//
// 《微光岛》关卡与内容管线。编辑器导出的关卡是一份带版本号的 JSON:小岛生成参数
// (种子 / 尺寸 / 密度)+ 出生点 + 手工摆件。运行时的加载器只做三件事:解析并校验、
// 用种子程序化生成小岛、把结果翻译成渲染器能消费的实例列表。全部确定性、无随机。

import { assembleFrame, type SceneInstance } from '../render/app/renderer-assembly.ts';
import { hashUnit } from '../render/ui2d/sprite-batch.ts';

export interface PropDef { readonly kind: string; readonly x: number; readonly z: number }

export interface LevelDoc {
  readonly version: number;
  readonly island: { readonly seed: number; readonly size: number; readonly glimmerCount: number; readonly treeCount: number };
  readonly spawn: { readonly x: number; readonly z: number };
  readonly props: readonly PropDef[];
}

export interface Vec2 { readonly x: number; readonly z: number }

export interface Level {
  readonly spawn: Vec2;
  readonly glimmers: readonly Vec2[];
  readonly trees: readonly Vec2[];
  readonly props: readonly PropDef[];
}

export const LEVEL_VERSION = 1;

/** 解析并校验编辑器导出的关卡。越界或未知版本一律抛错,绝不静默降级。 */
export function parseLevel(text: string): LevelDoc {
  let raw: unknown;
  try { raw = JSON.parse(text); } catch { throw new Error('关卡不是合法 JSON'); }
  const doc = raw as Partial<LevelDoc>;
  if (doc.version !== LEVEL_VERSION) {
    throw new Error(`不支持的关卡版本:${String(doc.version)}(期望 ${LEVEL_VERSION})`);
  }
  const isl = doc.island;
  if (isl === undefined || !(isl.size > 0)) throw new Error('island.size 必须为正');
  if (!Number.isInteger(isl.seed)) throw new Error('island.seed 必须是整数');
  if (isl.glimmerCount < 0 || isl.treeCount < 0) throw new Error('生成数量不能为负');
  const half = isl.size / 2;
  if (doc.spawn === undefined || Math.abs(doc.spawn.x) > half || Math.abs(doc.spawn.z) > half) {
    throw new Error('spawn 必须落在小岛范围内');
  }
  for (const p of doc.props ?? []) {
    if (typeof p.kind !== 'string' || p.kind.length === 0) throw new Error('prop.kind 不能为空');
    if (Math.abs(p.x) > half || Math.abs(p.z) > half) throw new Error(`prop ${p.kind} 越界`);
  }
  return doc as LevelDoc;
}

/** 程序化生成:用种子把点铺在抖动网格上,同种子必然得到同一组坐标。 */
export function scatter(seed: number, size: number, count: number, salt: number): Vec2[] {
  if (count === 0) return [];
  const half = size / 2;
  const cols = Math.ceil(Math.sqrt(count));
  const cell = size / cols;
  const out: Vec2[] = [];
  for (let i = 0; i < count; i++) {
    const cx = i % cols;
    const cz = Math.floor(i / cols);
    // 抖动限制在格子的 60% 内,保证不跨格、不重叠
    const jx = (hashUnit(i, seed ^ salt) - 0.5) * cell * 0.6;
    const jz = (hashUnit(i, seed ^ (salt + 101)) - 0.5) * cell * 0.6;
    const x = Math.max(-half, Math.min(half, -half + cell * (cx + 0.5) + jx));
    const z = Math.max(-half, Math.min(half, -half + cell * (cz + 0.5) + jz));
    out.push({ x, z });
  }
  return out;
}

/** 由关卡文档构建运行时关卡:手工摆件 + 程序化生成的微光与树木。 */
export function buildLevel(doc: LevelDoc): Level {
  const { seed, size, glimmerCount, treeCount } = doc.island;
  return {
    spawn: { x: doc.spawn.x, z: doc.spawn.z },
    glimmers: scatter(seed, size, glimmerCount, 0x9e37),
    trees: scatter(seed, size, treeCount, 0x51ed),
    props: doc.props,
  };
}

/** 把关卡翻译成渲染器实例:微光 / 树 / 摆件各有自己的 meshId。 */
export function toSceneInstances(level: Level): SceneInstance[] {
  const out: SceneInstance[] = [];
  let id = 0;
  const push = (p: Vec2, meshId: number): void => {
    out.push({ id: id++, meshId, materialId: 0, position: [p.x, 0, p.z], scale: 0.8, alpha: false, visible: true });
  };
  for (const g of level.glimmers) push(g, 1);
  for (const t of level.trees) push(t, 2);
  for (const p of level.props) push({ x: p.x, z: p.z }, p.kind === 'chest' ? 3 : 4);
  return out;
}

/** 俯视正交投影:把 [-extent,extent] 映射到裁剪空间,列主序 4x4。 */
export function orthoViewProj(extent: number): number[] {
  return [1 / extent, 0, 0, 0, 0, 0, -1 / 20, 0, 0, 1 / extent, 0, 0, 0, 0, 1, 1];
}

export interface LevelStats { readonly glimmers: number; readonly trees: number; readonly props: number; readonly drawn: number; readonly culled: number }

/** 装配一帧:把关卡实例送进剔除 + 排序,返回统计。 */
export function assembleLevel(level: Level, extent: number): LevelStats {
  const frame = assembleFrame(toSceneInstances(level), orthoViewProj(extent), [0, 20, 0], 100);
  return {
    glimmers: level.glimmers.length, trees: level.trees.length, props: level.props.length,
    drawn: frame.stats.drawn, culled: frame.stats.culled,
  };
}

/** 示例关卡:编辑器导出的样子。 */
export const SAMPLE_LEVEL: LevelDoc = {
  version: LEVEL_VERSION,
  island: { seed: 20261010, size: 20, glimmerCount: 9, treeCount: 12 },
  spawn: { x: 0, z: 0 },
  props: [{ kind: 'chest', x: -7, z: 6 }, { kind: 'sign', x: 5, z: -6 }],
};

if ((import.meta as { main?: boolean }).main) {
  const doc = parseLevel(JSON.stringify(SAMPLE_LEVEL));
  const stats = assembleLevel(buildLevel(doc), 10);
  console.log(`[level] seed=${doc.island.seed} size=${doc.island.size}`);
  console.log(`[level] glimmers=${stats.glimmers} trees=${stats.trees} props=${stats.props}`);
  console.log(`[level] drawn=${stats.drawn} culled=${stats.culled}`);
  const a = buildLevel(doc).glimmers.map((g) => `${g.x.toFixed(3)},${g.z.toFixed(3)}`).join(' ');
  const b = buildLevel(doc).glimmers.map((g) => `${g.x.toFixed(3)},${g.z.toFixed(3)}`).join(' ');
  console.log(`[level] deterministic=${a === b}`);
}

本机 node src/game/level-pipeline.ts 的实跑输出:

[level] seed=20261010 size=20
[level] glimmers=9 trees=12 props=2
[level] drawn=23 culled=0
[level] deterministic=true

种子 20261010 生成 9 个微光 + 12 棵树,加上 2 个手工摆件,共 23 个实例;extent=10 的正交盒能装下整座岛,所以 culled=0。把正交盒收窄,就能看到剔除真的在起作用:

extent 12 -> drawn=23 culled=0
extent 10 -> drawn=23 culled=0
extent  8 -> drawn=22 culled=1
extent  6 -> drawn=12 culled=11

配套单测覆盖解析校验(未知版本 / 出生点越界 / 摆件越界各一例)、程序化生成(同种子相同 / 不同种子不同 / 全部落在范围内)、以及装配统计,共 9 条断言,npx vitest run src/game/level-pipeline.test.ts 全过。

注意 extent=6 那一行:23 个实例只剩 12 个进绘制列表,11 个被剔除。这个数字本身不重要,重要的是它可复现——同一个种子、同一个 extent,任何机器上跑出来都是 drawn=12 culled=11。内容管线的全部价值就建立在「同一份数据在任何机器上产出同一个世界」之上。

为什么不直接做一个关卡编辑器

看到这里很容易想「顺手把编辑器也做了」。《微光岛》刻意不做,理由有三条:

  • 编辑器是另一条产品线。卷三用了整整一章讲编辑器与运行时的关系、文档模型、命令与撤销。把编辑器塞进一个垂直切片,只会让切片失去「小到能一眼看完」这个优点。
  • 内容格式不依赖编辑器。本节验证的是「文件 → 运行时」这条边,它可以用手写 JSON 验证,也可以用编辑器验证。先用最小的方式把这条边钉死,将来接任何编辑器都不用改运行时。
  • 版本迁移先于工具。有了 version 字段和加载期校验,将来编辑器升级格式时,运行时的迁移逻辑可以先行到位,而不必等编辑器一起发版。

一句话:垂直切片验证的是管线,不是工具。管线通了,工具随时可以换。

小结

内容管线的价值在于「把内容从代码里赶出去」:关卡是数据、生成靠种子、校验在加载期完成。这样引擎才能一边保持零随机、一边接纳外部内容。它也是切片能持续长大的前提——以后加一张新岛,只需要多一个 JSON 文件,而不是多一段 if 判断。

下一节我们回到画面,把本节产出的关卡真正渲染出来,并给玩法加上震动、粒子、音效与转场这些打磨。

回看这三节的关系:7.1 让状态能动、7.2 让内容能进、7.3 让反馈能出。三者合起来,才是一个引擎垂直切片该有的完整闭环——从数据进来,到状态推进,再到画面出去。任何一环缺失,切片都只能验证引擎的一部分;三环齐备,才能声称「这台引擎真的跑起来了」。

阅读导航:上一节:7.1 玩法装配:收集、昼夜、存档 · 下一节:7.3 打磨与表现 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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