在浏览器里造开放世界,第 24 部分:保存一个世界,和你能看见的风
作者:Oleg Sidorkin,Cinevva CTO 和联合创始人
刚看到?看系列导览。那里解释了 spike 是什么,并链接了所有部分。
第 23 部分把人放进了世界、给了他们声音。这一部分讲让世界记住人们对它做了什么,以及在没人碰它时也让它显得活着。Spike 47 是持久化:一个创作者雕刻地形、摆放道具,那些编辑能熬过重新加载、同步到其他每个 peer,并在两个人同时编辑时干净地仲裁。Spike 49 是风:移植一个风格化自然资源包的植被着色器,让树、灌木和草按美术意图运动,这后来变成了一场和着色器编译器的搏斗,多过和数学的搏斗。
一个会记住的世界
设置是一个共享的创作会话。玩家在一个世界里行走,用点击摆放道具,用右键移除它们,并通过拖动一支笔刷来雕刻地面,抬高或降低高度图、或者穿过把一个 chunk 提升为 marching cubes 的 SDF 地形挖出体积洞穴。他们做的一切都持久化在一个 Cloudflare WorldChunkDO 里,一个服务器权威的 Durable Object,由它自己的 SQLite 存储支撑。客户端不直接写状态。它们发送意图,DO 仲裁并广播结果,DO 是唯一的真值来源,所以一个刚加入的人拿到一份快照,落进的世界和其他每个人看见的一模一样。
两个持久化细节挣得了它们的饭碗。地形不是作为一份在加入时重放的事件日志来存的;它是作为逐 chunk 的二进制 blob 来存的,这些 blob 就是真值来源,在落笔结束时上传,所以一个加入者直接加载已提交的字节,而不是重跑成千上万次笔刷采样。而那些 blob 是一个 chunk 一个存储键、而不是一大行,因为单个 SDF chunk 是 168 KB,而 DO 有每行 2 MB 的上限。一个 chunk 索引追踪哪些键存在,让 DO 能在唤醒时重新水合整张地图。身份在客户端用两种存储处理:玩家 id 住在 sessionStorage 里,所以两个标签页是两个不同的 peer,而不是一个在 DO 的玩家映射里把自己覆盖掉的 peer;而显示名住在 localStorage 里,所以在一个标签页里改名会跨所有标签页带过去。
让并发编辑收敛
多人创作真正难的部分是:两个人在同一瞬间编辑重叠的地面时会发生什么。这个 spike 按编辑的代数把它们拆开。加法操作,高度图上的抬高和降低、SDF 上的加和减,是可交换的:按任意顺序施加都落到同一个结果,所以它们走一条乐观-时间戳的路径,每个客户端本地施加并发出时间戳,DO 把它广播给所有人、无需协调。顺序真的无所谓,所以没什么要协调的。
依赖顺序的操作,平滑和压平,是有意思的那种情况。最初的设计给它们一个区域锁:客户端在按下指针时请求一个锁,DO 授予或拒绝,客户端在按住期间缓冲采样,在抬起指针时 DO 把整个笔触原子地施加。它能用,但它是一个独立的协议,带自己的锁 TTL 和授予/拒绝的往返。替换它的更干净的答案是一个预先算好的 delta:发起者本地跑平滑或压平笔刷,然后发出得到的逐单元 delta 列表,每个 peer 只是把那些 delta 加到自己的单元上,不重新推导任何东西。这就把一个依赖顺序的操作变成了可交换的操作,办法是在源头把它的结果冻住,于是整个编辑系统跑在一个统一的可交换协议上,收敛完全相同、完全没有锁。道具锁因为另一个原因留了下来:逐记录的锁定取代了只有所有者能删除,所以任何 peer 都能删除任何道具,除非有人锁了它,且只有锁定者能清除它。撤销和重做的工作方式是:让客户端在摆放道具之前先给它的 id 命名,这样它在服务器回声之前就知道 id,能确定性地反转自己的动作。Hibernating WebSocket 始终让一个空闲房间免费,正是上一部分里让 avatar 中继便宜的那个属性。
忠实移植的风,然后开始搏斗
Spike 49 拿 Quaternius 的 Stylized Nature MegaKit,把它的风移植到我们的技术栈。这个包出货了它的源 Godot 着色器,四个,而正确的做法是忠实翻译、而非重新发明。树皮(Bark)有一个空的顶点函数,所以树干是刚性的;早先一次程序化遮罩的尝试让树干在摆动,修复办法就是干脆完全不给树皮施加风。叶子(Leaves)从一个三角脉冲哈希得到逐顶点的混乱摇摆,按高度遮罩,让树冠运动而基部站稳。基础植被(Base foliage)在世界空间里得到一个噪声调制的 sin/cos 摆动。草(Grass)是基础植被加上一个风线(wind-line)抖动,它通过一个幂曲线采样一张滚动的噪声纹理,让只有纹理的亮带做贡献,这产出了那些在草地上行进、可见的涟漪。分发遵循这个包自己的材质命名约定,所以一个叫 Leaves_Birch 的材质路由到叶子路径、Grass_Common 路由到草路径,不用猜。
移植中的一个惊喜是:叶子的颜色不在纹理里。Quaternius 完全用一个垂直渐变和一个 Fresnel 边缘来创作叶子外观:反照率是一个从树冠底部那个额外颜色到顶部叶子颜色的混合,按高度键控,外加一个次表面散射(subsurface scattering)色调作为发光、由一个 heightFactor 顶点属性、而非原始的本地 Y,在加载时按每个叶子组从世界空间 Y 归一化,这样渐变和风遮罩都正确表现,无论 FBX 导入把每个 mesh 的本地坐标轴旋转成什么样。Godot 的颜色常量被标为 sRGB,在着色器看到它们之前转成线性,所以移植做同样的转换,而不是把那些明亮的 sRGB 数值当成线性喂进去、把植被洗白。
吃掉帧率的那次重编译
这之所以先作为一个 FBX 基线出货、把完整的风材质放进一个 .bak 文件搁置,是因为一个逐帧重编译 bug 把场景拖到了约 1 fps。把自定义 TSL 叠到 FBX 加载的材质上,会触发 Three.js 每帧重建着色器程序,needsUpdate 实际上卡在开着。诊断纪律是把每个植被材质剥回一个没有自定义节点的朴素带纹理 pass,看重编译循环是否还在。如果它停了,自定义图就是罪魁;如果它继续,起因就在上游的 FBX 材质设置或 Three.js 本身。让真正的着色器能回来的修复,是把每个逐材质的差异,叶子颜色、SSS 颜色、强度和混合,都绑定为 uniform,让所有叶子资源共用一个编译好的程序,而不是让编译器为每个独特的颜色组合发出一个全新的着色器、把编译队列搅得乱七八糟。
还有两块值得记下。草作为每个源文件一个 InstancedMesh 渲染,它的风在世界空间里计算,因为 sin 相位是用世界位置键控的。但位移必须在实例矩阵运行之前、在本地空间里施加,而 WGSL 没有 inverse() 可调。对于一个由平移、一个 Y 旋转和一个均匀缩放组成的实例矩阵,上 3×3 的逆就是它的转置除以缩放的平方,所以着色器把世界位移乘以转置后的模型矩阵、再除以矩阵第一列长度的平方,不用开平方根就恢复了缩放。在顶点变换重新施加矩阵之后,运动恰好按创作那样落在世界空间里,与每丛草的旋转或缩放无关。还有,每丛草跑一次逐顶点的 GPU 视锥剔除:它把实例中心投影到裁剪空间,如果它带余量地落在视锥外,就把每个顶点塌缩到本地原点,让每个三角形的三个顶点重合,光栅器丢掉这个退化三角形,屏外的草不发生任何片元、alpha-test 或阴影工作。这叠在 Three.js 本就做的粗糙逐 chunk 包围球剔除之上,用浮点 step 而非布尔构建,这样它直接乘进位置混合里。
本章涉及的技术
服务器权威的世界持久化。 一个 WorldChunkDO Durable Object 仲裁道具摆放和地形编辑,把逐 chunk 的二进制 blob 作为真值来源持久化(这样加入者加载已提交的字节、而非重放一份事件日志),并按一个 chunk 一个存储键来存,以待在 DO 每行 2 MB 的上限之下。玩家 id 住在 sessionStorage 里,让标签页是不同的 peer;显示名住在 localStorage 里,让改名跨标签页带过去。
可交换的编辑收敛。 加法地形操作(抬高/降低、SDF 加/减)是可交换的,走一条乐观-时间戳路径、无需协调。依赖顺序的操作(平滑、压平)被变成可交换的,办法是发送预先算好的逐单元 delta、而非获取一个区域锁,于是整个系统在一个统一协议下收敛、完全没有锁。逐记录的道具锁取代了只有所有者能删除,而客户端命名的对象 id 让确定性的撤销/重做在服务器回声到达之前就能进行。见 GPU 驱动的 LOD。
从 Godot 到 TSL 的忠实着色器移植。 Quaternius 的四个源风着色器逐行翻译:刚性树皮、按高度遮罩的叶子摆动、世界空间的植被摆动,以及带滚动风线抖动的草,由包的材质命名约定来分发。叶子颜色来自一个高度渐变加一个 Fresnel 驱动的 SSS 边缘、而非纹理,sRGB 创作常量被转成线性,让外观匹配参考渲染。
避免逐帧的着色器重编译。 把自定义 TSL 叠到 FBX 材质上可能把 needsUpdate 钉住、每帧重建程序,塌到约 1 fps。把每个逐材质的差异绑定为 uniform,让所有变体共用一个编译好的程序,而不是为每个独特的参数集发出一个全新的着色器。一个朴素带纹理的诊断 pass 能隔离出究竟是自定义图、还是上游设置才是起因。
实例化植被上的世界空间风。 草风在世界空间里计算,再用一个手推的逆(转置除以缩放平方)变换回本地,因为 WGSL 没有 inverse()。一次逐顶点的 GPU 视锥剔除把屏外的草丛塌缩成一个退化三角形,让没有片元或阴影工作运行,叠在 Three.js 的逐 chunk 包围球剔除之上。
第 24 部分,共 29 部分。 上一篇:第 23 部分 - 五十个 avatar 和房间里的一个声音 下一篇:第 25 部分 - 一具骨架,每套行头 系列导览:/zh-CN/blog/2026-02-25-open-world-browser-series-guide