跳转到内容

世界流送

场景是整体加载的。在世界大到一台机器不愿意一次性常驻之前,这都是对的答案;越过那条线 之后,问题就不再是“什么被画出来”,而是**“什么存在”**。世界流送回答后者:cook 把作者 编排的世界切成方形格子,运行时一个格子之所以存在,是因为有东西靠近它;没有任何东西需要 它时,它就不再存在。

这里没有任何“隐藏”的把戏。离开的格子是被销毁的:它的实体、刚体、导航代理、资源引用 全部交回。这正是重点——只是被隐藏的地方仍然占内存、仍然会碰撞,也仍然允许一个谁都看不见 的敌人攻击玩家。

两个组件,都在场景里编排:

// 挂在世界设置实体上:这个场景按 1000 单位的格子切分。
world.insert(settings, StreamedWorld, { cellSize: 1000 });
// 挂在任何绝不允许被流送掉的东西上。
world.insert(player, WorldPersistent, {});
world.insert(camera, WorldPersistent, {});
world.insert(sun, WorldPersistent, {});

其余一切按它所在顶层实体的位置落入某个格子。没有 StreamedWorld 的场景不是流送 世界,加载方式和以前完全一样。

一个格子之所以存在,是因为有靠近它:

world.insert(player, WorldStreamingSource, {
prefetchRadius: 3000, // 距离在此以内的格子被**准备好**,但不进入世界
loadRadius: 2000, // 距离在此以内的格子被带进来
unloadRadius: 3000, // 已常驻的格子在越过这个距离前一直保留
});

可以同时存在多个源,它们的请求是并集——只要任意一个源需要,格子就继续常驻。这正是 分屏、观战相机、过场相机和编辑器预览可以是同一类东西、而不是四个特例的原因。这里不会把 多个源折叠成一个焦点:折叠意味着最后写入的那个说了算,于是第二个相机出现的下一帧,它就 把第一个相机正站着的那片世界删掉了。

距离量到格子的包围盒,而不是中心点:按中心量,站在对角角落的源和隔了整整一格的源 读数相同,而网格的对角线正是“洞”出现的地方。

落在 prefetchRadius 以内的格子会被准备:文档取回、预制体展开、资产获取—— 而它的实体一个都还不存在。所以没有任何东西能观察到它:查询查不到、渲染器不画、 刚体不碰撞、里面的敌人不做任何决策,因为那里根本还没有东西。只有发布才创建实体, 而发布发生在某个源真正进入 loadRadius 的时候。

这大约值玩家二十毫秒。在 Estella 自己的 fixture 上,走进一个已准备好的地方要等 0.6ms;走进一个没准备的,还要连取回和解码一起等。

准备是推测,不是权威。为一个掉头走开的玩家准备好的格子会被丢弃、资产交回—— 绝不会因为“都准备好了”就塞进世界。而且一次准备完成时会重新问这个格子是否仍被 需要,不会拿开始时的答案当结论。

prefetchRadius 的取值永远不会低于 loadRadius;除此之外它和 unloadRadius 之间没有任何大小关系要求——它们回答的是两个不同方向的问题:多早开始准备,和 多晚才放手。

格子在 loadRadius 处进来,直到越过 unloadRadius 才离开。两者之间的空隙就是它们都 必须存在的全部理由:否则一个站在边界上的源会每一帧都在实例化和销毁同一个地方。把 unloadRadius 设得比 loadRadius 明显更大——多出一半是个不错的起点。

切分发生在 cook 时,不在启动时:

main.esscene ──cook──▶ main.esscene 持久世界
world/main.world.json 清单
world/main.cell_0_0.json
world/main.cell_1_0.json ...

包体启动的入口场景,是把格子取走之后剩下的东西——residency 永远不会移除的那部分。格子 不是可切换的场景:它们是 residency 带进来的内容,游戏永远不会点名其中任何一个。

格子只在 XZ 平面上切。一个格子是一根垂直柱体,所以一座塔的各层是同一个地方。

子树是 residency 的最小单位。 只有顶层实体会被定位,所以即使一扇门的世界坐标落在 隔壁格子里,它也不会被从自己的房子里分出去。格子的包围盒会扩张到覆盖它的内容,因此悬在 边缘外的道具不会在边界处突然弹出。

硬实体引用不允许跨越 residency 边界。 同一个格子里的两个实体可以互相引用;持久世界 内部也可以;格子引用持久世界也可以,因为持久世界比每一个格子都活得久。而从一个格子指向 另一个格子、或者从持久世界指向格子内部——都会让构建失败,因为它们指向的东西 residency 有权删除。

Residency 拥有“存在”,不拥有“延续”。 卸载的格子会丢掉运行时状态,重新加载会按编排 内容重建。你走开时只剩 25 点血的敌人,回来时是满血的。真正留下来的是实体的编排身份, 以后的存档系统会建在它上面。

const report = worldResidencyReport(app);
report.residentCells; // ['main.cell_1_0', 'main.cell_2_0']
report.cellEntityCounts; // 每个常驻格子当前拥有的实体数
report.loadCount; // 启动以来带进来过多少个地方

给的是计数而不是画面,因为“没有被画出来的格子”和“根本不存在的格子”,从相机看是一样的。

格子通过游戏本来就在用的场景加载路径异步到达,但还没有优先级、预取和预算:一个大格子 到达时就是一次卡顿。没有 HLOD,所以不常驻的地方在远处根本没有任何表示。导航网格不流送 ——世界尺寸的 navmesh 一直常驻。纹理流送同样没有,世界原点也从不重定基准。