Skip to content

在浏览器里造开放世界,第 16 部分:为一个持续生长的世界做结构

作者:Oleg Sidorkin,Cinevva CTO 和联合创始人

刚看到?看系列导览。那里解释了 spike 是什么,并链接了所有部分。

第 13 部分起每个 spike 都按同一个套路来:复制上一个巨石,加一个功能。到spike 32结束时那个巨石已经是单个 <script type="module"> 里 6285 行的 index.html。代码搜索一片嘈杂,找到该往哪加功能比写它还久,任何架构改动都得动一个大到没法在脑子里 diff 的文件。在加下一个功能之前,我们先还了结构的债。

在零行为变化下拆掉巨石

在新标签页打开 Spike 33 ↗ · 看源码

约束很严:每一次拆分都必须是纯重构,不是重新设计。巨石变成了 19 个 .mjs 文件加一个 151 行的宿主 shell。顶层模块管 scene、water、grass、character、physics、multiplayer、UI,一个 wgsl.mjs 把每一段 WGSL 源字符串作为 GPU 真相的唯一来源握着,还有一个 terrain/ 子树管 heightmap、SDF、chunk、GPU buffer 包装、笔刷、LOD 和持久化。总代码量出来是 6555 行,基本上就是巨石加上 import 模板代码。体量净变化为零,可导航性变化很大。

然后页面加载成了黑屏。两条报错,两个不相关的根因。第一个是 WebGPU 抱怨一个零字节的 buffer 绑定。在巨石里,SDF 笔刷 buffer 是在第一个 marching-cubes chunk 出现时惰性分配的,而绑定组工厂恰好后跑,在 buffer 已存在之后。把 terrain/gpu.mjsterrain/brush.mjs 拆出来重排了模块求值,于是工厂现在先跑,试图绑定一个 null 占位。修法是把绑定组创建推迟到第一次 dispatch,用一个 getOrCreateBindGroup(chunk) 辅助函数。"一上来就把所有东西都建好"这个模式是巨石单一初始化路径的产物。

第二条消息是个看起来吓人的 FBX 骨架警告,结果是个误导。它从 spike 25 起就一直在打印,而且无害。角色之所以不见,只是因为第一个 bug 连锁了:每次计算 dispatch 都抛错,heightmap 从没被写入,高度采样返回 0,角色在原点出生然后掉穿了世界。修好那个 buffer,角色就动得好好的,连警告一起。

这才是重构真正的教训。巨石把每一个"X 必须在 Y 被建起来之前存在"的关系藏在自上而下的脚本顺序里。模块化打乱了那个顺序,浮出了另外三个潜伏的顺序 bug:草在 heightmap 上传之前撒布、水面在环境贴图解码完成之前加入、持久化加载在第一帧之后才完成。三个都是一行修复,没有一个会在不拆分的情况下被抓到。

一百个道具,什么都没撑大

在新标签页打开 Spike 34 ↗ · 看源码

Spike 34 是检验那套结构有没有回报的测试。目标是一个第一人称调色板,从一个 CC0 模型包里放树、岩、灌木、蘑菇和路径,带地形感知对齐、持久化、多人同步和物理碰撞体,全程不用离开操作。每一行新代码都落在 src/props/ 下面五个新文件里,没有一个现有模块增长超过十行接线代码。

资源这条线绕了一段值得记下来的弯路,因为这种事能吃掉一整天。我们从 Quaternius 的 Ultimate Nature 包开始,那是一个没有内嵌纹理的 FBX 库。FBX 材质以 MeshPhong 形式发布、没有 map,所以我们手工接了一张材质名到 PNG 的表,把 Phong 转成 Standard,再手动设色彩空间。大约 30% 的材质没有匹配的 PNG,还有几个名字在相似的树之间含糊不清。第二个 FBX 包有同样的缺口。修法不是更多的映射表,而是一个作得更好的包:Quaternius 的 Stylized Nature MegaKit 发布了 116 个完整的 glTF,带内嵌的 PBR 材质和烘焙好的法线。把 FBXLoader 换成 GLTFLoader 删掉了厘米到米的缩放、纹理表和 Phong 转换,把 library.mjs 缩了大约 80 行。结论是:带 PBR 的 glTF 是发布它的 CC0 包的正确管线,而带手工纹理映射的 FBX 是两倍的代码、一半的质量。

glTF 这条路也带来了几个尖角。调色板要渲 116 个缩略图,而 WebGPU 的 canvas.toDataURL() 对一个 GPUCanvasContext 表面返回空白,所以缩略图渲进一个 RenderTarget,用 readRenderTargetPixelsAsync 读回,再 blit 进一个 2D canvas,注意 WebGPU 的 256 字节行对齐。幽灵预览把每个材质克隆出来染成绿色,这在 material 是数组的 mesh 上挂了,用一个 Array.isArray 分支修好。还有那些发布时约 200 MB 的 16 位法线贴图,做了个一次性的 mogrify -depth 8 降到约 32 MB,视觉上没区别,反正浏览器在上传时本来就会降采样。

当被渲染的几何体只存在于 GPU 上

最有教益的 bug 是幽灵预览随光标移动时以 1 到 2 米的步长跳。地形 mesh 把顶点位置存在一个 StorageBufferAttribute 里,因为计算管线直接在 GPU 上写它们,所以 three.js 的 CPU Raycaster 看不见它们、返回空。回退是对解析 heightmap 做一个粗的 1.5 米光线步进,而那个固定步长就是用户看到的栅格。我们把它换成了自适应步进:高出表面很多时步长 2.5 米,离它 5 米内缩到 0.4 米,然后在 (rayyterrainy) 的符号翻转处二分 14 次。这在大约 30 个宽步加上每次投射 14 次二分内达到亚毫米精度。当被渲染的几何体只活在 GPU 上时,别跟 raycaster 较劲,对解析源做步进。

在编辑中也保持诚实的碰撞体

我们选了基本体代理而不是凸包或 mesh 碰撞体。Quaternius 道具圆鼓鼓、低面、没有有意义的凹陷,所以凸包大约是 50 倍的代码、10 倍的运行时开销,换来一样的玩法。每个道具从它的包围盒退化成一个形状:树和仙人掌退成一个竖直胶囊,岩石退成一个球,原木退成一个沿长轴的水平胶囊,装饰性的灌木和花退成什么都没有。岩和原木可行走(只往竖直推,所以你能站上去),树和仙人掌是阻挡的(完整 3D 推,所以你爬不上树干)。一个 8 米的空间哈希把每帧测试限制在玩家的 3×3 邻域,通常是零到六个道具。

两个设计决定让系统保持一致。地形对齐是一个清单标志,不是硬编码在代码里的类别枚举,所以幽灵预览和提交后的放置读的是同一个 placement.alignToTerrain 值,不会互相矛盾。而已放置的道具通过一个辅助函数对地形编辑作出反应:一次笔刷描边之后(本地的或从对端重放来的),refreshPlacementsInRadius 对受影响圆盘内每个道具下方的地面重新采样、重新应用对齐、重新推导碰撞体端点。在一棵树下雕一座山,树就跟着升上去。持久化和多人完全复用 spike 31 的模式,存一个扁平列表 {uid, propId, x, y, z, rotY, scale},并在 BroadcastChannel 上镜像放置、移除和微调事件。

本章涉及的技术

WebGPU 下的 ES 模块分解。 把一个巨石式的 <script type="module"> 拆成裸路径的 .mjs import,当模块以静态资源形式提供时不需要打包器,three.js TSL 跨模块边界也工作良好。隐藏的代价是初始化顺序:巨石把"先建 X 再建 Y"编码在自上而下的脚本顺序里,而模块按 import 顺序求值,可能在 GPU 绑定组工厂的 buffer 存在之前就跑它。修法模式是惰性初始化(首次使用时 getOrCreate...)和 await 正确的 promise,而不是依赖声明顺序。

带内嵌 PBR 的 glTF vs 带手工映射的 FBX。 glTF 以米发布、引用自己的纹理、直接给出 MeshStandardMaterial,所以一个作为 glTF 制作的 CC0 包能径直接进 PBR 管线。没有纹理绑定元数据的 FBX 包需要一张手工维护的材质名到 PNG 的表,每次包更新它都漂移,外加一个 Phong 到 Standard 的转换和手动色彩空间标注。一个植被安全网把没有 alphaTesttransparent 材质提升成 alphaTest: 0.5 的 cutout 卡片,让它们在不透明几何体后面正确排序。

WebGPU 离屏缩略图。 canvas.toDataURL() 对一个 GPUCanvasContext 支持的 canvas 返回空白,因为从一个呈现表面回到 2D 上下文没有通路。渲进一个 RenderTarget、用 readRenderTargetPixelsAsync 读像素、再 blit 进 2D canvas 是可行的,只要 blit 按 WebGPU 的 256 字节对齐读回步长来走。结果缓存在 localStorage 里一个带版本号的键下,所以包变化会让旧的渲染失效。

对解析 heightmap 的自适应光线步进。 当地形顶点活在一个 GPU StorageBufferAttribute 里时,CPU raycaster 看不见它们。对解析高度函数步进,离表面远时用大步长、近时用小步长,在 (rayyterrainy) 的符号翻转处做二分搜索精修,能在有界的采样数内给出亚毫米光标精度。同一个原语既驱动笔刷光标也驱动道具幽灵。

带空间哈希的基本体胶囊碰撞。 每个道具从它的包围盒按类别退化成一个胶囊或球,记成 {kind, walkable, radius, p1, p2},并注册进它重叠的每一个 8 米哈希桶。每帧玩家只测试自己 3×3 桶邻域里的道具,每个做一次胶囊对胶囊求解。可行走代理(岩、原木)只受竖直推力,阻挡代理(树)受完整 3D 推力。这建立在 SDF 地形碰撞的胶囊数学之上。


第 16 部分,共 29 部分。 上一篇:第 15 部分 - 先换掉基线,再同步它 下一篇:第 17 部分 - 不用重定向的动画,和一个实时资源搜索 系列导览:/zh-CN/blog/2026-02-25-open-world-browser-series-guide