Skip to content

在浏览器里造开放世界,第 20 部分:在一块平面上伪造深度

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

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

第 19 部分用一块平面四边形在远处伪造了整棵树。这一部分用一块平面四边形在近处伪造深度:视差遮蔽贴图(parallax occlusion mapping),这个技巧能让一条鹅卵石路看上去像有 5 厘米深的凹陷勾缝,却一个额外顶点都不花。目标是把它落到生产技术栈上(Three.js r184、WebGPU、TSL),这样地形细节材质就能在需要的地方带上那份深度错觉,而在其他所有地方只付平面贴图的开销。

三种伪造深度的方式,并排放

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

这个 spike 把三块平的 5×5 米平面并排放,全用同一套材质骨架,唯一的区别只是喂给采样器的 UV。Flat 直接采样纹理,作为参考基线。单次采样的视差按视线方向、根据该点的高度把 UV 偏移一次,便宜,低幅度时也还行,但在掠射角下会"游动"。POM 在切线空间里做光线步进(ray-march):沿视线步进,找到光线第一次落到高度场之下的那一层,再细化这个交点。切线空间能保持简单,是因为每块测试平面都和坐标轴对齐,所以视线方向只用几个符号翻转就能打包进切线空间,而不用一整套逐顶点的 TBN 矩阵。纹理集是从 Polyhaven 文件 API 实时拉取的,和第 17 部分模型搜索走的是同一条路径。

两堵 WebGPU 的墙,和一个无分支的光线步进

教科书式的 POM 循环会在第一次相交时跳出搜索。在 r184 上这行不通,有两个各自独立的原因。If(...).and(...) 编译时没报错,但产出的 WGSL 里循环体根本不执行,于是循环后的细化跑在垃圾数据上,平面渲染出来几乎全白。而作为独立节点的 Break() 根本就没进 r184 这个构建,所以即便 If 正常工作,也没办法表达"第一次相交就停"。这两点都能追溯到 three.js 已知的问题:在这个版本区间里,TSL 控制流会跨 IfLoop 边界过度优化。

重写后的版本是无分支的。每次迭代都无条件采样纹理,这让纹理访问保持在 WGSL 规范所要求的统一控制流里,然后通过一个用浮点持有的 done 标志把新状态折叠进来。一旦 done 翻成 1,逐迭代的 mix 调用就退化成"保持状态不变",这是 break 的无分支等价物。done 标志用一个 step 辅助函数构建,实现为 0.5 + 0.5 × sign(x + ε),因为布尔到浮点的强制转换在整个 r18x 系列里都不太靠谱,而 sign() 处处安全。代价是不管片元实际在哪儿相交,每个片元都要跑满全部 64 次迭代,但在片元尺度上这是对的取舍:运行时反正会以最大步数为闸门,而真实 GPU 在面对一个"真正的" break 时也会推测执行越过它。这里干净的回退很要紧,最后那个 mix(baseUV, refined, done),让幅度为零时(距离淡出的远端)没有片元相交,done 保持 0,POM 材质就和 flat 逐比特一致。这正是距离-LOD 技巧的全部意义所在:在效果本就小于一个像素的地方,把开销塌缩到平面贴图的开销。

bug 是纪律的失守,不是数学的错误

无分支版本能跑,但看上去扭曲,中等幅度时有条纹状的水平瑕疵,低幅度时则是"微妙地不对、又不锐利"的结果。修复来自一句话的提示:去读权威参考。启发我的那篇 LlamAcademy 教程不过是个 Unity ShaderGraph 节点,所以真正的实现住在 Unity 的 PerPixelDisplacement.hlsl 里。逐行读下来,浮现出三处我无意中引入的语义差异:光线高度基线上的一个差一错误(Unity 在循环前做了一次初始前进,所以我的参考系整整错位了一步,导致大约一半的时候交点落进了错误的层)、细化步骤所依赖的最大偏移量的一个符号约定,以及"累积偏移量"对"累积 UV"的记账选择,后者让我的细化数学多干了活、还把符号搅乱了。

根本原因不是任何单一错误,而是混用了两份参考。我拿 LearnOpenGL 的 POM 教程当向导,它用的是相似但不同的符号约定和另一套细化公式,结果落进了一个杂种状态:三分之二的数学对得上一份来源,三分之一对得上另一份。重写后的版本是把 Unity 的 HLSL 几乎逐字移植进 TSL,相同的变量名、相同的初始前进、相同的细化,在上面保留无分支的 done 标志。这个教训值得记住:当你从另一个技术栈移植一个已知良好的着色器时,先用相同的名字逐行移植,之后再为本地风格重构。不要在移植途中对着第二份参考重新推导。

一块不会说谎的参考平面

并排对比缺了最显而易见的东西:一块真几何平面。没有它,"POM 看上去挺好"是无法证伪的。挺好,是相对于什么?所以这个 spike 加了第四块平面,同一张高度图、用真实的顶点位置推上去。WebGPU 没有硬件曲面细分(这根本不在规范里,为了 Metal 兼容性被砍掉了),所以替代品是一块高度细分的平面(256×256 段、131072 个三角形),在顶点阶段做顶点位移。同一个幅度 uniform 同时驱动 POM 和这块几何平面,所以它们一起淡出,对比在每个距离上都保持是同类比同类。

有了屏幕上的真值,那些定性的说法就变得可测量了。在向下 16° 的环绕视角下,POM 和细分平面在内部明暗上一致。在掠射角下它们恰恰在该分歧的地方分歧:POM 钳到几何那条完美笔直的矩形边缘,而真实 mesh 显示出一条由真实的峰与谷起伏、迎光的地平线轮廓。所以 POM 边缘的"游动"现在可被证明是算法固有的,不是纹理或光照的瑕疵。两种开销形状也清楚了:POM 受片元限制(开销随覆盖的像素增长),细分平面受顶点限制(开销随 mesh 密度增长,与覆盖无关)。对一块地形 chunk 而言,它本就要付一块高度图驱动平面的顶点开销,所以对于次于 mesh 的细节,POM 才是对的答案。

这块参考平面还抓到了一个微妙的 UX bug。用户注意到随着幅度增加,表面看起来在下沉。这追溯到 Unity 的约定:把几何平面当成高度场的顶部,于是峰齐平锚定、其他一切都向下做视差,把平均表面拖到平面基线之下 (1 − mean_h) × amplitude。修复办法是把约定重新居中,让 h = 0.5 是平面,峰向相机抬起、谷向内凹陷。算法完全按 Unity 规定的方式运行;spike 只是把输出后处理了半个偏移量,去匹配"幅度"对一个拖滑块的人应该意味着的东西。

参考平面还了结了另一件事。一个"Steps"滑块看起来什么都没做,读起来像个管线 bug,其实不是。线性搜索之后那三次割线(secant)细化太好了(Tatarchuk 2006 年的 POM 论文指出,4 步搜索加 3 步割线在视觉上与 64 步搜索无法区分),以至于在一张平滑的高度图上,从 4 到 64 的每一种步数都收敛到同一个亚纹素的 UV。修复办法是一个开关,而不是重新布线:把割线关掉,步数滑块就成了控制相交精度的唯一旋钮,于是降到 4 会让鹅卵石明显出现阶梯状,拉到 64 又把它磨平。这个开关是一个 0/1 的 uniform,关闭时把每次割线状态更新 mix 成一个空操作,所以切换它从不重建材质、也从不卡顿。

本章涉及的技术

TSL 里的视差遮蔽贴图。 POM 在切线空间里沿视线方向对高度场做光线步进,找到光线第一次跌到表面之下的那一层,再细化交点,在一块平面四边形上产出凹陷勾缝的深度,不用任何额外几何。最后一个 mix(baseUV, refined, done) 让没有片元相交时材质与 flat 逐比特一致,这正是让距离-LOD 幅度衰减能在远处把开销塌缩到平面贴图开销的关键。见地形材质

面向 WebGPU 控制流的无分支循环。 在 Three.js r184 上,TSL 的 If(...).and(...) 可能编译出循环体根本不跑的 WGSL,而独立的 Break() 又不可用。可移植的模式是每次迭代无条件采样一次纹理(按 WGSL 规范让纹理访问保持在统一控制流里),加上一个用浮点持有的 done 标志,一旦置位就把每次状态更新 mix 成空操作。一个由 sign(x + ε) 构建的 step 辅助函数避开了不可靠的布尔到浮点强制转换。代价是无论提前退出点在哪儿都跑满最大迭代次数,这在片元尺度上是正确的取舍。

逐字移植着色器。 从另一个引擎移植一个已知良好的着色器,应该先用原作的变量名逐行移植,再为本地风格重构。混用两份参考(Unity 的 PerPixelDisplacement.hlsl 和 LearnOpenGL 教程)产出了一个杂种:光线基线差一、偏移符号被反转,以及一个细化公式,它的钳位把越界权重伪装成了空间上的不连续。要一份权威真值,而不是重新推导。

顶点位移的真值参考。 由于 WebGPU 没有硬件曲面细分,一块高度细分的平面(256² 段)在顶点阶段做位移,充当真几何来验证一个片元阶段的伪造。用同一个幅度 uniform 同时驱动两者,让对比跨距离都保持诚实。POM 受片元限制(随覆盖像素增长),几何平面受顶点限制(随 mesh 密度增长),所以它们恰好在轮廓边缘处分歧,证明 POM 的边缘游动是固有的,而非瑕疵。


第 20 部分,共 29 部分。 上一篇:第 19 部分 - 必须在森林里活下来的 imposter 下一篇:第 21 部分 - 一个并没有更快的"更快渲染器" 系列导览:/zh-CN/blog/2026-02-25-open-world-browser-series-guide