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