北斗网格性能优化(三):LOD 与视锥剔除
LOD(Level of Detail,细节层次)决定网格画多细;视锥剔除判断哪些空间块可能出现在画面里;活动预算限制同时参与渲染的数据规模。 三者分别减少无法分辨的细节、视野外的工作,以及设备暂时承担不起的负载。
这套北斗网格的做法是:用相机到 Tile 中心的三维距离选层级,用区域 OBB 和 Tile 包围体做两级剔除,再按距离和预算安排加载;相机移动期间暂缓新增 L7,停下来约 120 毫秒后补细节。
代价是细节分阶段出现,Tile 级筛选会保留少量实际看不见的数据,而且视锥剔除不处理遮挡 —— 楼房和地形挡住的块仍在视锥内,仍然要生成。
这篇从一个常见问题展开:已经用了 GPU Instancing,为什么大规模网格还是会卡?
实例化之后,GPU 还在忙什么
假设我们已经把 10 万个盒子改成实例化绘制:单位立方体只存一份,每个空间块提交一批矩阵和颜色,提交次数也降下来了。
继续把相机拉高、把区域放大,画面还是可能变慢。Instancing 让 CPU 可以批量安排绘制,盒子本身的工作量还在。一个有 24 个带法线顶点的立方体,重复绘制 10 万次,仍然对应约 240 万个实例顶点;同时画填充和线框就还要再来一遍线框的顶点。透明盒子互相重叠时,一个屏幕像素还可能被反复处理。
相机拉高以后,这种工作很容易变得不划算:地面上一个细格子只占半个像素,你已经看不清它的边界,GPU 却仍在变换顶点、裁剪三角形、处理覆盖关系,更多格子只是挤在相同的屏幕位置上。
所以优化完「怎么批量画」,问题就变成:这一帧究竟需要哪些格子,需要细到什么程度?
| 需要回答的问题 | 使用的手段 |
|---|---|
| 这个位置需要多细的网格? | LOD |
| 这个空间块有没有机会出现在画面里? | 视锥剔除 |
| 候选数据都很合理,但设备能同时承担多少? | 活动预算 |
这三个问题解决的是不同的浪费。只做视锥剔除,正前方仍可能堆满几百万个细格子;只做 LOD,身后的块仍可能参与生成;两者都做了,近距离的大范围细节也可能超过设备承受能力。
LOD:距离变远,让一批细格子退成粗格子
对北斗网格来说,LOD 直接对应不同的网格层级:近处看细格子,远处看更大的格子。这种变化减少的是实际实例数量,不只是让同样数量的盒子看起来粗一点。
以水平划分为例,L6 的角跨度是 2 角秒,L7 是 0.25 角秒。相同范围内,一个 L6 单元的水平覆盖对应 8×8 个 L7 单元 —— 只看一个水平层,选 L6 就可能用 64 个粗单元覆盖掉原来 4096 个细单元。三维区域还要乘上高度层数的变化,第一篇算过,L6 到 L7 总量差 512 倍。
从屏幕上也能理解这件事。一个物体的投影高度大致与它的实际尺寸成正比、与相机距离成反比:距离增大十倍,同一个格子的投影尺寸大约缩小到十分之一,但保持所有细节付出的几何成本没有相应减少。
网格也不总是越细越有信息。远景里密集边线会混成一团,粗网格反而更容易看出空间范围。
距离怎么算,比阈值填多少更重要
项目用相机到基础 Tile 中心的三维距离选层级 —— 是 ECEF 里量出来的空间直线距离,不是相机高度,也不是水平距离。所以相机高度相同的情况下,面前的块和几公里外的块仍然会有不同层级。
压测示例配的规则是:
| 相机到基础 Tile 中心的距离 | 目标层级 |
|---|---|
| 不超过 1,500 米 | L7 |
| 大于 1,500,不超过 6,000 米 | L6 |
| 大于 6,000,不超过 50,000 米 | L5 |
| 大于 50,000 米 | L4 |
一个口径说明:源码里这份默认表的最后一档是「不加载」(返回 null),L4 是示例自己配上去的。这一系列提到「示例配置」时,指的都是 example/only3d/stressTestCustomZonesL7Poc(1).html。
用块中心作为参考,计算简单,也容易解释。代价是块内部的距离并不相同:块中心跨过阈值时整块一起切换,块边缘的格子可能更近或更远。Tile 尺寸因此会影响切换体验。
距离 LOD 适合先建立清晰可控的规则。如果业务需要在不同分辨率、不同视场角下保持相近的屏幕细节,可以进一步引入屏幕空间误差,让投影大小参与决策;这套方案用的是固定阈值,改变浏览器分辨率不会自动改写它们。
剔除分两级:区域用 OBB,Tile 先看区域结论
相机能看到的空间由六个平面围成,对透视相机来说像一个切掉尖端的锥体,所以叫视锥。判断视锥外的数据时,项目先用一个简单形状代表一整块复杂几何。
区域这一级用 OBB(有向包围盒),不用包围球。 原因是球对方向不敏感,遇到狭长区域会留下大量空白:一条长 2 公里、宽 100 米的带状区域,为了包住两端球半径接近 1 公里,真正的数据只是细长的一条 —— 视锥稍微碰到球就得继续往下查。OBB 用中心和三条有长度的方向轴描述范围,沿区域的局部东、北、上放置,比球紧得多。
GridTileIndex.ts 里的构造只有两行,理由是源码注释里写着的:
1 | // GridTileIndex.ts |
这条注释说的是一个只有真的贴着地表飞过才会遇到的问题:相机贴近地面时,球形包围体的球心可能落到地表以下,球把相机整个包进去,于是永远和视锥相交,永远剔不掉。OBB 在垂直方向是真实尺寸,就不会有这个问题。
区域这一级的结论还能往下传。_isTileVisible 的第一行就是短路:
1 | // ViewportGridManager.ts |
区域完全在视锥内时,只要区域包围体覆盖了子块,就不用对每个 Tile 再跑一遍相同的判断;区域与视锥边界相交时,才逐个测 Tile。两级都用 obb ?? boundingSphere 兜底 —— 缺 OBB 时降级到球,不会因为拿不到 OBB 就整块丢掉。
包围球还是要用的,它更便宜。项目里的构造取的是中心点到一个角点的距离:
1 | // GridTileIndex.ts |
这里只量了 (maxLon, maxLat, maxHeight) 一个角点。在平面上四个角点等距,取哪个都一样;换到椭球上,各角点到中心的 ECEF 距离会有微小差别,代码取的这个角点是个近似里比较稳的选择,但严格说不是最小外接球。传进来的 bbox 是含 0.00025 度外扩的宽松版本,所以球会略大于真实数据 —— 偏大只是多留一点数据,偏小才会让高盒子在转动相机时突然消失,这个方向上的余量是故意留的。
把检查放在生成之前,才真正少做工作
GPU 自己会裁剪视野外的图元,但等到那个阶段,网格编码、矩阵计算、缓冲上传都已经做完了。
所以第一轮视锥筛选放在数据调度之前,按区域和 Tile 两层跑,输出的是候选块和目标层级:
1 | 区域 OBB 与视锥比较 |
区域在内就接受全部子块这一条,是这一级里省得最多的判断。
渲染阶段还有第二轮,跑在每个 Tile 自己的 update() 里:
1 | // BeiDouGridPrimitive.ts — update()(每帧调用) |
这段的次序是有意的:先测视锥,通过了才去创建渲染命令。_createCommand 里会造顶点缓冲、索引缓冲、Shader 和 DrawCommand —— 都是 GPU 资源。一个块可以仍在活动集合里,但因为本帧视野变化而不提交绘制,这时候就不该为它分配这些资源。这个检查比常规的「提交前剔除」多省了一步。
顺带一提,构造 DrawCommand 时也填了 boundingVolume: this._boundingSphere,Cesium 自己也会拿它做一次剔除。显式检查的价值不在「Cesium 不会剔」,而在「还没建命令就能退出」。
相机可能在两次调度之间快速转动,这两轮检查就是为这种错位准备的。
视锥里的数据,也要排队和限量
看得见不代表都要同时加载。相机贴近一个很大的三维区域时,视锥内仍可能装下远超设备能力的细网格。
示例里配的是总活动网格预算 180,000、L7 活动网格预算 120,000、最多 128 个等待生成的任务。总量限制整场活动规模,单层级预算避免 L7 把所有额度占满,队列上限限制等待中的计算需求。
层级预算同样是个开关,要 maxActiveGridsByLevel 非空才会启用:
1 | // ViewportGridManager.ts — _selectDesiredTilesWithinBudget |
没配这份映射时,全局预算一个人管到底。配了之后,映射里没列出的层级(比如只写了 { 7: 120000 })在分组阶段拿到的是 ?? maxActiveGrids,也就是整个全局额度而非剩余份额,最后再由 _applyGlobalActiveBudget 兜一次总量 —— 所以它给 L7 设的是上限,不是把预算切走一块。
候选块先按距离排序,近处优先。生成之前先估算实例数量,拿到真实结果后再用实际数量核对活动集合。这一步估算不能省:如果必须先生成所有候选才能知道哪些能留下,最贵的那部分计算已经发生了。估算让调度器在前面就能拒掉明显超预算的需求 —— 第一篇里那个和写死值只差 0.28% 的估算,就是用在这里。
以名义上 8192 个实例的 L7 切片为例,14 个满块是 114,688 个实例,第 15 个满块就会超过 120,000。遇到区域边界,块可能只剩几千个实例,能容纳的块数也跟着变。所以预算更适合按网格量控制,同时保留块数和任务队列的约束。
块是整体激活的,估算也有误差,加上切换期间可能保留已有数据,活动数量不会每个瞬间都精准贴着配置线。这里的预算是调度依据,也不是显存的字节上限 —— 实例矩阵、颜色、编码、缓存和临时数组都还要占内存。
相机移动时,先保证交互顺畅
连续拖动相机会不断产生新的精细候选。假设一次 L7 生成还没结束,相机已经离开那片区域,继续把所有经过的位置都排进队列,只会让用户停下来之后还在等旧视角的工作清空。
项目采用「移动中先粗、停下来补细」:新的 L7 请求在相机移动期间先回退到 L6,相机停止并经过约 120 毫秒的空闲等待后,才允许细层级加载。已经活动的 L7 可以在移动中保留,减少刚生成就被替换的反复工作 —— 代码里由 loadOnMoveEndLevels 这个配置声明哪些层级参与这套延迟,示例里配的是 [7]。
这份名单同时也是整套行为的开关:
1 | // ViewportGridManager.ts — _isCameraIdle |
_resolveTargetLevel 只在 _isCameraIdle 返回 false 时才去调 _fallbackCoarserLevel;源码默认值里没有这份名单,于是每个层级都算空闲,回退分支根本不进入。「移动中先粗」是配置打开的可选项,不是框架自带行为。
示例配 [7] 的含义是:只有 L7 参与延迟,移动期间 L7 的目标层回退到 L6 —— _fallbackCoarserLevel 从目标层往粗里逐档走,落到第一个不在名单里的层级,正是 L6;而 L6 自己的加载不受影响。同一份名单还被 _shouldRetainMoveEndLevelEntryDuringMove 用来判断「已经活动的 L7 要不要在移动中留着」,所以它既管新的别急着上,也管旧的先别撤。
调度检查另外做了约 200 毫秒的节流。这里限频的是候选遍历、层级选择和队列更新,场景渲染仍然逐帧运行,上面那个包围球检查也继续执行。否则为了降低调度成本把相机响应也一起变慢,就失去意义了。
这种做法减少了短暂视角触发的重计算,让有限的生成能力更集中地服务最终停留的位置。它不消除所有层级切换痕迹;要在阈值附近进一步减少来回切换,需要设计不同的进入、退出距离,那是另一套滞回策略,和这里按移动状态暂缓加载不是一回事。
怎样判断这套选择真的有效
下面是近距离进入 L7 后的实际画面。17 个活动 Tile 中有 15 个 L7、2 个 L6,共管理 132,814 个活动实例,其中 L7 为 119,680 个。

画布为 1600×1000,截图来自已有缓存的运行会话。活动数量包含已经激活的数据,不等于这一帧屏幕上逐个可见的格子数量;这张图用于展示层级和数量分配,不作为优化前后的帧率对照。
接近 L7 预算说明细节供给确实受到了限制:相机能看到的区域,不会无条件全部展开到最细。
验证时我更关心固定相机路线上的连续表现:拉远后细层级是否减少,转身后无关任务是否退出等待,相机停下后附近细节能否逐步补齐,活动规模是否维持在预期范围。再结合主线程耗时、GPU 压力和上传峰值,才能知道卡顿发生在哪一段。
包围体的正确性要沿视锥边缘检查 —— 慢慢旋转相机、让高盒子从屏幕边缘进出,比只看正上方视角更容易发现过早消失的问题。
最后要区分视锥和遮挡:楼房后面的网格可以在视锥内,地形挡住的数据也可能通过包围体测试。深度测试负责像素之间的前后关系,不会自动取消这些块的生成任务。如果场景主要被严重遮挡影响,需要另外评估遮挡处理的收益和成本。
做到这一步,渲染器承担的工作开始跟着观察需求变化。接下来要解决的是,这些被选中的块怎样生成,才能在补充细节时不卡住主线程 —— 那是下一篇。
上一篇:GPU Instancing 与 DrawCommand · 下一篇:Web Worker 与 Transferable · 系列分类
北斗网格性能优化(三):LOD 与视锥剔除
https://wywzixin.github.io/beidou-grid-performance/03-lod-culling/