Skip to content

在浏览器里造开放世界,第 15 部分:先换掉基线,再同步它

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

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

前十四部分覆盖了 spike 1 到 30。那一段以一套能实时雕刻的地形系统收尾,外加一个能在上面走、滑翔、坠落的角色。这一部分从 spike 31 接着讲,而我们要做的第一个决定不是技术上的,是怎么处理那一堆 spike 代码。

"替换,不要回迁"的决定

我们有 30 个独立 HTML 文件,每个证明一个孤立概念,集成度为零。生产的 world/client/ 还是旧那套栈:WebGL、一张简单的 heightmap、一个 75 行的角色控制器、一个有九种消息类型的 MessagePack 协议。没有编辑、没有 WebGPU、没有材质、没有植被。

显然的方案是把 spike 成果一个一个回迁进那份生产代码。我们把它扔了。Spike 30 的地形、物理、材质、植被、镜头都已经比 world/client/ 从来达到过的都好。回迁进旧的 WebGL 代码意味着一路都在跟它较劲。所以我们做了决定:用最成功的那个 spike 替换世界实现,然后往前建。Spike 30 成了新基线,world/client/ 成了死代码。

这重新框定了剩下的工作。要从"很棒的单人技术 demo"走到"产品",我们需要多人、持久化、无限世界流送、还有物体放置。多人地形同步先做,因为它是那个逼着你定架构的。它回答的问题问起来简单、做错了代价高:当玩家 A 雕刻时,到底有什么东西在网络上传?

笔刷参数重放,不是像素同步

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

在写一行网络代码之前,我们先精确追了一遍一次笔刷描边都做了什么。heightmap 笔刷在 CPU 的 Float32Array 里绕光标走一圈半径,套一个 smoothstep 衰减,然后做加、减、平滑或压平之一。SDF 笔刷在 3D 里对一个体素球做同样的事。两条路径都是纯 CPU 数组运算。循环里没有 GPU 计算、没有随机性、没有不确定的浮点。同一个输入数组加同一套参数,在每台机器上都等于同一个输出。

这就是全部的窍门。我们不发被编辑过的地形。我们发笔刷参数,每个描边 tick 56 字节,每个客户端重放同一个确定性函数。同步协议有四种消息类型:对端发现、笔刷消息 {op, wx, wy, wz, radius, strength, flattenTarget}、还有一个 20Hz 的玩家位置消息。

在这个 spike 里我们干脆跳过服务器,用了 BroadcastChannel,那是浏览器给同源跨标签页消息用的 API。开两个标签页,它们就能通话,零基础设施。这把同步问题从延迟、鉴权、Durable Object 接线里隔离了出来。如果参数重放能在标签页之间收敛,它在 WebSocket 上也会收敛。

重放唯一可能发散的地方是依赖顺序的操作。抬升和下降是可交换的,所以 val + strength * falloff 落点和谁先应用无关。平滑和压平要读相邻值,所以两个客户端在同一瞬间平滑完全相同的点,每个 tick 可能漂出零点几毫米。实际上这从来没触发过,而生产里的修法已经很明显了:把编辑路由进 DO,让它分配一个单调递增的序号,在客户端乐观地应用,如果权威序号不一致就纠正顺序。经典的乐观并发,反正 DO 本来就是一个天然的串行化点。

一直消失的对端胶囊

编辑第一次就同步上了。远端玩家的胶囊没有。它在另一个标签页里时有时无地闪,花了三个各自独立的 bug 才让它稳定显示。

胶囊在世界原点出生,那里埋在地形下面,因为 join 消息在任何位置数据之前到达。修法:先让它隐藏,在第一个位置更新时再显示。位置广播原本在渲染循环里,而 Chrome 会对失焦标签页的 requestAnimationFrame 限速,所以另一个标签页的过期检查会把对端收掉,下一条消息又把它重建。修法:把广播移到 setInterval,它对可见标签页不限速。还有过期超时设得太激进,5 秒,任何 GC 暂停都能触发它。修法:提到 30 秒,正常关闭时依靠干净的 leave 消息。

持久化和迟到加入,同一种格式

我们把持久化叠进同一个 spike,而不是再开一个新的,因为不管目的地是 IndexedDB 还是另一个标签页,序列化格式都一样。一份快照是完整的 heightmap(一个 129×129 的 Float32Array,约 66 KB),加上只有被编辑过的 SDF chunk(每个 653,约 1.1 MB),加上锁进 marching-cubes 模式的 chunk ID 列表。一个去抖的保存在最后一次编辑两秒后写入 IndexedDB。加载时,程序化地形同步生成,然后保存的状态在第一帧有意义的画面之前覆盖它。迟到加入复用完全相同的字节:当一个新标签页加入时,一个有编辑的现有标签页序列化它的状态,定向发给新来者,五分钟前你雕的那座山就出现在他们屏幕上。

第一次持久化测试翻出一个不错的顺序 bug。草是在初始化时用程序化高度同步撒布的,但 IndexedDB 还原是异步的、会在之后覆盖 heightmap,于是每根草要么悬空要么沉下去。修法是一个 refreshAllGrass() 过程,对每个实例下方的高度重新采样,把现在落在坏坡度或坏海拔上的草藏掉。同一个函数同时服务加载和迟到加入。

坡度连续剧

被雕过的地形比平滑的程序化基线更崎岖,它暴露出三个旧地形永远碰不到的物理 bug。直直往上坡走,胶囊会往侧面滑。原因是一个本想让移动贴着地面切线的速度投影,但写的时候只用了法线的水平分量。在一个法线为 (0.3,0.9,0.3) 的斜对角坡上,往北走会凭空注入侧向速度。地面吸附本来就让玩家贴在表面上,所以我们干脆把那个投影删了。

漂移仍然存在,来自第二个源头。SDF 碰撞探测把身体沿梯度方向按穿透深度推出去。在任何坡上梯度都有水平分量,所以一个 15° 坡上 0.1 米的穿透每步往侧面推大约 0.026 米,在 120Hz 下这大概是 3 m/s 的隐形漂移。修法:按坡度拆分响应。在可行走地面上(grady 高于行走阈值)推力只往竖直方向。在墙和悬崖上保留完整的 3D 推力,因为那正是你想被弹开的地方。我们还把可行走上限从 cos45° 提到 cos60°,让笔刷造出来的山坡像《旷野之息》里那样可攀爬。

第三个 bug 让胶囊在 chunk 边界冻住,因为碰撞探测只采样单个 chunk 的 SDF,当探测越进相邻 chunk 时拿到了"深处是空气"的哨兵值。修法是 sdfSampleWorld(wx, wy, wz)sdfGradientWorld(...),它们为任意世界位置找到正确的 chunk,并在没有 SDF 的地方回退到 heightmap 距离估算。SDF 到 heightmap 的碰撞过渡现在是连续的了。

水让世界完整

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

到这里为止每个 spike 都是"陆地在水之上"。Spike 32 加了一片海洋,连带一个新的移动动词。我们把水位设在 22,地形范围大致是 8 到 58,这会淹掉低洼的山谷,在岸线留下海滩,还留出大量干地供玩。

水面是一个用 TSL 搭的 MeshStandardNodeMaterial,和地形用的是同一套节点路子。三道不同频率、互相叠加的正弦波位移顶点,表面法线来自这些波的解析余弦导数,而不是来自 mesh 法线。颜色用一个深度估算 (levely+wave)×0.12 钳到 [0,1],从绿松石色的浅水混到深青色的深水,那个估算在岸线接近零的地方升起泡沫,alpha 跟着深度走,所以浅水看起来透明、深水看起来近乎不透明。

游泳是一个浮力弹簧。当脚降到水位以下、且身体中心在表面一个胶囊半高之内时,玩家进入游泳模式。一个弹簧把身体拉向表面下方一点的目标,浮力常数 12、水阻尼 4,玩家露着头稳稳地浮着,不会震荡。游泳速度比走路慢,加速飘、还带拖曳,靠近水面起跳会以正常起跳速度的 60% 把你弹出水面,入水时把向下速度封在 -5 m/s,这样你不会一头扎下去。水下地形碰撞仍然在跑,所以湖床高出游泳目标的地方你能走。游泳标志搭在位置广播里一起传,所以对端能看见你在游,当镜头沉到水面以下时一个 HTML 渐变叠层给画面染色。

本章涉及的技术

确定性笔刷参数重放。 与其流送被编辑过的地形,每个客户端只发笔刷参数并重放同一个 CPU 函数。这能成立是因为 heightmap 和 SDF 笔刷都是纯 Float32Array 运算,没有随机性也没有 GPU 不确定性,所以相同输入到处都产生比特相同的输出。负载是每个描边 tick 56 字节。可交换操作(抬升、下降)不论顺序都收敛,而读相邻值的操作(平滑、压平)需要一个串行化点来保证收敛,这由生产里的 Durable Object 通过单调序号提供。

BroadcastChannel 作为 WebSocket 的替身。 一个给同源跨标签页消息用的浏览器 API,零服务器。这里用它把同步协议从网络延迟和鉴权里隔离出来测试。序列化格式(原始 Float32Array heightmap 加被编辑过的 SDF chunk 加 MC 锁定的 chunk ID)就是 IndexedDB 持久化和迟到加入状态传输用的同一份字节,所以一种格式覆盖三件事。

按坡度拆分的 SDF 碰撞响应。 当胶囊探测穿进体积地形时,朴素的修法是把身体沿 SDF 梯度按穿透深度推出去。在坡上那个梯度有水平分量,会注入侧向漂移。拆分响应,让可行走表面(grady 高于阈值)只受竖直推力,而陡峭表面保留完整的 3D 推力加速度投影,这去掉了漂移又没丢掉墙体碰撞。看 SDF 地形碰撞

带解析波法线的 TSL 水。 海洋是一个节点材质,它的顶点被三道求和的正弦波位移。表面法线不是位移后重新计算 mesh 法线,而是从波函数的余弦导数解析地推导出来,这更省、还避开了粗网格上有限差分法线的瑕疵。深度驱动的颜色、岸线泡沫、深度驱动的透明度都基于同一个深度估算。

浮力弹簧式游泳。 游泳物理把身体建模成一个被拉向表面下方一点的目标的阻尼弹簧。浮力常数 12、阻尼 4,玩家稳定地停在水面不震荡。一组不同的移动常数(更慢的速度、飘的加速、重的拖曳)让游泳和走路手感不同,而已有的胶囊对地形碰撞在水下继续工作。


第 15 部分,共 29 部分。 上一篇:第 14 部分 - 世界活了 下一篇:第 16 部分 - 为一个持续生长的世界做结构 系列导览:/zh-CN/blog/2026-02-25-open-world-browser-series-guide