北斗网格性能优化(五):解决大坐标精度问题——RTE 与局部坐标系

RTE(Relative-To-Eye)是一种相对相机计算坐标的技术:先从物体位置里减去相机位置,再把这个较小的差值交给 GPU。 它解决的是大世界坐标进入 Float32 之后,小尺度细节丢失的问题。

局部坐标系则给一小块空间挑一个附近的参考原点。 在地球场景里,项目让同一个 Tile 内的网格共用一套 ENU(东、北、上)坐标系,每个实例只保存相对 Tile 参考中心的局部位置。

这套北斗网格的坐标链路可以概括为:CPU 用双精度把 ECEF 世界坐标转成 Tile 局部坐标,并算好参考中心减相机的位置;GPU 再把局部偏移旋转回世界方向,得到相对相机的坐标,最后只做视图旋转和投影。

它不是让 Float32 变成 Double,也不解决所有闪烁。它做的是重新安排减法顺序 —— 让最容易损失精度的「大数减大数」先在 CPU 的双精度里完成。

为什么地球坐标一进 GPU,几厘米和几米就可能不见了

Cesium 的三维地球用 ECEF(地心地固坐标系),原点在地心,单位是米。地表附近的分量很容易达到几百万。比如赤道附近一个点的 X 坐标接近 6,378,137 米,现在有两个网格顶点,相距 0.25 米:

1
2
3
4
5
6
const a = 6378137.25;
const b = 6378137.00;

console.log(a - b); // 0.25
console.log(Math.fround(a) - Math.fround(b)); // 0
console.log(Math.fround(a - b)); // 0.25

第一行用的是 JavaScript Number,也就是 IEEE 754 双精度,差值保得住。

第二行先用 Math.fround 模拟 Float32 再相减:两个大数都被舍入成 6,378,137,0.25 米直接没了。

第三行先在双精度下减,得到 0.25,再转成 Float32 —— 小数仍然表示得出来。

这三行就是 RTE 最核心的判断:先转 Float32 再减大坐标,误差已经发生;先用 Double 做减法,再把小差值交给 GPU,能保留更多局部细节。

具体差多少?Float32 有 24 位有效二进制精度。在约 640 万这个量级,相邻两个可表示数的间隔大约是 0.5 米;同样一个 Float32 如果只表示 1,000 米附近的局部坐标,间隔大约是 0.000061 米,也就是 0.061 毫米。

Float32 没变,变的是它表示的数值范围。而 L7 的格子边长才 7.73 米 —— 0.5 米量级的舍入,在近景就是边线抖动、相邻块对不齐。

真实代码里的 ENU 三件套

项目在 Worker 里自己实现了这套换算,没有依赖 Cesium —— 因为 Worker 里拿不到 Cesium 的渲染环境,这个约束逼出来一份可以在两边跑的纯函数。

第一件是把大地坐标转成 ECEF,用 WGS84 椭球参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// gridWorkerUtils.ts
const WGS84_A = 6378137.0;
const WGS84_E2 = 6.6943799901413165e-3;

function geodeticToEcef(lon: number, lat: number, height: number): [number, number, number] {
const lonRad = degreesToRadians(lon);
const latRad = degreesToRadians(lat);
const sinLat = Math.sin(latRad);
const cosLat = Math.cos(latRad);
const sinLon = Math.sin(lonRad);
const cosLon = Math.cos(lonRad);
const n = WGS84_A / Math.sqrt(1 - WGS84_E2 * sinLat * sinLat);

return [
(n + height) * cosLat * cosLon,
(n + height) * cosLat * sinLon,
(n * (1 - WGS84_E2) + height) * sinLat,
];
}

第二件是构造 ENU 的三根单位轴。这就是解析几何,没有任何魔法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// gridWorkerUtils.ts
function buildEnuBasis(lon: number, lat: number) {
const lonRad = degreesToRadians(lon);
const latRad = degreesToRadians(lat);
const sinLat = Math.sin(latRad);
const cosLat = Math.cos(latRad);
const sinLon = Math.sin(lonRad);
const cosLon = Math.cos(lonRad);

return {
east: [-sinLon, cosLon, 0] as [number, number, number],
north: [-sinLat * cosLon, -sinLat * sinLon, cosLat] as [number, number, number],
up: [cosLat * cosLon, cosLat * sinLon, sinLat] as [number, number, number],
};
}

east 只和经度有关,是纬线圈的切线方向;north 和 up 由纬度的正余弦构成,互相垂直,且 north × up 才是 —— 这里不用验算,它们是标准结果。有一点值得注意:这三根轴只由参考点的经纬度决定,和高度无关。

第三件是把 ECEF 世界坐标投影到这套轴上,也就是那个关键的减法:

1
2
3
4
5
6
7
8
9
10
11
12
// gridWorkerUtils.ts
function worldToEnu(world, reference, basis): [number, number, number] {
const dx = world[0] - reference[0];
const dy = world[1] - reference[1];
const dz = world[2] - reference[2];

return [
dx * basis.east[0] + dy * basis.east[1] + dz * basis.east[2],
dx * basis.north[0] + dy * basis.north[1] + dz * basis.north[2],
dx * basis.up[0] + dy * basis.up[1] + dz * basis.up[2],
];
}

dx / dy / dz 那三行是整段 RTE 里最重要的三行:world - reference 先用双精度算,得到的差值才进入后面所有运算。只要 Tile 不大,这个差值就落在几百或几千米的范围,随后写进 Float32 实例矩阵也不会丢低位。

假设一个 Tile 横向只有 1 公里,实例平移就不再是类似 [-2400000, 4700000, 3500000] 的 ECEF 坐标,而是类似 [132.4, -286.1, 24.8] 的局部偏移。

参考中心是求平均求出来的

参考中心不是一个随便挑的点,是块内所有格子中心的经纬高均值:

1
2
3
4
5
6
7
8
9
10
11
// gridWorkerUtils.ts —— calculateReferenceCenter
let sumLon = 0, sumLat = 0, sumHeight = 0;

for (const grid of grids) {
const bounds = grid.decodeRes;
sumLon += (bounds.minLon + bounds.maxLon) / 2;
sumLat += (bounds.minLat + bounds.maxLat) / 2;
sumHeight += (bounds.minHeight + bounds.maxHeight) / 2;
}

return [sumLon / grids.length, sumLat / grids.length, sumHeight / grids.length];

它和前面算包围球时用的「bbox 中心」不是一回事:这里是按格子平均,不是按空间平均。边缘的格子被切掉一部分时,两者会有差别。按格子平均的结果更贴近实例的实际分布,所有实例到参考点的距离也更均匀。

这也顺手解释了一件事:为什么切块能改善精度。块越小,块内格子的经纬跨度越小,均值点到任何一个格子的距离就越短,局部坐标就越小,Float32 能保留的相对精度就越高。分块不只服务调度和剔除,它同时是精度手段。

第二步:参考中心也不要带着绝对坐标进 shader

局部坐标解决了每个实例的平移,但 Tile 还得放回地球上的正确位置。

最直观的还原公式是 P = O + R × p:p 是 Tile 局部坐标,R 是 ENU 到 ECEF 的旋转,O 是参考中心的 ECEF 坐标。但如果在 GPU 里把 O 和 R × p 相加,先还原成几百万米的世界坐标,再由完整 View 矩阵减相机,前面那个问题就原封不动地回来了。

所以参考中心也得先变成相对相机的位置。设相机 ECEF 坐标为 C,CPU 每帧算:

Oeye = O − C。

顶点在 GPU 里的合成变成:

相对相机位置 = (O − C) + R × p。

相机靠近 Tile 时,O − C 本身也是个小量,参与相加的两项都不再是百万米量级。

1
2
3
4
5
6
7
8
9
CPU 双精度:
网格世界位置 − Tile 参考中心 → 局部实例位置(写进实例矩阵)
Tile 参考中心 − 相机世界位置 → 参考点相对相机(走 uniform)

GPU Float32:
相对相机参考点 + 旋转后的局部顶点
|
v
眼坐标与裁剪坐标

Shader 里为什么只乘 czm_viewRotation

设单位立方体经过实例矩阵后得到局部点 p,ENU 到 ECEF 的旋转为 R,普通世界坐标关系是 P = O + R × p。View 矩阵可以理解成相机旋转 Vrot 和平移 −C 的组合,所以眼坐标是:

Peye = Vrot × (P − C) = Vrot × [(O − C) + R × p]。

这就是 shader 要执行的关系,也是项目里填充和线框两个顶点着色器共用的核心几行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// BeiDouGridPrimitive.ts —— 节选
uniform vec3 u_referenceRelativeToEye; // CPU 算好的 O − C
uniform mat3 u_referenceRotation; // ENU 到 ECEF 的旋转 R

mat4 instanceMatrix = mat4(
modelMatrixRow0, modelMatrixRow1,
modelMatrixRow2, modelMatrixRow3
);
vec4 localPosition = instanceMatrix * vec4(position, 1.0);
vec3 rotatedPosition = u_referenceRotation * localPosition.xyz;
vec3 positionRelativeToEye = u_referenceRelativeToEye + rotatedPosition;
vec3 positionEC = czm_viewRotation * positionRelativeToEye;

gl_Position = czm_projection * vec4(positionEC, 1.0);

用的是 czm_viewRotation,它只包含相机方向 —— 相机平移已经在 O − C 里做掉了。如果这时候再乘一遍含平移的完整 View 矩阵,相机位置会被减两次,整个 Tile 会跑到错误的位置上。

这是最容易犯的错,也是最容易看出来的错:如果 RTE 实现错了减两遍,画面不是抖动,是整块网格飞走。抖动才是精度不足的症状。

法线只需要乘参考旋转和视图旋转,不参与平移,这样光照方向能和盒子的实际朝向保持一致。

相机移动时,不需要重算所有 instanceMatrix

一个 Tile 内的网格没有因为相机移动而改变:局部位置 p、尺寸、颜色、ENU 旋转 R 全都可以继续用。变的只有相机 C。

项目里这件事由 uniform map 的写法定下来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// BeiDouGridPrimitive.ts —— _createUniformMap
private _createUniformMap(): { [key: string]: () => any } {
return {
u_referenceRelativeToEye: () => {
// 每帧动态计算:参考点相对于相机的位置
const cameraPosition = this._scene.camera.positionWC;
Cesium.Cartesian3.subtract(
this._referencePosition, // O,构造时定下,之后不变
cameraPosition, // C,每帧都变
this._referenceRelativeToEye // 复用一个 Cartesian3,不新建对象
);
return this._referenceRelativeToEye;
},
u_referenceRotation: () => { /* 构造时从 modelMatrix 取出,静态 */ },
};
}

三个细节都值得看一眼。

uniform 的值是回调,不是快照。 Cesium 每帧执行命令时调用这些函数取当前值,所以 O − C 自动就是这一帧的,不需要谁去主动通知更新。

_referencePosition 是构造时就定死的,从 modelMatrix 里取出来(Matrix4.getTranslation)。相机怎么飞都不会动它。

第三个参数复用了同一个 Cartesian3。 subtract 有一个接收结果的版本,结果写进 this._referenceRelativeToEye 而不是每次新建一个对象。每帧每个 Tile 调用一次,几百个 Tile 乘 60 帧就是每秒几万次 —— 如果每次都 new,光垃圾回收就够看一阵。

对比一下工作量:一个满的 L7 切片有 8192 个实例。相机每次移动都重算并上传 8192 份矩阵,和更新一个 vec3 uniform,完全不是一个数量级。

这也是局部坐标和 Instancing 特别适合放在一起的原因:实例保存稳定的局部差异,Tile 保存稳定的公共参考系,相机变化只更新批次级数据。

为什么不给每个格子单独选参考点

给每个格子单独挑参考点当然能让局部数字更小,但它把公共数据重新变成了实例数据:10 万个格子就要管 10 万份参考位置和方向,shader 输入、CPU 计算和内存布局都会复杂一圈。

一个 Tile 共用一套参考系,是精度和批处理之间的折中:参考中心离块内格子足够近,同时整批实例只需要一份参考 ECEF 和一份 ENU 旋转。DrawCommand 的 uniform 为整个批次提供这两项,实例属性继续只存矩阵和颜色 —— 也就是第二篇里那 80 字节。

这也给 Tile 大小提出了更明确的标准。它不只要满足调度和缓存,还要保证块内局部坐标落在合适范围。如果某个超长区域被切成一个块,虽然绘制命令很少,坐标精度和剔除效率却会一起变差。

RTE 的收益随块变大而递减:块跨得越大,块内局部坐标本身越大,就越接近原来的问题;相机靠近块的一侧时,O − C 和 R × p 还可能分别很大、方向相反,GPU 又得让两个大数相加得到小结果。所以这套方案面向的是城市和局部区域分块。全球范围需要适合球面数据的中心计算和更严格的空间层次,跨越国际日期变更线时也不能直接对经度做算术平均。

为什么不直接在 shader 里用 double

WebGL 面向浏览器和广泛的消费级 GPU,常规的顶点属性和矩阵管线以 Float32 为主。即使某些硬件或扩展提供更高精度,也不能把一套跨设备的 Web3D 方案建立在「所有顶点都按双精度高速执行」这个假设上。

而且实例矩阵、颜色和顶点缓冲本身已经按 Float32 组织。如果数据上传时低位信息已经丢了,后面临时换一种高精度运算类型也还原不回来。

RTE 的价值恰好在这里:它不要求 GPU 改变自己擅长的数据类型,而是在 CPU 和 GPU 之间重新分配工作 —— CPU 负责绝对定位和高精度差值,GPU 负责大量局部顶点的并行旋转、投影和着色。

RTE、high/low 拆分和浮动原点有什么区别

大坐标精度有好几种处理方式,目标相似,数据组织不同。

方法 主要做法 更适合的场景
Tile 局部坐标 + RTE 每块固定局部原点,每帧算原点减相机 分块地理数据
high/low 编码 双精度位置拆成两份 Float32,shader 里按专门顺序组合 需要在 shader 里保留更完整的世界位置
浮动原点 相机移动后,周期性把整个场景重新定位到新原点 可统一管理的本地大场景

这套网格用的是第一种。Cesium 的一些内部路径会走类似 high/low 的思路,但那不是这份实例数据的布局。浮动原点常见于 Three.js 的超大场景,代价是要协调物理、动画和所有世界对象一起重定位。

三者可以组合,但不能因为都在解决精度问题,就当成同一套实现。

RTE 不解决哪些闪烁

看到画面抖动时,先判断问题是不是真的来自坐标精度。

两个表面几乎共面时的 z-fighting,要调几何关系、深度精度或渲染策略;包围体偏小导致的边缘消失,要修剔除范围;经纬度、高度基准或坐标转换错了,也不是减去相机就能修好。透明面排序、线宽上限和抗锯齿影响的是观感,同样不属于 RTE 的职责。

这套变换围绕 Cesium 的三维 ECEF 场景设计。切到二维地图或 Columbus View 时,需要重新确认坐标和相机矩阵的含义,不能因为三维模式稳定就默认其他模式自动成立。

怎么验证精度方案真的生效

把相机贴近几米级的网格,沿 Tile 边界缓慢平移和旋转,重点看三件事:

  1. 同一块内的边线是否随着相机移动发生跳动。
  2. 相邻块的网格是否在边界保持对齐。
  3. 相机停下之后,画面是否还有周期性漂移。

还可以在数据阶段直接记录局部坐标的范围。如果一个小 Tile 的实例平移仍然达到几十万或几百万,那就说明参考中心或者坐标转换没有真正把数值压下来 —— 这个检查比看画面可靠,因为它不依赖肉眼能不能分辨亚像素的抖动。

比较方案时要用同一相机轨迹、相同分辨率和同一批网格。这类结论更适合用稳定的近景录屏或数值误差来验证,而不是靠某一张截图。

这套方案最终保住的不是抽象的「高精度」,而是一个明确的计算顺序:世界定位留在 CPU 双精度里,Tile 内的差异变成局部量,相机平移提前消掉,GPU 只承担适合 Float32 的那部分变换。第五篇讲完了坐标,下一篇回到更具体的问题:L7 那 512 倍的量,到底是怎么在水平和高度两个方向上被切开的。


上一篇:Web Worker 与 Transferable · 下一篇:L7 的水平微块与高度切片 · 系列分类

北斗网格性能优化(五):解决大坐标精度问题——RTE 与局部坐标系

https://wywzixin.github.io/beidou-grid-performance/05-rte/

作者

wangyawei

发布于

2026-09-12

更新于

2026-09-15

许可协议