文章目录
北斗三维网格案例把二维经纬网扩展到高度方向,并叠加到城市 3D Tiles 上。它不仅画格子,还会采样场景表面,标出与建筑或地形发生碰撞的体素。

实现思路导图
flowchart TD
ROOT[北斗三维网格]
ROOT --> RANGE[范围定义]
RANGE --> POLYGON[绘制多边形]
RANGE --> LEVEL[层级与高度范围]
ROOT --> SAMPLE[空间采样]
SAMPLE --> CENTER[生成水平格心]
SAMPLE --> HEIGHT[地形与建筑高度]
ROOT --> COMPUTE[Worker 体素计算]
COMPUTE --> COLLISION[结构或表面碰撞]
ROOT --> RENDER[立方体实例化与拾取]
三维网格的输入
用户需要给出区域范围、网格层级和高度范围。二维区域先被离散成规则行列,高度再按层切分,每个单元都能得到独立编码。
const region = {
west,
south,
east,
north,
minHeight,
maxHeight,
level,
};
层级越高,单元越小,体素数量会按平面面积和高度层数同时增长。因此页面必须同时限制最大网格数和最大显示数。
表面碰撞不是包围盒碰撞
案例使用 Cesium 的场景高度采样读取 3D Tiles 与地形表面,再把采样结果映射到体素列。表面模式只保留每列与场景相交的外壳单元;结构模式则可以把建筑从地面到表面的占用空间填实。
这两种模式用途不同:外壳适合观察表面覆盖,结构实体更适合构造后续风场或可达性分析的固体掩膜。
Worker 负责批量生成几何数据
经纬度、高度和编码计算会产生大量顶点。案例把网格几何生成放进 Worker,主线程只接收扁平化数组并创建 Cesium Primitive:
gridWorker.postMessage({
type: "region",
id: requestId,
voxels,
params: renderOptions,
});
请求带有递增 id。旧请求晚于新请求返回时会被丢弃,避免快速调整参数后出现旧网格覆盖新网格。
盒体和线框分开渲染
碰撞体素适合复用单位立方体,通过每个 GeometryInstance 的 modelMatrix 完成定位和缩放。线框则合并成一个 LINES Geometry。两条渲染路径可以独立开关,避免为了看线框仍提交全部半透明面。
编码查询
鼠标指向体素时,页面可以反查层级、行列、高度层和完整编码。这里的编码是空间索引,不等同于 Cesium Entity 的 id。业务数据应以网格编码为键,Cesium 拾取结果只负责找到当前显示对象。
截图中的红色体素代表什么
截图已经完成区域绘制和碰撞采样。青色边框是用户区域;红色体素是被判定为建筑结构的列,而不是把整块 3D Tiles 涂红。左侧统计中的“区域体素”是整个三维点阵数量,“当前显示”受 25000 上限限制,“结构列”则是高出局部地面阈值的二维列数。
切换“表面碰撞”时,每个二维列只显示与场景表面相交的一层。切换“结构实体”时,会从估计地面向上填充到建筑表面。截图选择结构实体,目的是让读者看到建筑占用体素与普通区域网格之间的区别。
从 GeoSOT 层级得到米制单元
GeoSOT 以度空间递归剖分,经纬方向的实际米制长度随纬度变化。案例在区域中心纬度计算东西、南北方向的近似单元尺寸,并为高度方向设置独立步长。
level → 经度跨度/纬度跨度
→ 在中心纬度换算 dx、dy
高度范围 → 按 dz 切成 nz 层
因此统计里会出现 2.9 × 3.9 × 3.9 m,而不是三个方向完全相等。三维编码仍按层级、行列和高度索引生成,显示尺寸则使用当地米制换算。
场景高度采样的限制
sampleHeightMostDetailed 只能采到当前场景可见并已加载的数据。程序在采样前等待 3D Tiles 加载,但等待存在超时;网络慢或瓦片未细化到足够层级时,建筑表面会偏粗。
采样得到的是某经纬度方向上的最高可见表面,不包含建筑内部空洞。随后通过局部低值窗口估计地面:
surfaceHeight - localGround >= structureMin
条件成立才计为结构。structureMin 太小会把坡面起伏当建筑,太大会漏掉低矮建筑。这个阈值必须结合数据分辨率调整。
Worker 返回的不是 Cesium 对象
Worker 不能安全传递 Viewer、Cartesian3 或 Geometry。它只返回可转移的数值数组:模型矩阵、颜色、线框顶点和索引。主线程再创建 Cesium 对象。
每个请求携带 id,返回时与 pendingReqId 比较。这是防重复提交,不是取消 Worker 计算。旧请求仍可能消耗 CPU,但其结果不会覆盖新参数生成的网格。
体素渲染为什么复用单位立方体
所有体素拓扑相同,只是位置、旋转、尺寸和颜色不同。程序复用一份 BoxGeometry,为每个实例提供 modelMatrix。相比为每个体素创建 Entity,这种批量 Primitive 能显著减少更新和绘制对象数量。
显示上限只限制提交给渲染器的体素,不改变完整统计和碰撞掩膜。截图只显示 924 个结构体素,但求解数据仍保留全部碰撞结果。
这个案例的主线是:定义三维区域 → 按 GeoSOT-3D 层级离散 → 采样 3D Tiles 表面 → 生成碰撞体素 → 用 Worker 与批量 Primitive 渲染。
从零复现:北斗三维网格教程
案例源码位于 src/demos/cesiumdemos/beidou-3d-grid/。这一案例先解决“空间怎样编号”,再解决“哪些格子与建筑相交”,最后才是立方体渲染。
第 1 步:定义分析范围和层级
让用户绘制水平多边形,并设置最低高程、最高高程和北斗网格层级。层级函数必须返回该层对应的水平边长和垂直步长。
const grid = {
horizontalSize: levelToMeters(level),
verticalSize: heightStep,
minHeight,
maxHeight,
};
先在面板显示估算列数、行数、层数和总体素数。总体素数超限时立即阻止计算,不能等 Worker 分配数组后再提示。
第 2 步:在局部坐标中生成格心
以区域中心建立 ENU 参考系,在包围矩形内按米制步长遍历 x/y。把格心换回经纬度后执行点在多边形判断,只保留区域内部列。
完成标志:只渲染最底层线框时,格子应覆盖用户多边形,边长与面板值一致,旋转相机不会产生倾斜或漂移。
第 3 步:批量采样建筑表面
每个水平格心只采一次建筑或地形表面高度,生成 surfaceHeights。同一列的所有垂直体素共享这个高度,不要每层重复执行场景拾取。
3D Tiles 未加载时采样会得到空值。必须等待瓦片可见并记录有效采样率;有效率过低时暂停生成,而不是把空值当 0 米。
第 4 步:把规则计算交给 Worker
主线程将局部列坐标、高度场、层高和模式放入 TypedArray,通过 transferable 发送。Worker 遍历列与层,返回体素类型、位置索引和统计。
worker.postMessage(
{ requestId, columns, surfaceHeights, minHeight, maxHeight, heightStep },
[columns.buffer, surfaceHeights.buffer],
);
回调先比较 requestId。快速调整参数时,旧 Worker 结果必须丢弃,否则画面会回退到上一次层级。
第 5 步:实现两种碰撞模式
结构实体模式判断体素中心是否低于建筑表面;表面壳层模式判断体素高度区间是否跨过表面高度。前者适合 CFD 固体掩膜,后者适合展示建筑外壳占用。
const isSolid = cellTop <= surfaceHeight;
const hitsSurface = cellBottom <= surfaceHeight && cellTop >= surfaceHeight;
两种结果必须分别统计。不能用“显示上限”裁掉的数量反推碰撞率,因为显示上限只属于渲染阶段。
第 6 步:实例化单位立方体
创建一份单位 BoxGeometry,为每个可见体素生成 ENU 位置、水平尺寸和层高对应的模型矩阵。按类型分批设置颜色,使用 Primitive 或实例集合一次提交。
不要为每个体素创建 Entity。几千个 Entity 会把时间消耗在属性系统和可视化器调度,而不是网格本身。
第 7 步:拾取并解释网格
为实例附加格网 id、层号、经纬范围和高度区间。点击体素后显示这些字段,读者才能确认编号与空间位置一致。
验收与排错
- 格网随纬度拉伸:检查水平步长是否在局部米制坐标中生成。
- 所有建筑都只有一层体素:检查垂直索引是否用
floor((height-min)/step)。 - 体素落到建筑旁边:检查采样位置与渲染格心是否使用同一 ENU 参考系。
- Worker 完成后页面卡顿:限制显示体素数,并把矩阵数组也改为批量生成。