世界流送
场景是整体加载的。在世界大到一台机器不愿意一次性常驻之前,这都是对的答案;越过那条线 之后,问题就不再是“什么被画出来”,而是**“什么存在”**。世界流送回答后者:cook 把作者 编排的世界切成方形格子,运行时一个格子之所以存在,是因为有东西靠近它;没有任何东西需要 它时,它就不再存在。
这里没有任何“隐藏”的把戏。离开的格子是被销毁的:它的实体、刚体、导航代理、资源引用 全部交回。这正是重点——只是被隐藏的地方仍然占内存、仍然会碰撞,也仍然允许一个谁都看不见 的敌人攻击玩家。
声明一个流送世界
Section titled “声明一个流送世界”两个组件,都在场景里编排:
// 挂在世界设置实体上:这个场景按 1000 单位的格子切分。world.insert(settings, StreamedWorld, { cellSize: 1000 });
// 挂在任何绝不允许被流送掉的东西上。world.insert(player, WorldPersistent, {});world.insert(camera, WorldPersistent, {});world.insert(sun, WorldPersistent, {});其余一切按它所在顶层实体的位置落入某个格子。没有 StreamedWorld 的场景不是流送
世界,加载方式和以前完全一样。
请求地方存在:流送源
Section titled “请求地方存在:流送源”一个格子之所以存在,是因为有源靠近它:
world.insert(player, WorldStreamingSource, { prefetchRadius: 3000, // 距离在此以内的格子被**准备好**,但不进入世界 loadRadius: 2000, // 距离在此以内的格子被带进来 unloadRadius: 3000, // 已常驻的格子在越过这个距离前一直保留});可以同时存在多个源,它们的请求是并集——只要任意一个源需要,格子就继续常驻。这正是 分屏、观战相机、过场相机和编辑器预览可以是同一类东西、而不是四个特例的原因。这里不会把 多个源折叠成一个焦点:折叠意味着最后写入的那个说了算,于是第二个相机出现的下一帧,它就 把第一个相机正站着的那片世界删掉了。
距离量到格子的包围盒,而不是中心点:按中心量,站在对角角落的源和隔了整整一格的源 读数相同,而网格的对角线正是“洞”出现的地方。
在被需要之前就准备好
Section titled “在被需要之前就准备好”落在 prefetchRadius 以内的格子会被准备:文档取回、预制体展开、资产获取——
而它的实体一个都还不存在。所以没有任何东西能观察到它:查询查不到、渲染器不画、
刚体不碰撞、里面的敌人不做任何决策,因为那里根本还没有东西。只有发布才创建实体,
而发布发生在某个源真正进入 loadRadius 的时候。
这大约值玩家二十毫秒。在 Estella 自己的 fixture 上,走进一个已准备好的地方要等 0.6ms;走进一个没准备的,还要连取回和解码一起等。
准备是推测,不是权威。为一个掉头走开的玩家准备好的格子会被丢弃、资产交回—— 绝不会因为“都准备好了”就塞进世界。而且一次准备完成时会重新问这个格子是否仍被 需要,不会拿开始时的答案当结论。
prefetchRadius 的取值永远不会低于 loadRadius;除此之外它和 unloadRadius
之间没有任何大小关系要求——它们回答的是两个不同方向的问题:多早开始准备,和
多晚才放手。
两个半径之间的带
Section titled “两个半径之间的带”格子在 loadRadius 处进来,直到越过 unloadRadius 才离开。两者之间的空隙就是它们都
必须存在的全部理由:否则一个站在边界上的源会每一帧都在实例化和销毁同一个地方。把
unloadRadius 设得比 loadRadius 明显更大——多出一半是个不错的起点。
cook 产出什么
Section titled “cook 产出什么”切分发生在 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 平面上切。一个格子是一根垂直柱体,所以一座塔的各层是同一个地方。
一个流送世界必须遵守的规则
Section titled “一个流送世界必须遵守的规则”子树是 residency 的最小单位。 只有顶层实体会被定位,所以即使一扇门的世界坐标落在 隔壁格子里,它也不会被从自己的房子里分出去。格子的包围盒会扩张到覆盖它的内容,因此悬在 边缘外的道具不会在边界处突然弹出。
硬实体引用不允许跨越 residency 边界。 同一个格子里的两个实体可以互相引用;持久世界 内部也可以;格子引用持久世界也可以,因为持久世界比每一个格子都活得久。而从一个格子指向 另一个格子、或者从持久世界指向格子内部——都会让构建失败,因为它们指向的东西 residency 有权删除。
Residency 拥有“存在”,不拥有“延续”。 卸载的格子会丢掉运行时状态,重新加载会按编排 内容重建。你走开时只剩 25 点血的敌人,回来时是满血的。真正留下来的是实体的编排身份, 以后的存档系统会建在它上面。
读取当前常驻状态
Section titled “读取当前常驻状态”const report = worldResidencyReport(app);report.residentCells; // ['main.cell_1_0', 'main.cell_2_0']report.cellEntityCounts; // 每个常驻格子当前拥有的实体数report.loadCount; // 启动以来带进来过多少个地方给的是计数而不是画面,因为“没有被画出来的格子”和“根本不存在的格子”,从相机看是一样的。
这一版不做什么
Section titled “这一版不做什么”格子通过游戏本来就在用的场景加载路径异步到达,但还没有优先级、预取和预算:一个大格子 到达时就是一次卡顿。没有 HLOD,所以不常驻的地方在远处根本没有任何表示。导航网格不流送 ——世界尺寸的 navmesh 一直常驻。纹理流送同样没有,世界原点也从不重定基准。