北斗网格性能优化(五):解决大坐标精度问题——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 | const a = 6378137.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 | // gridWorkerUtils.ts |
第二件是构造 ENU 的三根单位轴。这就是解析几何,没有任何魔法:
1 | // gridWorkerUtils.ts |
east 只和经度有关,是纬线圈的切线方向;north 和 up 由纬度的正余弦构成,互相垂直,且 north × up 才是 —— 这里不用验算,它们是标准结果。有一点值得注意:这三根轴只由参考点的经纬度决定,和高度无关。
第三件是把 ECEF 世界坐标投影到这套轴上,也就是那个关键的减法:
1 | // gridWorkerUtils.ts |
dx / dy / dz 那三行是整段 RTE 里最重要的三行:world - reference 先用双精度算,得到的差值才进入后面所有运算。只要 Tile 不大,这个差值就落在几百或几千米的范围,随后写进 Float32 实例矩阵也不会丢低位。
假设一个 Tile 横向只有 1 公里,实例平移就不再是类似 [-2400000, 4700000, 3500000] 的 ECEF 坐标,而是类似 [132.4, -286.1, 24.8] 的局部偏移。
参考中心是求平均求出来的
参考中心不是一个随便挑的点,是块内所有格子中心的经纬高均值:
1 | // gridWorkerUtils.ts —— calculateReferenceCenter |
它和前面算包围球时用的「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 | CPU 双精度: |
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 | // BeiDouGridPrimitive.ts —— 节选 |
用的是 czm_viewRotation,它只包含相机方向 —— 相机平移已经在 O − C 里做掉了。如果这时候再乘一遍含平移的完整 View 矩阵,相机位置会被减两次,整个 Tile 会跑到错误的位置上。
这是最容易犯的错,也是最容易看出来的错:如果 RTE 实现错了减两遍,画面不是抖动,是整块网格飞走。抖动才是精度不足的症状。
法线只需要乘参考旋转和视图旋转,不参与平移,这样光照方向能和盒子的实际朝向保持一致。
相机移动时,不需要重算所有 instanceMatrix
一个 Tile 内的网格没有因为相机移动而改变:局部位置 p、尺寸、颜色、ENU 旋转 R 全都可以继续用。变的只有相机 C。
项目里这件事由 uniform map 的写法定下来:
1 | // BeiDouGridPrimitive.ts —— _createUniformMap |
三个细节都值得看一眼。
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 边界缓慢平移和旋转,重点看三件事:
- 同一块内的边线是否随着相机移动发生跳动。
- 相邻块的网格是否在边界保持对齐。
- 相机停下之后,画面是否还有周期性漂移。
还可以在数据阶段直接记录局部坐标的范围。如果一个小 Tile 的实例平移仍然达到几十万或几百万,那就说明参考中心或者坐标转换没有真正把数值压下来 —— 这个检查比看画面可靠,因为它不依赖肉眼能不能分辨亚像素的抖动。
比较方案时要用同一相机轨迹、相同分辨率和同一批网格。这类结论更适合用稳定的近景录屏或数值误差来验证,而不是靠某一张截图。
这套方案最终保住的不是抽象的「高精度」,而是一个明确的计算顺序:世界定位留在 CPU 双精度里,Tile 内的差异变成局部量,相机平移提前消掉,GPU 只承担适合 Float32 的那部分变换。第五篇讲完了坐标,下一篇回到更具体的问题:L7 那 512 倍的量,到底是怎么在水平和高度两个方向上被切开的。
北斗网格性能优化(五):解决大坐标精度问题——RTE 与局部坐标系