北斗网格性能优化(六):L7 的水平微块与高度切片

空间分块把一个大区域划成若干可以独立生成、绘制、隐藏和回收的空间块。 切得好,一块就是一次 Worker 任务、一次实例化绘制、一个缓存条目;切得不好,包围体太粗、单次任务太重,前面所有优化都会被抵消。

L7 微块是 L7 层级专用的水平分组,高度切片是在它之上再按采样层切的一刀。 两者合起来把一根根竖直的格子柱拆成可以单独调度的小块。

这套北斗网格的做法是:一个水平微块每边跨 2 个 L6 步长,也就是 16×16 = 256 根格子柱;每根柱子按最多 32 个采样层分成一片,满片 8192 个实例。 微块的列号行号取自全局经纬度,不取自区域边界,所以相邻的两个基础块看到同一片空间会算出同一个编号。

这篇只讲 L7。L6 的切法完全不同 —— 它按区域预估工作量反推块大小,放在下一篇和缓存一起说。

为什么 L7 不能沿用 L6 的切法

第一篇算过,L6 到 L7 每个方向细 8 倍,三个方向乘起来是 512 倍。

L6 的切块策略是「按工作量反推」:估算整块区域在 L6 下有多少格子,以每块约 12000 个为目标求出期望块数,再开方得到每边跨多少个 L6 单元。这个思路在 L6 上成立,因为 L6 的总量可控 —— 示例里所有区域在 L6 下的合计大约十几万个格子,切完每块就是几万个量级。

L7 不行。同一个区域在 L7 下是七千万,按每块 12000 反推会要求几千个块;而块一旦切得这么碎,块数、消息数、绘制命令数、缓存条目数会一起涨上来,管理开销吃掉全部收益。

所以 L7 换了个思路:不去追「每块多少格子」这个目标,而是直接固定块的空间尺寸,让它每次产出的量自然落在合适范围。 水平固定 16×16 根柱子,高度固定最多 32 层,乘起来 8192。

这个数不是算出来的,是选出来的 —— 它对应「一次 Worker 任务」和「一次实例化绘制」希望处理的规模。第二篇提到过,一个 L7 切片的名义规模就是 8192 个实例。

水平方向:一个微块跨 2 个 L6 步长

先看层级关系。L6 的经度步长是 2 角秒,L7 是 0.25 角秒,1 度等于 3600 角秒。每个方向上,一个 L6 步长包含 8 个 L7 步长。

微块的边长由配置决定,示例用的是默认值 2:

1
2
3
4
5
// ViewportGridManager.ts
const DEFAULT_L7_MICRO_TILE_L6_SIZE = 2;

const lonSpan = l6Config.lonSpan * microL6Size; // 2 角秒 × 2 = 4 角秒
const latSpan = l6Config.latSpan * microL6Size;

于是每边的 L7 数量是:

每边 L7 数量 = 2 × 8 = 16

一个完整的内部水平微块就是 16 × 16 = 256 根格子柱。注意这里还没乘高度,它描述的只是平面分组 —— 一根柱子从地面立到顶。

为什么是 2 个 L6 步长而不是 1 个或者 4 个?1 个太碎(8×8 = 64 根柱子,块数乘 4 倍);4 个又太粗(32×32 = 1024 根柱子,乘高度后单块规模接近 3 万)。2 是在「块数」和「单块体积」之间取的一个平衡点,而且它让微块边界和 L6 网格边界对齐 —— 微块的边正好压在 L6 的格线上,不会切在某个 L6 格子的中间。

这个微块只是渲染和调度分组,没有改变任何一个 L7 格子本身的编码规则。实例数据里存的 gridIndices 仍然是解码出来的 L7 网格索引 x/y/z,不是微块内的局部坐标。

列号取自全局经纬度,不取自区域边界

水平分块最容易做错的地方在这里。

如果每个区域、每个基础块都从自己的边界开始重新起一个局部网格,那么两个相邻基础块看到同一片空间时,算出来的行列号很可能不一样 —— 同一块数据会有两个身份,候选去重、缓存命中、重新加载就全乱了。

项目的做法是让列号直接由绝对经度决定:

1
2
3
4
5
// ViewportGridManager.ts —— _buildL7MicroExpansionContext
minCol: Math.floor(baseClip.minLon / lonSpan),
maxCol: Math.ceil(baseClip.maxLon / lonSpan),
minRow: Math.floor(baseClip.minLat / latSpan),
maxRow: Math.ceil(baseClip.maxLat / latSpan),

baseClip.minLon / lonSpan 里没有任何和区域相关的东西,只有绝对经度除以固定的 4 角秒跨度。所以第 842 号微块的西边界,在地球上是一个固定的经度值,不管是谁在什么时候算,答案都一样。

这个决定带来的直接好处是同一块数据在天生的层面上只有一个身份。后面会看到,去重和缓存键都是建立在这个前提上的。

区域边界怎么裁:允许最后一列探出去

全局对齐解决了身份问题,但带来一个新问题:区域不会恰好落在微块的格线上,边缘那些微块有一大半在区域外面。

_computeL7AxisRange 处理这件事 —— 把微块的原始范围夹到区域范围内,并顺手标出是不是最后一列:

1
2
3
4
5
6
7
8
9
10
11
// ViewportGridManager.ts
private _computeL7AxisRange(index, span, areaMin, areaMax): L7AxisRange | null {
const rawMin = index * span;
const rawMax = rawMin + span;
const clipMin = Math.max(rawMin, areaMin);
const clipMax = Math.min(rawMax, areaMax);
if (clipMax <= clipMin) {
return null; // 完全在区域外,这个微块不用要
}
return { min: clipMin, max: clipMax, isLast: rawMax >= areaMax };
}

返回 null 就是「这一列微块和区域没有交集」,直接跳过。这比先建块再判相交便宜。

isLast 在后面用到了一个关键地方 —— 只有区域最外面那一列/行会向外放宽半个 L7 单元:

1
2
3
4
5
6
7
8
9
// ViewportGridManager.ts —— _expandL7MicroSliceCandidates
const horizontalClip: TileBoundingBox = {
minLon: colRange.min,
maxLon: colRange.isLast ? colRange.max + ctx.l7Config.lonSpan / 2 : colRange.max,
minLat: rowRange.min,
maxLat: rowRange.isLast ? rowRange.max + ctx.l7Config.latSpan / 2 : rowRange.max,
minHeight: ctx.minHeight,
maxHeight: ctx.maxHeight,
};

放宽半格是为了照顾区域最边界的那一列格子 —— 它们的中心点可能刚好落在区域外面一点点,按严格区间判会被丢掉。

而内部相邻的微块不能两侧一起放宽。这是第四节那个归属规则的另一半:放宽只发生在整个区域的边缘,两个微块共用的那条边界两侧都保持严格,格子只归一侧。否则同一根柱子会被两个微块同时生成,实例翻倍。

高度方向:259 个采样层切成 9 片

水平切完,还有高度。

高度步长不是独立参数,它由水平步长推出来 —— 水平细一倍,高度也跟着细一倍。L7 的高度步长是 7.730520 米。给定区域的高度范围,采样层数是:

1
2
3
// ViewportGridManager.ts —— _expandL7MicroSliceCandidates
const totalLayers = Math.max(1, Math.floor((ctx.maxHeight - ctx.minHeight) / ctx.heightStep) + 1);
const sliceCount = Math.ceil(totalLayers / ctx.sliceLayers);

对高度 0 到 2000 米的区域:

  • 总采样层数 floor(2000 / 7.730520) + 1 = 258 + 1 = 259
  • 切片数 ceil(259 / 32) = 9
  • 前 8 片各 32 层(共 256),最后一片只剩 3 层
1
2
3
4
5
6
7
一个 16×16 水平微块
|
+-- 高度片 0:32 层
+-- 高度片 1:32 层
+-- ...
+-- 高度片 7:32 层
+-- 高度片 8: 3 层

这就是高度切片的意义:同一根柱子上的不同高度段可以独立排队、独立占预算、独立进缓存。相机在一千米以下飞行时,只需要附近那几片,不必把整根两千米高的柱子一次装进来。高度范围是业务给的,切片是引擎唯一能控制的那个旋钮。

这里有个口径差异值得单独说。示例里的估算函数 estimateGridCount 用的是 Math.ceil(heightSpan / heightStep) + 1,同一个 0–2000 米区域会算出 260 层;引擎实际切片用的 _expandL7MicroSliceCandidates 是 Math.floor(...) + 1,得到 259 层。差这一层不是笔误:采样点是 minHeight + k × heightStep,满足 minHeight + k × h ≤ maxHeight 的最大 k 是 floor(258.7146) = 258,所以真的只有 259 个采样点。ceil + 1 会多算一个落在 2002.20 米的点,超出区域高度 —— 估算值因此偏大一点点,第一篇那个 0.28% 的估算误差里有一部分就来自这里。

「层数减一」到底减的是什么

切片的高度范围是这么算出来的:

1
2
3
4
5
6
// ViewportGridManager.ts —— _appendL7MicroSliceCandidate
const sliceMinHeight = ctx.minHeight + slice * ctx.sliceLayers * ctx.heightStep;
const sliceMaxHeight = Math.min(
ctx.maxHeight,
sliceMinHeight + (layersInSlice - 1) * ctx.heightStep
);

sliceMinHeight 那行好理解:第 slice 片从「区域底面 + 前面所有片的层数 × 步长」开始。第 0 片从 0 开始,第 1 片从 32 × 7.730520 = 247.377 米开始。

容易看错的是 sliceMaxHeight 里的 layersInSlice - 1。一片有 32 个采样点,相邻两点之间是一个步长,32 个点之间只有 31 段间隔 —— 所以第一个点在第 0 层、最后一个点在第 31 层,本片的结束高度是 起点 + 31 × 步长。

把它和前几片的起点放一起看,能看出这个 - 1 是在对齐采样点:

切片 采样层编号 采样高度范围,约
0 0~31 0~239.646 米
1 32~63 247.377~487.023 米
8 256~258 1,979.013~1,994.474 米

第 0 片的结束高度是 31 × 7.730520 = 239.646,第 1 片的起始高度是 32 × 7.730520 = 247.377 —— 中间正好差一个步长。这不是留了一条空缝,而是两片各自占了第 31 层和第 32 层这两个相邻的采样点。

如果这里写成 layersInSlice * heightStep 会怎样?第 0 片会结束在 32 × 步长,和第 1 片的起点重合,第 32 层被算进两片,要么多生成一层,要么因为归属规则打架丢一层。少乘一个数就少一层,多乘一个数就多一层 —— 这类错误在视觉上极难发现,只在块边界上表现为一条细线重复或者缺失。

还有一点值得分清:这里的采样高度描述的是「在哪儿取样」,不是「盒子的上下表面在哪儿」。 最终每个盒子的物理上下界由编码解码得到。把取样位置当成格子表面,会得出「切片之间有缝」的错误结论。

8192 是满块规模,不是每块都有这么多

16 × 16 根柱子、每片最多 32 层,理论上限是:

16 × 16 × 32 = 8192 个实例

这个数字在系列里反复出现,但它不是「每块都有 8192 个」。三种情况都会让它变小:

  • 区域边缘的微块被 _computeL7AxisRange 裁过,列数行数不足 16。
  • 多边形裁剪会剔掉矩形里但不在多边形内的格子,这是 Worker 里 filterGridsInPolygon 干的活。
  • 最后一片只有 3 层,规模是前 8 片的十分之一不到。

所以 8192 是个规划用的上限,用来估预算、算队列容量、判断一块该不该进缓存。真实的实例数是生成完才知道的,这也正是为什么调度器要先估算、再生成 —— 第三篇讲过这件事。

两套边界框:生成用宽松的,归属用严格的

每个 L7 微块带着两套边界:

1
2
3
4
5
6
7
8
9
// ViewportGridManager.ts —— _createL7MicroTile
const bufferedBbox: TileBoundingBox = {
minLon: bbox.minLon - DEFAULT_TILE_BUFFER, // 0.00025 度
maxLon: bbox.maxLon + DEFAULT_TILE_BUFFER,
minLat: bbox.minLat - DEFAULT_TILE_BUFFER,
maxLat: bbox.maxLat + DEFAULT_TILE_BUFFER,
minHeight: bbox.minHeight, // 高度不外扩
maxHeight: bbox.maxHeight,
};

bbox 是生成范围,外扩 0.00025 度。 这个量是刻意留得比较大的 —— 0.00025 度大约是 3.6 个 L7 单元。外扩是为了防止漏格子:边界附近的候选编码要能被枚举到,宁可多枚举几个再由归属判定剔掉。

clipBbox 是归属范围,不外扩,用 [min, max) 半开区间。 Worker 里按格子中心点判定:

1
2
3
4
5
6
7
// gridWorkerUtils.ts —— clipGridsByOwnership
// 半开区间: [minLon, maxLon) × [minLat, maxLat)
// 最末 Tile 的 clipBbox.max 已外推半个网格步长,所以 < 判定也能包含边界网格
if (centerLon >= clipBbox.minLon && centerLon < clipBbox.maxLon &&
centerLat >= clipBbox.minLat && centerLat < clipBbox.maxLat) {
result.push(grid);
}

生成范围重叠,归属范围不重叠 —— 这就是这套设计的一句话总结。枚举时多找一点没关系,最后每一根柱子只归一个微块。

高度方向不参与这套外扩:上面两套 bbox 的 minHeight / maxHeight 都直接来自该片的采样范围。Z 方向的归属由切片自己的区间管,不能把水平中心点的裁剪规则照搬到高度上。

相交缓存按行列存,不按切片存

一个微块和一个区域相不相交,是纯二维问题。项目按 (col, row) 缓存这个结论:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ViewportGridManager.ts
private _doesL7MicroTileIntersectArea(areaData, col, row, ...): boolean {
const cacheKey = `${areaData.id}:L7:m${col},${row}`; // 没有 slice
const cached = this._l7MicroTileIntersectionCache.get(cacheKey);
if (cached !== undefined) {
return cached;
}

let intersects = false;
try {
const tilePolygon = turf.polygon([[...]]);
intersects = turf.booleanIntersects(tilePolygon, areaData.polygon);
} catch {
intersects = true; // turf 出错时宁可留下
}

this._l7MicroTileIntersectionCache.set(cacheKey, intersects);
return intersects;
}

两个细节。

key 里没有 slice,这是有意的。 同一根柱子的 9 个高度片,水平投影完全一样,和多边形相不相交的答案必然一致 —— 求一次就够了。在 0–2000 米这种高度范围下,这直接把 turf 调用次数除以 9。这和第四篇里 Worker 那个「同一根柱子只测一次相交」的 grid2DSet 是同一个道理,只是发生在两个不同的层级上:Worker 在格子层面去重,ViewportGridManager 在微块层面去重。

catch 分支默认 true。 turf 在某些退化多边形上会抛异常,这时候选择「当作相交」,让这一块继续走下去。反过来默认 false 会静默丢数据 —— 少画一格没人会注意到,但地图上缺一块是事故。这个取舍方向和前面包围球故意留大一点是一致的。

缓存在区域被移除时按前缀清掉:

1
2
3
4
5
6
7
8
private _clearL7MicroTileIntersectionCacheForArea(areaId: string): void {
const prefix = `${areaId}:L7:`;
for (const key of Array.from(this._l7MicroTileIntersectionCache.keys())) {
if (key.startsWith(prefix)) {
this._l7MicroTileIntersectionCache.delete(key);
}
}
}

同一块被两个基础块发现,靠 cacheKey 收口

现在回到最初那个全局对齐的决定,看它的回报。

一个基础 Tile 需要 L7 时,会展开出一批微块候选。相机移动时,相邻的两个基础 Tile 可能同时发现同一个微块 —— 它们的展开范围在交界处是重叠的。

因为列号取自全局经纬度,这两个基础块算出的 col / row 完全相同,于是 cacheKey 也完全相同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ViewportGridManager.ts —— _createL7MicroTile
const key = `${areaData.id}:L7:m${col},${row},z${slice}`;
// 候选那边再拼上层级
const cacheKey = `${microTile.key}@L7`;

const existing = desiredCandidateMap.get(cacheKey);
if (existing) {
existing.distance = Math.min(existing.distance, distance); // 只更新距离,保留近的那个
return;
}

desiredCandidateMap.set(cacheKey, {
cacheKey,
tile: microTile,
level: 7,
distance,
estimatedGridCount: this._getGridCountForCacheKey(cacheKey, microTile, 7),
});

desiredCandidateMap 天然完成了去重。重复发现的块不会生成两次,只是把距离取小值 —— 也就是按「离相机更近的那个基础块」来排序,这正好是调度想要的。

如果列号是局部的,这里就会出问题:两个基础块算出不同的编号,同一个微块以两个身份进入候选表,最终生成两遍、画两遍、缓存里存两份。而且是那种不会报错、只是慢一点和内存高一点的 bug。

这个 cacheKey 后面还会继续用:过了预算选择、进入任务队列、命中缓存、写进 _activeTileByKey,全程都是它。一个稳定的键,把候选、任务、活动、缓存四张表串成了一条线。

这套切法换来了什么,代价是什么

换来的:

  • 单次 Worker 任务和单次绘制稳定在几千到 8192 个实例的量级,不随区域总面积变化。
  • 高度成为独立维度。相机在低空时只加载附近几片,不必把整根两千米的柱子一次装进来。
  • 块的身份全局唯一,跨基础块、跨帧、跨缓存命中都指向同一个键。
  • 相交判断一次算、九片复用。

代价是:

  • 块数变多了。793 × 263 个 L7 格子的区域,水平方向上就有约 50 × 17 = 850 个微块位置,乘 9 片接近 7650 个潜在块。绝大部分永远不会被生成 —— 靠的就是第三篇那套视锥剔除和活动预算在前面挡着。
  • 每根柱子的竖直方向被切了 9 刀,切片边界上的格子归属必须严格不重叠,-1 那一处写错就会在块边界出现重复或缺失。
  • 高度片的独立性意味着相邻两片可能处于不同状态(一片已加载、一片还在队列里),视觉上会出现高度方向上的分界。

最后一条是这个系列反复出现的那个取舍:参数不是调优出来的,是被单次任务的规模约束反推出来的。 16 和 32 这两个数没有魔法,它们乘起来等于 8192 —— 一个能在一帧里顺利生成完、传给主线程、上传到 GPU 的规模。

下一篇是这个系列的最后一块:L6 怎么按工作量反推块大小,以及这些块在离开视口之后怎么进缓存、超出预算之后怎么被回收。


上一篇:RTE 与局部坐标系 · 下一篇:Tile、缓存与资源回收 · 系列分类

北斗网格性能优化(六):L7 的水平微块与高度切片

https://wywzixin.github.io/beidou-grid-performance/06-l7-slicing/

作者

wangyawei

发布于

2026-09-12

更新于

2026-09-15

许可协议