Skip to content

在浏览器里造开放世界,第 28 部分:一直铺到地平线的草,以及把自己藏起来的地面

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

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

第 27 部分造了那座岛并给它一片看起来不像瓷砖的地面。这一部分覆盖让地形感觉有人住而不是空荡荡的两件事。Spike 56 是草,那种把一面有纹理的斜坡变成一个你愿意走进去的地方的表面细节,而问题是让一片草地读起来像一片草地而不是零散的条纹。Spike 57 是画更多的反面:是关于不去画一座山已经在藏的东西,而有意思的结果是,测试它的那条快路结果也是正确的那条。

读起来像一片草地的草,不是彩纸屑

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

核心决策是几何上的。一根细的渐窄草叶从大多数相机角度看都是亚像素的,所以五十万根扁平的草叶读起来是稀疏的绿色彩纸屑而不是一片草地。修法是一个交叉 quad 草簇:三个渐窄的 quad 绕局部上轴互相旋转六十度,所以从任何观看方向看至少有一个 quad 几乎垂直于相机,每个 instance 覆盖大约三个草叶宽的实际屏幕面积。这就是看到绿色条纹和看到草之间的区别。

一个 WebGPU 计算 kernel 在 init 时把每一簇放置一次。它把 instance 索引哈希成五条去相关的随机流,在 patch 内挑一个 XZ 位置,从 CPU 地面 mesh 用的同一个 FBM 采样地面高度(一个 TSL 移植,带保号的 mod 让数值精确匹配,使草叶坐在表面上而不是浮在上方),取一个中心差分法线,并掷出每簇的宽度、高度和色相。渲染端是一个 TSL 顶点图,完全绕过 instance 矩阵直接写到裁剪空间:它缩放单位交叉 quad,用 Rodrigues 公式把局部上轴旋转到地面法线上,并平移到草簇位置。距离 LOD 不用剔除通道就白送来了,因为顶点图把草簇的高度乘以 1smoothstep(fadeNear,fadeFar,dist),所以远处的草簇塌平到零高度,不再花 fill 开销。风是草簇世界 XZ 加时间上的两个倍频程的 sin 和 cos,水平施加并按高度分数 gate,所以根部保持锚定而尖端动,再加一个微小的相机感知倾斜让每簇朝观看者倾,这样它们在透视上张开而不是读成扁平的卡片。

颜色配方借自 NedMakesGames 的《旷野之息》URP shader:平面着色,草叶颜色沿高度分数从一个根部色调到一个尖端色调做 lerp,而根部和尖端色调本身又按每簇的色相值在两个调色板之间 lerp。这给出真正的 BotW 草地才有的那种斑驳的双色观感。Diffuse 用地面法线对着太阳,配一个环境光底,因为交叉 quad 的逐 quad 法线对一种风格化观感来说太吵了,没法单独着色。整片草地是一次 draw,完全在 GPU 上放置和动画。

剔除一座山藏着什么的四种方法

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

Spike 57 在生产客户端流式加载的同一个程序化世界上对四条剔除路径做基准测试,垂直缩放 1.8 倍让山真的能遮住植被。T0 是仅距离,匹配现有发布的 chunk 可见性已经在做的。T1 加上 frustum 剔除,那个单项最大的便宜赢面,扔掉视锥外的所有东西。T2 加上一个地形感知的地平线测试:从眼睛向每个 instance march 一条光线,如果高度图沿途任何地方升到光线上方就拒绝它,所以一棵在山脊后面的树即便在 frustum 内也会被剔除。T3 保持同样的地平线测试但用一个最大高度金字塔加速它,T4 把整个距离加 frustum 加地平线的测试移植到一个 TSL 计算 kernel,它写一个逐 instance 的可见性缩放,植被材质读它来塌掉被藏的顶点。一个两帧保持可见的计数器平滑掉单帧闪烁,那是当相机轻推时一个采样点刚好落进或落出一个 texel 时出现的,故意保持得短,因为任何更长的东西都会掩盖算法 bug 而不是修它。

金字塔是这个 spike 赚回本钱的地方,教训是关于把测试匹配到屏幕上实际有的东西。被渲染的地形是一个三角形 mesh,顶点在一个固定的两米网格上,顶点之间光栅器做线性插值,所以任何矩形内真正被渲染的高度是它内部顶点的最大值,永远不是它们之间连续的噪声峰。如果遮挡金字塔在一个更细的网格上采样噪声,它会找到 mesh 从不显示的幻影峰,并开始挡住相机明明能看穿的光线。所以金字塔的底层 texel 正好坐在顶点网格上,每个存它四个角顶点的最大值,上层是教科书式的 2×2 最大值归约,这让它相对于被渲染的 mesh 是精确的。对逐步的光线测试,代码故意用双线性插值点采样 mesh 而不是查询金字塔,因为一个亚 texel 的 AABB 查询返回的是整个 texel 的最大值,在陡峭山脊上把高度抬高好几米,那正是会藏住可见 prop 的过度遮挡。

令人惊讶的结果是 T2,那个暴力参考,才是错的那个。因为 T2 直接点采样连续噪声,它逮到了 mesh 顶点之间那些不渲染的峰,所以它稍微过度遮挡,藏住了玩家其实看得见的植被。金字塔路径既更快,因为 chunk 级测试有 O(logN) 的 AABB 归约,又更几何正确,因为它只可能返回 mesh 真正显示的高度。这就是这个 spike 的全部要点:加速结构不是你为了速度接受的质量妥协,它是匹配现实的那个版本。Chunk 剔除每个 chunk 跑一个五点测试(四个顶角加 chunk 最大高度处的中心),并把每个 chunk 自己的足迹从遮挡物集合里排除,这样一个 chunk 永远不会遮挡自己。推荐的生产路径是 T4:把 instance 元数据和高度场推进 storage buffer,在一个计算 shader 里跑相同的循环配间接 draw,反正渲染器正在转向 WebGPU。

本章涉及的技术

交叉 quad GPU 草。 每簇三个渐窄 quad 互相旋转六十度,保证从任何角度都有一个近乎垂直的 quad,所以五十万个 instance 读起来像一片草地而不是亚像素的彩纸屑。一个计算 kernel 放置每一簇(FBM 地面高度用和 CPU mesh 一样的保号 mod 采样、中心差分法线、每簇的尺寸和色相),一个 TSL 顶点图绕过 instance 矩阵去缩放、用 Rodrigues 旋转到法线上、平移、并施加按高度 gate 的风。距离 LOD 白送:草簇按 1smoothstep(fadeNear,fadeFar,dist) 塌到零高度。颜色照着 NedMakesGames 的 BotW 配方,在两个色相混合的调色板上做一个根到尖的渐变。见 GPU 驱动的 LOD

地形地平线遮挡剔除。 在生产世界上做基准测试的四条路径:距离、加 frustum、加一个拒绝山脊后 instance 的高度图光线测试、加一个加速它的最大高度金字塔、加一个 TSL 计算移植。金字塔在 mesh 镶嵌所在的那个确切的两米顶点网格上采样(底层 texel = 四个角顶点的最大值,向上做 2×2 最大值归约),所以它只返回光栅器实际显示的高度。

加速且正确,不是一种取舍。 点采样连续噪声(暴力 T2 路径)会找到 mesh 顶点之间那些从不渲染的峰,过度遮挡可见的植被。顶点网格金字塔既更快(O(logN) 的 AABB 归约)又几何精确,所以逐步的光线测试双线性采样 mesh 而不是查询金字塔的逐 texel 最大值,后者会在陡峭山脊上把高度抬高数米。Chunk 剔除用一个五点测试并把每个 chunk 自己的足迹排除,这样它永远不会自遮挡。生产路径把元数据和高度场推进 storage buffer,做一个计算 shader 剔除配间接 draw。


第 28 部分,共 29 部分。 上一篇:第 27 部分 - 用噪声造一座岛,让地面看起来像地面 下一篇:第 29 部分 - 一个 controller,任何身体 系列导览:/zh-CN/blog/2026-02-25-open-world-browser-series-guide