Skip to content

在浏览器里造开放世界,第 21 部分:一个并没有更快的“更快渲染器”

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

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

第 20 部分在一块平面四边形上伪造了表面深度。这一部分讲一个渲染架构决策,也是那种"教科书答案对我们的硬件来说是错的"的 spike。问题是:在第三人称相机下、为电影级密度的 alpha-test 草地,我们在推到 200 人密度之前需要一个 visibility buffer 吗?流行的建议是斩钉截铁的"需要"。我们造了它、量了它,答案是"不需要"。

人人都推荐的那项技术

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

visibility buffer 把渲染拆成两个 pass。Pass 1 光栅化几何,只把三角形和实例 ID 写进一个紧凑的整数目标外加深度,完全不做明暗着色。Pass 2 是一个全屏 pass,读取每个被覆盖像素处的 ID,重新取回该三角形的顶点,重建插值后的属性,对每个可见像素恰好着色一次。卖点是完美的 overdraw 剔除:深度测试是针对那些没做任何着色工作的片元来跑的,所以昂贵的材质只在你真正看见的东西上运行。

这个 spike 在同一块画布、同一个 device 上跑两条路径,让唯一的变量就是着色发生在哪儿。前向路径是通过 three.js 的一个普通 MeshStandardNodeMaterial。vis-buffer 路径是一条在 three.js 之外运行的裸 WebGPU 两-pass 管线,直接从后端读取 three.js 的草纹理,在 pass 1 把 (instanceId, triId) 写进一个 RG32Uint 目标,在 pass 2 解算光照。两者共用同一套真值光照配置。

有两个实现笔记值得记下。WebGPU 在片元着色器里仍然没有可移植的 primitive_index 内置,所以诀窍是把逐顶点的三角形 ID 烘焙到非索引几何上,再以 flat 插值读取,这会让顶点数变成 3 倍,但在一张 12 顶点的草卡上可忽略不计。而和 three.js 的渲染器共享画布大体上是个非事件,只要你从不重新配置上下文、也不碰画布尺寸即可,这两样都归 three.js 管。给前向路径计时才是麻烦的部分,因为 three.js 没暴露任何钩子让你在它的渲染 pass 里注入 GPU 时间戳查询;变通办法是在它工作前后各提交一个空操作的时间戳 pass 把它的工作夹起来,GPU 会按提交顺序运行它们。

数字朝错误的方向走

在一台 M 系列 Mac 上、约 1080p、一片 80 米草地上铺交叉草卡时:

5 万个实例时 vis-buffer 路径赢了 25%,4.13 毫秒对前向的 5.51 毫秒。10 万时打平。20 万个实例时前向赢了 44%,5.44 毫秒对 vis-buffer 的 7.80 毫秒。随着密度攀升,vis-buffer 路径相对地变得更糟,这和"恰恰在 overdraw 严重时取胜"的民间说法正好相反。

为什么前向扛得住

Apple Silicon 的 GPU 是基于瓦片的延迟渲染器(TBDR),这改变了整个算计。TBDR 上的前向着色有一个在片元着色器之前运行的隐面消除(HSR)阶段:光栅器收集每个映射到某瓦片的片元,按深度排序,只有幸存者(经过 alpha-test 之后)才会抵达片元着色器。所以前向路径已经在硬件内部、免费地兑现了 visibility buffer "每像素只着色一次"的大部分承诺。随着草叶挤满屏幕,更多片元在任何着色触发之前就在 HSR 处被拒绝,于是前向的有效每像素开销大致持平,而不是随 overdraw 增长。

vis-buffer 路径的 pass 1 拿到的也是同样的 TBDR 好处。麻烦全在 pass 2。Pass 2 要从一个 buffer 里读每个像素的实例矩阵,这个 buffer 在 20 万个实例时有 12.8 MB,远大于任何 GPU 缓存。屏幕上相邻的像素通常属于不同的草实例(scatter 是抖动网格,所以相邻草叶有任意的实例 ID),于是每个命中那个 buffer 的 wave 都发散地未命中缓存。光是这种不相干的随机访问,每帧自己就藏掉大约 4 毫秒。前向完全躲开了它,因为实例矩阵是通过逐实例属性路径随顶点一起到达的,所以等片元着色器运行时,变换后的顶点数据已经在瓦片本地的寄存器里了,根本不需要兆字节规模的随机读取。

这正是 Nanite 的材质分类 pass 之所以存在、用来摊薄的那笔开销:按实例对像素分箱,分派排好序的 compute wave,让每个 wave 的读取都相干。我们没有那个。一个粗略估算说,按实例对像素排序会把那 4 毫秒降到大概 1.5 到 2 毫秒,把交叉点推到 40 万到 50 万个实例。但那是在一个本来就不占优的架构上叠加优化。

诚实的结论,以及挣得这个结论的审计

对于 Apple Silicon WebGPU 上的 alpha-test 交叉草卡片植被,three.js TSL 管线的前向路径已经处在或低于 vis-buffer 的开销,而 vis-buffer 的那套管线在远超 20 万个实例之前买不到任何可见的东西,而且还得你额外加一个排序或分箱 pass 才行。对生产引擎切实可行的决定是:保留前面那些 spike 里的"前向 + LOD + imposter"技术栈,并且不投资 vis-buffer 基础设施,除非要么我们把独立的 NVIDIA 或 AMD GPU 当成主力部署目标(那里 overdraw 开销更线性),要么我们转向 meshlet 架构(那里 vis-buffer 本就是天然产物)。

因为这个结果反直觉,所以结论只有在对比公平时才值钱,于是这个 spike 走了一遍完整的审计。浮现并修掉了几个真 bug:一个会悄悄让两条路径失同步的草叶缩放滑块、一半前向草叶因反平行法线渲染成漆黑(用经典的"法线统一朝上"植被技巧修好了),以及 vis-buffer 读起来亮约 2 倍,原因是用了一个手挑的 Lambert 因子而非能量守恒的 1/π、一个硬编码的环境项、以及缺失的色调映射。修复办法是把 three.js 那条精确的 ACES filmic 曲线照搬进 WGSL,并每帧从场景里实际的灯光读取灯光颜色和强度。剩下的一个已知缺口是 pass 2 缺少直接镜面反射,它把对比偏向了 vis-buffer,意味着前向在做严格更多的每像素工作却仍在高密度时取胜。这让头条结论是保守的,而非乐观的。唯一站得住的告诫是:这一切都是 M 系列特定的,交叉点在独立显卡上很可能反过来,所以在为非 Apple 目标定下技术栈之前值得重跑一遍。

本章涉及的技术

visibility buffer 渲染。 Pass 1 光栅化几何,只写三角形和实例 ID 外加深度,不做着色。Pass 2 是一个全屏解算,读取每个被覆盖像素的 ID,重新取回源三角形,重建透视校正的重心坐标属性,对每个可见像素着色一次。由于 WebGPU 缺少可移植的片元 primitive_index,三角形 ID 被烘焙成非索引几何上一个 flat 插值的逐顶点属性。

TBDR 隐面消除对比延迟解算。 在一个基于瓦片的延迟 GPU(Apple Silicon)上,前向着色已经在片元着色器运行之前就拒绝了被遮挡的片元,所以它免费地拿到了 visibility buffer "只着色一次"的大部分好处,而且其每像素开销随 overdraw 增长大致持平。vis-buffer 的解算 pass 反而要为对一个大型逐实例 buffer 的不相干随机访问付账(20 万实例时 12.8 MB),这在高密度时占主导,除非先像 Nanite 的材质分类那样按实例对像素排序或分箱。

与 three.js 的 WebGPURenderer 共享画布。 裸 WebGPU 命令缓冲区会在共享队列上与 three.js 的提交正确交错,只要你从不再次调用 context.configure()、也不写 canvas.width/height,这两样都归渲染器管。前向路径的 GPU 计时(three.js 没为它暴露钩子)可以用在它的渲染调用前后各提交一个空操作的时间戳渲染 pass 来夹起来,因为 GPU 按提交顺序运行命令缓冲区。

验证一个反直觉的基准测试。 一个出人意料的性能结果,其可信度只等于对比的公平度。把两条路径审计到完全相同的场景内容和着色(匹配的 ACES 色调映射、能量守恒的 Lambert、从同一批对象读取的灯光、相同的草叶缩放),正是这件事把"vis-buffer 更慢"从一个很可能的测量瑕疵,变成了一个站得住的结论,且剩下那一处不对称还偏向了保守方向。


第 21 部分,共 29 部分。 上一篇:第 20 部分 - 在一块平面上伪造深度 下一篇:第 22 部分 - 能飞穿的云,和真正划算的剔除 系列导览:/zh-CN/blog/2026-02-25-open-world-browser-series-guide