在浏览器里造开放世界,第 14 部分:世界活了
作者:Oleg Sidorkin,Cinevva CTO 和联合创始人
刚看到?看系列导览。那里解释了 spike 是什么,并链接了所有部分。
到第 13 部分我们已经能雕刻了。抬地、挖洞、磨平悬崖,全部实时,接缝完整。但世界看起来还是一个技术 demo。带调试色的平面着色、线框叠加、用 LOD 颜色区分的灰色几何体。你能编辑它。你感受不到它。
三个 spike 把这件事改了。架构没变。和第 13 部分完全相同的管线、buffer、接缝拼接。我们只是加上了让地形像一个地方的几层东西:会响应形状的表面、长在那些表面上的生命、还有走在上面的身体。
"技术上能工作"和"我想留在这里"之间的差距,结果出乎意料地小。
雕一面悬崖,看它变成岩石
Spike 28 问了一个很窄的问题:一个带 triplanar 映射的 4 层材质,能不能跑在计算着色器生成的地形上,并且不爆帧预算?答案是能,但有意思的是后面发生的事。
四张程序化纹理(草、岩、沙、雪),启动时用 FBM 噪声生成。没有外部文件、没有资源管线,就是数学加一个 DataTexture。材质权重来自表面本身:坡度和海拔。林线之下的平地长草。陡面是岩。低洼是沙。高峰是雪。权重按每个片元归一化,加起来始终是 1。Triplanar 映射处理 MC chunk 的 UV 投影,因为那里三角形没有有意义的 UV 坐标。
让我被打动的瞬间:用笔刷抬起地形造一个陡崖,在同一帧里岩石纹理就出现在新的崖面上。把它压回去,草就重新占领表面。材质并不知道有笔刷。它只是读世界位置和表面法线,和几何体被构建时用的是同一份数据。从雕刻到画面的反馈回路是即时的,不用脚本。
Spike 8 在一个半月之前用 WebGL 上的静态 mesh 测过地形材质开销。Spike 28 证明它在带完整雕刻管线的、动态计算生成的地形上也能跑。我们预算里给它留过位置,但看到所有东西都开着还能稳在 60fps,仍然让人松了口气。
八万簇草和一个洞顶
Spike 29 是 gamedev 的直觉接管的地方。
我们要的是《旷野之息》的草。不是它的面数,是它的感觉。长在该长的地方、随风动、让你想跑过去的草。
每一簇草是三个相交的 quad,互相成 60 度角,竖向分四段方便弯曲。它是一个十字形,从任何角度看都有体积感,不用 billboard 那种小聪明。每个 chunk 一个 InstancedMesh,全世界八万簇,加起来大约 240 万个草顶点。
放置才是关键的部分。CPU 在每个 chunk 上走一遍带抖动的栅格,评估和 GPU 材质着色器同一套坡度/海拔逻辑。材质系统说"这里是草"的地方,草就长。岩或沙主导的地方,密度降到零。衰减是平滑的,因为底下的 smoothstep 权重在生物群边界上是连续的。你感觉不到边界,因为边界根本就不存在。
然后我们做了一件我没把握能成的事。在 SDF 表面上长草。撒布函数沿着 SDF 体的每一根列走,找相邻体素之间的零交叉。SDF 从负穿到正的地方,就有一个表面。从那一点的 SDF 梯度估算坡度。如果够缓,就放一簇草。
在 Spike 27 里用 SDF 笔刷挖一个洞。回到 Spike 29 里来看,洞顶上长出了草。撒布代码不知道洞是什么。它只看到一个有合适坡度、合适海拔的表面。这就是那种让做开放世界系统变得过瘾的涌现行为。
风是 TSL 顶点着色器里的一个正弦波,按草叶的 V 坐标调制,所以尖端摇、根不动。按 V 在轻风、强风、关闭之间切换。风在所有 8 万簇草上同时跑,CPU 开销是零,因为它完全在顶点着色器里。
Spike 7 测过 5 万簇草,我们当时还担心要顶到上限。Spike 29 在计算地形、接缝拼接、多材质纹理和笔刷之上跑 8 万簇。把更多东西批进更少的 InstancedMesh 提交,仍然比降低每片草的顶点数更重要。Spike 7 的教训站得住。
我们留了一手没做:当你在草下面雕刻时,草不会更新。实例矩阵是撒布时定下来的。把一座山雕成一个山谷,草会悬在空中,直到你按 G 重新撒布。spike 阶段够用了。生产里需要"脏 chunk 重撒布"。
第一步脚印
Spike 30 是我反复回去看的那一个。
没有物理库。自定义胶囊体,120Hz 固定时间步。生产代码用 Rapier 跑在 web worker 里(Spike 2 证明了延迟没问题),但这个 spike 要证明的是碰撞查询本身。一个角色能不能从 heightmap 地形上走过去,踩上被雕过的 SDF 地形,并且不掉下去?
核心是一个叫 terrainQuery(x, y, z) 的函数。它检查该位置是不是落在一个被锁进 MC 模式的 chunk 里。如果是,就在 CPU 的 SDF 镜像上做三线性插值,把梯度作为表面法线返回。否则,做 heightmap 查询,用中心差分算法线。角色不知道自己站在哪一套地形系统上。它只是向地面要一个答案,就得到一个答案。
SDF 碰撞是我以为会难的部分。胶囊周围 7 个探测点(底、中、顶、四个基本方向偏移)。SDF 值小于胶囊半径的地方,梯度给出推出方向。速度被投影掉指向表面的那一份分量。它不是物理引擎。它就是几何查询加简单响应。但它处理洞、悬空、雕出来的隧道,不需要任何形状专属的代码。你走进十秒前自己挖的洞,胶囊精确地贴着洞顶轮廓走。
移动模型从实用开始,慢慢变得好玩。走、跑、冲刺、跳、45 度以上滑坡。然后我加了一个《旷野之息》风格的滑翔翼,这个 spike 就变成了我不想关掉的东西。
在空中按空格。重力从 -30 掉到 -4。下落速度封顶 -3。一个三角翼 mesh 从胶囊里展开,带缩放插值。往左压杆,机翼跟着倾。胶囊的朝向自动对齐到速度向量,所以你永远看着自己要去的方向。松开空格就掉。落地就接着跑。
镜头拉回到第三人称(按 P 切换)。它跟在玩家后面,带偏航平滑。从玩家到镜头的射线步进在 20 步里检测地形。飞进洞里时,镜头臂会平滑变短,而不是穿进岩石。从另一头出来时又伸回去。
让这个 spike 值得的瞬间是这样的:用 heightmap 笔刷雕一面高崖。切到第三人称镜头。跑到边上。跳。展开滑翔翼。从刚刚自己雕出来的地形上方往左压杆飞过,下面是 Spike 29 里长出来的草在摇,崖面上是 Spike 28 的岩石纹理,每个 chunk 边界上的 Transvoxel 接缝都稳住。落到另一边。一切在同一个浏览器标签页里一起工作。
在自己脚下雕刻
我之前不确定的一件事:当你雕刻角色正站着的地形时会发生什么?heightmap 的改动会立刻通过 terrainQuery() 传过去,因为它直接读 CPU buffer。SDF 改动通过 CPU 的 SDF 镜像传过去。胶囊在下一拍物理上解决穿插,在 120Hz 下就是 8ms 之内。
它就那么好用了。在玩家脚下抬地,他们跟着升起来。把地挖掉,他们就掉下去。没有任何特殊处理。物理跑得够快,单帧地形变化不会产生大穿插。这是 120Hz 那个时间步的意外收获。我们选它是为了动作平滑,顺带让地形编辑变安全了。
30 个 spike 之后
我们从一张平的 heightmap 和 500 个立方体开始这个系列。现在我们有:带体素洞穴的雕刻地形、Transvoxel 接缝拼接、能响应表面形状的多材质纹理、八万簇在风里摇摆的草,还有一个能走、跑、跳、滑翔通过这一切的角色。
这些都还没进生产。world/client/ 的代码还在用 WebGL 跑简单 heightmap chunk。这 30 个 spike 里所有东西都还住在独立 HTML 页面里。集成工作是下一步:迁到 WebGPURenderer、把混合 HM/MC 策略接进 chunk 管理器、把笔刷和材质系统连进多人。
但渲染系统已经不再是风险了。剩下没解决的问题都是数据流方面的:编辑持久化、笔刷描边的网络同步、协作雕刻。是发生在玩家之间的事,不是发生在三角形之间的事。
如果你从第 1 部分一路跟到这里,谢谢你陪我们走完那些乱七八糟的部分。如果你刚发现这个系列,回去看第 1 部分。教训都住在走错的弯路里。
本章涉及的技术
TSL(Three Shading Language)。 Three.js 给 WebGPU 渲染器用的基于节点的着色器系统。材质由节点(positionWorld、normalWorld、smoothstep、triplanarTexture)通过 JavaScript 里的函数组合搭起来。着色器图在运行时编译成 WGSL。TSL 在 WebGPU 目标上替代了原始 GLSL 的 ShaderMaterial,同时提供和 Three.js 标准材质特性(灯光、阴影、雾)以及自定义每片元逻辑之间的互操作。
Triplanar 映射。 一种纹理投影技术,对纹理采样三次(XY、XZ、YZ 平面),按表面法线方向混合。这消除了任意 mesh 几何上的 UV 拉伸,对 marching cubes 输出尤其关键,因为它的三角形没有有意义的 UV 坐标。TSL 提供 triplanarTexture() 作为内建节点。
用十字 quad 草叶做实例化植被。 每一簇草是 3 个相交的 quad,互相成 60 度角,从任何视角看都有体积感。每个 quad 有 4 段竖向分段,便于做风动画的平滑弯曲。整片草以一个 chunk 一个 InstancedMesh 渲染。容量按 125% 超额分配,地形被编辑时新实例可以填进保留槽位,不用重新分配 GPU buffer。看我们的地形生成指南里关于植被的部分。
胶囊 vs SDF 碰撞。 角色对体素地形做碰撞,不用物理引擎。胶囊在多个点上对 SDF 做探测。场值小于胶囊半径的地方,梯度给出向外法线,差值给出穿插深度。这种方法处理洞、悬空、隧道,不需要任何形状专属的代码。看 SDF 地形碰撞。
固定时间步角色控制器。 物理以 120Hz 步进,与帧率无关,累加实时时间,再以固定大小消耗。一个最大子步数限制防止慢帧时陷入"死亡螺旋"。在斜坡行走中地面吸附让胶囊保持贴地。固定时间步保证了未来多人重放的确定性行为。
第 14 部分,共 14 部分。 上一篇:第 13 部分 - 地形雕刻,和数学函数之死 系列导览:/zh-CN/blog/2026-02-25-open-world-browser-series-guide