Skip to content

在浏览器里造开放世界,第 13 部分:地形雕刻,和数学函数之死

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

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

前十二部分我们造了一个可以看的地形引擎。可以飞过去看。可以欣赏接缝没裂开。这一次我们想动手摸它。

目标听起来很简单:让玩家用一支笔刷实时雕刻地形,并且不能搞坏我们用 24 个 spike 搭起来的任何系统。结果做了三次才成,遇到两个看上去像渲染失败、其实是数据模型失败的 bug,还有一次对地形数据该怎么工作的根本性重思。

三次失败的起步

Spike 25 本来应该是个简单活。生产代码里已经有一个能命中地形 mesh 的射线投射器,放置工具就用它来落物体。一支笔刷工具走的是同一套形状,只不过它改的是 heightmap 的值,而不是生成一个 prefab。简单。

第一次尝试:我直接做进生产 world/client/ 的 TypeScript 代码里。新建 terrain-brush.ts,改 chunk.ts,改协议,改 Vue 组件。一个小时之内笔刷大致能用了,但 chunk 边界上能看到明显的法线断层。我分不清这个 bug 是我的笔刷代码、原有的 chunk 拼接、还是和完整渲染循环的某个交互。这就是 spike 方法论存在的目的——避免这种情况。我跳过了规则,立刻被打脸。全部回退。

第二次尝试:独立的 spike,但我顺手用了 Three.js 0.170.0 和 WebGL。生产代码用 WebGL,所以感觉自然。但是 Spike 13-24 全都迁到 WebGPU 了。在遗留渲染器上证明笔刷能用,不等于在我们正在迁过去的那个上能用。方向错了。重来。

第三次尝试:WebGPU、WebGPURenderer、计算着色器生成顶点,匹配 Spike 22 的栈。这次架构对了。五种笔刷操作工作正常:抬升、下沉、平滑、压平、噪声。笔刷一个循环 P95 在 M1 上低于 4ms。

然后那个接缝 bug 还在。

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

一个不肯死的接缝 bug

跨 chunk 法线计算的标准修法是边界重叠:每个 chunk 多存一圈来自邻居的数据,这样边界上的法线计算就能同时采到两边。我这么做了。把邻居边数据复制进一个扩展 buffer。接缝照样裂。

我钻进数学里看。在 chunk A(cx=-1)和 chunk B(cx=0)的边界上,两个 chunk 都要在共享顶点上计算出同一个法线。Chunk A 的着色器采的是 mix(own_col31, own_col32, 0.85)。Chunk B 采的是 mix(neighbor_edge, own_col0, 0.85)。这是穿过不同数据的两条不同的双线性插值路径。哪怕边界数据是对的,两个 chunk 在同一点上还是会算出不同的法线。

就在这一刻我意识到,边界复制不是真正的 bug。真正的 bug 是数据模型。

从 1 到 24 的每一个 spike 都用一个叫 height_at() 的程序化数学函数。喂世界坐标,吐高度。干净、全局、无状态。笔刷改不了一个数学函数,所以我在上面加了一个 displacement buffer。地形现在变成了 height_at(x,z) + displacement[i]。GPU 着色器里塞了 30 行噪声函数给基础地形,再加上一段双线性插值代码给位移覆盖层。压平笔刷得减掉 height_at() 才能算出位移应该是多少才能让最终高度等于目标。两套系统叠在一起,用不同的采样策略算不同的东西。

这都不是真实游戏的做法。在生产里,作者制作的地形是采样数据,存在 buffer 里。程序化函数只是早期 spike 里方便的占位。它完成了它的任务。现在它在主动制造 bug。

我把它杀了。

每个 chunk 现在拥有一个 heightmap Float32Array,存的是真实高度值。创建时由程序化噪声填充。之后,那个噪声函数再也不会被调用。笔刷直接修改存储的高度。GPU 着色器从一个 buffer 里读,只用一个函数:hm_at(i,j)。法线在同一份数据上用栅格对齐的中心差分计算。没有双线性插值的歧义。没有两套系统的不一致。着色器从 90 行缩到 40 行。

接缝自己就好了。共享边上的两个 chunk 现在从各自的 buffer 里读到了同样的离散高度值(边界重叠里带着正确的邻居内部点)。同样的输入,同样的法线。

这不是关于笔刷的教训。这是被笔刷暴露出来的、关于数据架构的教训。

炸开的 mesh

Spike 26 是体素那边对应的版本。用笔刷修改一个 64 立方的 SDF 体,然后用 marching cubes 重新建网。和 Spike 25 是同一个问题,但在三维里。

我第一次跑起来,mesh 炸开了。长长的尖刺往四面八方射出去,像一只过得不太好的海胆。

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

我自己生成的 MC case 表里只有 3840 条,应该是 4096 条。从 case 112 开始缺了十六行。那之后的每一次查表都被错位了,case 编号对不上三角化数据。当 marching cubes 读到错的项时,它会生成两个端点都在等值面同一侧的边。顶点插值 t = -va / (vb - va) 会跑出 [0, 1],因为 vb - va 接近零或者符号反了。这就把顶点扔到体外很远的地方。乘以几百个出错的格子,你就得到一只刺猬。

修法蠢得令人发指:把 Spike 12 里那个被证明可行的表逐字节复制过来。教训记下。能复制一个被证明可行的查找表的时候,永远不要重新生成。

第二个 bug 更微妙。平滑笔刷应该是把地形特征软化。结果它造出了锐利的折痕。问题在于:我把每个 SDF 值都往零(等值面)拉。这听起来像是会让东西变平滑,但它其实是把距离场塌缩了。表面上下的体素都往零冲,把笔刷半径里的一切压平。在边界上,被平滑的体素遇到没被平滑的体素,产生一个硬阶跃。"平滑"笔刷成了折痕生成器。

修法是正经的 Laplacian 平滑:读六个直接邻居,取平均,往那个平均拉。这是通过平均附近几何来平滑表面形状,而不是去毁掉距离场的梯度。

全部一起跑

Spike 27 是集成关。把 Spike 24 完整的管线(heightmap 补丁、MC chunk、Transvoxel 接缝、geomorph LOD)和 Spike 25 的采样数据模型、以及两种笔刷类型组合到一起。

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

我做的第一件事是把 height_at() 从每一个着色器里拆掉。三个计算着色器(SDF 填充、heightmap 补丁、Transvoxel 接缝)现在都绑同一份 129x129 的 heightmap GPU buffer,通过一个共享的 WGSL 前导用同一个 hm_sample() 双线性插值函数。一份数据源,多个消费者。从 Spike 1 起就住在每个着色器里的程序化噪声函数,没了。

然后有意思的问题开始了。

当一个 SDF 笔刷把某个 chunk 锁进 MC 模式后,这个 chunk 和它 heightmap 邻居之间的 Transvoxel 接缝得从 SDF 体里采样,而不是从 heightmap。我给接缝着色器加了额外的存储 buffer 绑定和每个 chunk 的 MC 标志。四种边界组合要处理:HM-HM、HM-MC、MC-HM、MC-MC。

LOD 是另一道题。在前面的 spike 里,把一个 MC chunk 切到更低 LOD 意味着用更粗的分辨率重新填 SDF。我把它换成了基于步长的采样:SDF 数据始终保持全分辨率(65 个网格点)。MC 着色器从网格大小与格子数的比值算出步长。LOD0 步长是 1。LOD1 步长是 2,每隔一个体素采一次。Chunk 可以自由切 LOD,不用动它的 SDF 数据。

最让我满意的修法是动态垂直 chunk 生成。往上雕,雕到 chunk 顶之上,就会在上面冒出一个新的、只有 MC 的 chunk,它的 SDF 从下面那个 chunk 的边界面初始化。往下雕,一样。世界会长出来去贴合编辑。

最后一个坑是 heightmap 笔刷对锁在 MC 模式的 chunk 安静地什么都不干。HM 笔刷改 heightmapCPU 然后重新上传。MC chunk 不再读 heightmap,因为它们的 SDF 是从 heightmap 填好之后就各自走开了。我加了 syncHeightmapToSdf():heightmap 变了之后,对笔刷半径里的任何 MC chunk 重新派生 SDF 的列,把新值传上去。两种笔刷现在在两种 chunk 类型上都能工作。

我们到底学到了什么

笔刷这几个 spike 本来是要回答一个性能问题:雕刻能不能在帧预算内跑?能。这是简单的部分。

难的部分是发现 24 个 spike 一直用 height_at() 当地形真相,造出了一种隐形依赖,一旦我们想编辑任何东西就会断。那个程序化函数干净、全局、无状态,一直到它不再是地形为止。

我们写下来、不会再忘的规则:

  1. 地形高度来自采样数据。Chunk 拥有自己的 buffer。
  2. 程序化生成只填初始数据。它不是运行时的真相。
  3. 笔刷直接改 chunk 数据。不用位移覆盖层。
  4. 法线来自同一份数据,用栅格对齐的中心差分。
  5. 边界重叠(从邻居内部取 1 个格子)处理跨 chunk 法线。
  6. 能复制一个被证明可行的查找表的时候,永远不要重新生成。

第 14 部分里我们停止雕刻调试几何,开始让它看起来、感觉起来像一个真正的地方。

本章涉及的技术

采样 heightmap 架构。 地形作为每个 chunk 自己拥有的数据来存,而不是运行时从一个程序化函数算出来。每个 chunk 持有一份 Float32Array,里面是真实的高度值。创建时由程序化噪声填充初始数据,之后那个函数再也不调用。这消除了基于数学的地形和编辑覆盖层之间的两套系统不一致,简化了笔刷操作(直接改存储的值),也让 GPU 着色器极其简单(从 buffer 里读,用栅格对齐的中心差分算法线)。对于带流式的开放世界,每个 chunk 自有、从邻居取 1 格边界重叠是标准做法。看我们的地形生成指南

SDF 笔刷操作。 通过修改有符号距离场来雕刻地形。加(膨胀)用一个球周围的平滑阶跃衰减。减(雕除)用同样的形状取负。平滑用 Laplacian 平均:读六个直接邻居,算它们的均值,往那个均值拉。简单粗暴地把所有值往零拉会塌缩距离场、制造锐利边。Laplacian 平滑保留了场的梯度,同时把特征软化。看 SDF 地形表示

带混合数据源的 Transvoxel。 一个 MC chunk 和一个 heightmap chunk 边界上的过渡格子,两边需要采不同的数据。接缝着色器带着每个 chunk 的标志和 buffer 绑定来处理全部四种组合(HM-HM、HM-MC、MC-HM、MC-MC)。当一侧被锁进 MC 模式时,着色器用三线性插值采 SDF buffer,而不是采 heightmap。

marching cubes 的基于步长的 LOD。 不管 chunk 当前在哪一级 LOD,SDF 数据都按全分辨率存。MC 着色器从 SDF 网格点数与 MC 格子数的比值算出采样步长。全分辨率时步长是 1,半分辨率时步长是 2。这把 SDF 数据和 LOD 切换解耦了,chunk 可以自由换 LOD,不用重建 SDF。


第 13 部分,共 14 部分。 上一篇:第 12 部分 - 环、天空雾、和我们会再做一遍的事 下一篇:第 14 部分 - 世界活了 系列导览:/zh-CN/blog/2026-02-25-open-world-browser-series-guide