在浏览器里造开放世界,第 19 部分:必须在森林里活下来的 imposter
作者:Oleg Sidorkin,Cinevva CTO 和联合创始人
刚看到?看系列导览。那里解释了 spike 是什么,并链接了所有部分。
第 18 部分给了创作者一个能把山坡填满树的笔刷。麻烦在于当屏幕上有几万棵树时,那些树的开销有多大。一棵远处的树不需要 2000 个三角形去贡献四个像素。这一部分是最深的 LOD:imposter,一个穿着树的照片的平 quad,以及从"一棵看起来对的树"到 GPU 上一百万棵树的那条路。
一棵树是 billboard 上的两张纹理
一个 imposter 把一个道具从一个观察角度的栅格预渲染进两张纹理图集,一张存颜色、一张存世界空间法线,然后在运行时显示一个朝向相机的单个 quad,采样跟当前视角匹配的那个 tile。烘焙是每个 tile 两遍:漫反射用一个无光照材质,这样不会有光照烘进纹理;法线编码为 normalWorld × 0.5 + 0.5,alpha 从来源转发过来,让轮廓逐像素匹配。运行时材质是一个完整的 MeshStandardNodeMaterial,所以 imposter 仍然像任何别的表面一样接收场景的太阳和 IBL。胜利在于几何体:一个 quad 而不是上千个三角形,细节活在一张 1 MB 的纹理里。
把这个抽成它自己的 spike 本身就是一课。imposter 一开始是 spike 37 撒布系统里最深的那档 LOD,每次对烘焙的迭代都得跑完整个撒布管线来测试,烘焙的正确性跟实例矩阵迁移和 LOD 切换缠在一起。把它拆出来变成一个道具、一个 quad、跟原物并排,把迭代时间从几分钟降到几秒。
当课本答案是错的答案
第一版实现用了八面体编码,那是把球面方向打进一个正方形的课本式映射。它通过了数值往返测试,可用户却不停截图说 imposter 跳到了一个看起来像是从稍微上方而不是正面看树的 tile。接着是六轮修复(一个每 quad 的视角方向 uniform、一个正方形烘焙长宽比、调试材质、静态 billboarding),每一个都确实需要,但没有一个是真正的 bug。修复只在"从头重新想,KISS,不打补丁"时才来。
重写扔掉了八面体折叠,改用纯方位角乘仰角:az = atan2(dir.x, dir.z)、el = asin(dir.y)、uv = (az/2π, el/π + ½)。这就是全部编码,没有 L1 归一化、没有零符号边角情况。它在这里更好的原因不是更准(它采样一个不那么均匀的球面),而是 CPU 选格器和 GPU 着色器用同样的原语,所以它们不会在某个边界方向上像八面体那对那样悄悄不一致。图集读起来像一张接触印相:列是绕道具的角度、行是仰角,在叠层里一眼就明白。
即便那样,"像从稍微上方看"的抱怨还在,而原因是一个量化选择、不是编码。用一个 4×4 栅格,行中心落在 ±22.5° 和 ±67.5°,所以没有一行在恰好 0° 仰角。一个水平看的观察者,那个压倒性常见的情况,总是落进一个烘在某个倾角上的行。修法是奇数 N:一个 5×5 栅格把行中心放在 0° 和 ±36° 和 ±72°,所以水平观察者拿到一个烘在恰好水平的 tile。同一类"差半格"的错误在下一部分的视差工作里又出现了,而解药是同一个问题:对于规范输入,我的离散采样点真的落在我以为的地方吗?
还有两块要紧。billboard 必须分段静态,不能连续朝向相机。imposter 是一张从特定烘焙方向拍的平照片,所以运行时 quad 的图像平面必须匹配那个烘焙相机的平面,这意味着它在一个 cell 保持被选中的弧段内保持朝向,然后在边界处跳。而预算应该跟着玩家实际看的方向走:后面一个过程把倾斜的俯视行整个扔掉,换成 24 个间隔 15° 的水平环槽加一个正下方的 tile,因为树大致一直都是从眼睛高度看的。
跳变,以及混合如何抹掉了它
分段静态的 billboard 在远处看不出问题、在近处会爆,这没关系,直到你绕着它转。Spike 42 把四个变体并排放(原道具、az/el 5×5 基线、还有两个半八面体栅格)来隔离闪烁并杀掉它。两个瑕疵驱动跳变。cell 爆发生是因为片元着色器把视角方向量化进 25 个 cell 之一,所以越过一个边界就在同一帧里换掉采样的 tile 并重新瞄准 quad。极点退化是俯视情况,那里每个方位角都塌到一个点,环和顶部 tile 之间的桥接是图集里最糟的过渡。
半八面体映射把两个都修了。它把上半球连续地映射到单位正方形上,所以相邻的 3D 方向落在相邻的 UV,没有极点奇点、也不需要一个特殊的俯视 tile。闪烁的解药是双线性 cell 混合:不是跳到最近的 tile,而是找出夹住编码方向的那 2×2 组 tile 并混合全部四个,总共 8 次纹理采样(4 次漫反射、4 次法线)。相邻视角现在交叉淡入淡出而不是爆。两个单位向量的法线混合本身不是单位长度,所以它被重新归一化,对相邻 tile 之间的小角度来说表现得像一次 slerp。代价是真实的(一个 12×12 图集约 9 MB 对比 az/el 的 1.6 MB,而烘焙在 288 次渲染目标过程下大约长 5 倍),但烘焙是加载时一次性的,混合换来一个无爆的结果,这正是让 imposter 在相机真正运动时可用的东西。
一百万棵树,每帧一次相机位置复制
Spike 38 的运行时每帧每个 quad 做一次 CPU lookAt,这对一棵树没问题、对一片森林是致命的。在一百万棵树时,每帧的矩阵更新和实例 buffer 上传会压垮一切。Spike 41 把整条每帧管线搬上 GPU。每个实例的中心、偏航和缩放在构建时作为实例 attribute 上传一次、再也不变。顶点着色器从世界空间视角方向 camPos − center 建出 billboard 基,并把一个共享的单位 quad 展开进世界空间。片元着色器每像素做半八面体编码和双线性混合。整片森林唯一的每帧 CPU 活是一次 Vector3.copy 来更新相机位置 uniform,它根本不随树的数量缩放。
数学里有一块很妙:每实例的偏航从法线解码里抵消了。烘焙把法线存在烘焙相机的坐标系里,而因为绕世界 up 的偏航旋转保留 +Y、且叉积是旋转等变的,用世界 up 参考建出的运行时基已经等于旋转后的烘焙基。所以着色器直接通过运行时基的 varying 解码法线,完全不碰每实例的偏航。放置用一个抖动栅格而不是纯随机撒布:把区域分成 cell,每个 cell 在中心加一个有界偏移放一棵树,这保证一个最小间距(没有两棵树叠在一起),同时仍然读起来像一片自然森林。一个容易漏掉的细节是包围球。几何体模板只是一个单位 quad,所以 three.js 会在相机一看离原点的方向时就把整片森林视锥剔除掉。设一个覆盖完整区域加一个 quad 余量的显式包围球,能让角落的树在掠射角度下不被砍掉。
本章涉及的技术
八面体和方位角-仰角 imposter 图集。 一个 imposter 把一个道具从一个视角方向栅格烘进一张漫反射图集和一张世界空间法线图集,然后渲一个采样匹配 tile 的单个 billboard,用两张纹理替代上千个三角形。课本式的八面体映射给出均匀的球面覆盖,但在折叠边界处容易 CPU/GPU 不一致;一个纯方位角乘仰角的栅格采样一个不那么均匀的球面,但从构造上保证选格器和着色器一致。用奇数 N,让一行落在恰好 0° 仰角,并把 tile 预算花在水平环上,因为道具大多从眼睛高度看。
分段静态的 billboard 朝向。 一个 imposter 是从一个特定烘焙方向拍的照片,所以运行时 quad 的图像平面必须匹配烘焙相机的平面,不是连续朝向运行时相机。quad 在一个 cell 保持被选中的弧段内保持朝向,然后在边界处跳,这在 imposter 距离下看不出来,只在近处才爆,而那里不用 imposter。
带双线性 cell 混合的半八面体图集。 把上半球连续地映射到单位正方形上,去掉了极点奇点和那个特殊的俯视 tile。跳变靠采样夹住编码视角方向的那 2×2 组 tile 并双线性混合全部四个(8 次采样)杀掉,所以相邻视角交叉淡入淡出。混合后的法线被重新归一化,对相邻 tile 间的小角度近似一次 slerp。代价是更大的图集(12×12 约 9 MB)和一次更长的一次性烘焙,换来相机运动下无爆的着色。
GPU 驱动的实例化 imposter。 每实例的中心、偏航和缩放作为实例 attribute 上传一次;顶点着色器建出 billboard 基并展开一个共享的单位 quad,片元着色器每像素做编码和混合。整片森林的每帧 CPU 开销是一次相机位置 uniform 复制,与实例数无关。每实例的偏航从法线解码里抵消,因为绕世界 up 的偏航被叉积基构造保留。一个显式的森林范围包围球防止 three.js 在相机看离单位 quad 模板原点的方向时把整个实例化 mesh 视锥剔除掉。看 GPU 驱动的 LOD。
第 19 部分,共 29 部分。 上一篇:第 18 部分 - 一个感觉像 AI 摆的撒布笔刷 下一篇:第 20 部分 - 在一个平面上伪造深度 系列导览:/zh-CN/blog/2026-02-25-open-world-browser-series-guide