浮点精度:坐标正确,为什么画面仍会抖
JavaScript 在 CPU 上用 IEEE 754 Float64 表示普通数字;WebGL 顶点属性、缓冲和许多 shader 运算通常使用 Float32。把一个巨大绝对世界坐标直接转换为 Float32 时,低位会被舍去。相机移动后,顶点之间本应稳定的细小差异可能跨过不同舍入边界,于是道路边缘抖动、相邻瓦片开裂、建筑贴面闪烁。

1. 浮点数不是均匀刻度
规格化二进制浮点数可写成:
value=(−1)s×(1.f)2×2e
Float32 有:
- 1 个符号位;
- 8 个指数位;
- 23 个显式尾数位,加隐藏的最高位,共 24 位有效二进制精度。
当数值落在 [2^e,2^{e+1}),相邻 Float32 的间距约为:
ULP32=2e−23
ULP(unit in the last place)随量级指数增长。小数附近刻度很密,十亿附近刻度很粗。
2. 一个地图尺度表
若一整个 Web Mercator (EPSG:3857) 世界的渲染宽度定义为:
worldSize=tileSize⋅2zoom
并取 tileSize=512:
| 数值量级 | 二进制指数 e | Float32 ULP | 地图含义 |
|---|---|---|---|
| 1 | 0 | 1.1920929×10⁻⁷ |
normalized 世界附近 |
| 512 | 9 | 0.00006103515625 |
一个 tile-local 宽度 |
| 4,194,304 | 22 | 0.5 |
z13 完整世界宽度 |
| 2,147,483,648 | 31 | 256 |
z22 完整世界宽度 |
在 z22 世界边缘附近,Float32 甚至无法表达相差 1 个 world unit 的两个数;它们可能舍入成同一个值。把道路宽度、接缝微调或亚像素动画叠加到这种绝对坐标上没有意义。
normalized Mercator 看起来总在 [0,1],似乎没有问题。但若 shader 中再乘巨大 worldSize,乘法后的有效精度仍受限制;而且相邻瓦片在各自计算中可能产生不同舍入。精度策略必须覆盖整个变换链。
3. Float64 为什么能撑得更久
Float64 有 53 位有效二进制精度,间距约为:
ULP64=2e−52
在 2^31 附近,Float64 ULP 是:
231−52=2−21≈4.77×10−7
因此 CPU 可以准确完成"两个巨大且接近的世界坐标相减"。关键是:相减必须发生在仍为 Float64 的时候。
错误顺序:
text
Float64 absolute point
→ cast Float32
→ subtract Float32 origin
→ detail already lost
正确顺序:
text
Float64 absolute point − Float64 nearby origin
→ small local delta
→ cast/upload Float32
舍弃的是小数值的低位,而不是巨大绝对坐标的低位,局部细节得以保留。
4. Render-local 原点
设 Mercator 点为 p_m=(x_m,y_m),邻近视口的原点为 o_m=(o_x,o_y):
Xr=(xm−ox)⋅worldSize
Zr=(ym−oy)⋅worldSize
高度单独处理:
Yr=hp−ho
于是:
pr=(Xr,Yr,Zr)
关键计算顺序 :上述 (xm−ox)⋅worldSize 的减法与乘法必须全程在 CPU Float64 下完成 ,最后得到的小数值结果才转换为 Float32 上传给 GPU。如果误将 xm 与 ox 先转换为大数绝对坐标再相减,将重新引入精度丢失。
本书把 Mercator y 映射到 Three.js +Z,高度映射到 +Y。这正是 toRenderLocal 的契约。
原点可选择:
- 当前相机中心;
- 当前视口锚点;
- 每个瓦片的左上角;
- 一个短时间内稳定的区域锚点。
Stage 01 选择"当前瓦片左上角",所以 local x/z 都约在 [0,512]。后续连续地图会选择视口附近的稳定原点,并让所有可见瓦片共享同一帧语义。
5. 原点移动不是无成本的
如果每个微小相机变化都移动原点,所有实例矩阵或 uniform 会不停更新;若几何顶点已经预先烘焙到某个原点,还可能被迫重建。一个实用策略应定义:
- 何时 origin 仍可复用;
- 超过多远才 rebase;
- rebase 时更新实例矩阵、相机和拾取结构的顺序;
- 同一帧哪些对象必须共享同一个 origin generation;
- 旧异步结果如何识别自己基于过期 origin。
这与瓦片 generation 的思想相同:数值原点也是带版本的共享事实,不能在渲染中途悄悄改变。
6. 把一个 Float64 拆成 high/low
有些场景不能先在 CPU 烘焙所有局部坐标,例如大量实例共享绝对位置,或相机原点在 shader 中统一变化。可以把一个 Float64 拆为两个 Float32 可表示的分量:
js
const high = Math.fround(value);
const low = Math.fround(value - high); // 显式转为 Float32
满足:
value=high+low
其中等式在 CPU Float64 中成立。上传时 high/low 分别是 Float32 属性或 uniform。shader 不应简单把巨大 high + low 先相加;那会再次丢掉 low。常见做法是分别与相机 high/low 相减:
glsl
vec3 relative = (positionHigh - cameraHigh)
+ (positionLow - cameraLow);
先抵消巨大公共部分,再合并较小残差。
工程踩坑提示 :部分移动端 GPU 编译器在优化时可能依据结合律将算式重排为
(positionHigh + positionLow) - ...,这会导致 low 提前与 high 相加而重新丢失精度。工程上可通过分步赋值给中间变量,或在 GLSL 中使用precise关键字禁用宽松浮点优化来规避。
high/low 是更复杂的精度工具,不应替代简单局部原点。若每个瓦片本来就能稳定地以局部坐标构建,优先保持 typed attributes 小而紧凑。
7. 相邻瓦片为什么会开缝
两个瓦片共享同一地理边界,但若各自执行不同的浮点路径:
text
tile A: (xA + uA / extent) * worldSize
tile B: (xB + uB / extent) * worldSize
理论结果相同,浮点舍入顺序却可能不同。降低裂缝风险需要:
- 共享相同的边界公式和 origin;
- 先在 tile-local 中保持整数/稳定比例;
- 相邻边界采用相同量化规则;
- 避免一个瓦片在 CPU 展开、另一个在 shader 展开;
- 对填充几何使用一致裁剪和缓冲区规则。
"把所有位置乘一个更大的数"不会提升精度;它常常只会增加绝对量级。
8. 深度精度是另一件事
顶点位置抖动来自 x/y/z 数值的尾数不足;z-fighting 常来自投影后的深度缓冲分布。二者可能同时出现,但解决方式不同:
- render-local / high-low 解决世界位置精度;
- 合理
near/far、多层深度策略或 polygon offset 解决深度竞争; - 不能用无限增大
far解决瓦片可见范围; - 不能用把几何抬高一大截掩盖绝对位置抖动。
9. 如何测量,而不是凭肉眼猜
对一个已知绝对坐标 v:
js
const v32 = Math.fround(v);
const loss = v - v32;
再比较两条路径:
js
// 路径 A:先转 Float32,再相减
const bad = Math.fround(v) - Math.fround(origin);
// 路径 B:先用 Float64 相减,再转 Float32
const good = Math.fround(v - origin);
选取只差很小量的 v/origin,就能看到 bad 变成 0 或跳成粗粒度,而 good 仍保留差值。Stage 01 会显示绝对世界 X 的 high/low,以及同一点相对瓦片原点的 local x/z。
10. 精度契约
- 经纬度、Mercator 和视口原点在 CPU 上保持 Float64;
- 在上传 Float32 前减去附近原点;
- MVT tile-local 尽量保持整数或小范围浮点;
- 同一帧的相机、实例和拾取使用同一 origin generation;
- high/low 只有在确有跨大范围绝对坐标需求时才引入;
- 定位抖动时分别检查位置 ULP 与深度缓冲,不混为一谈。
至此,坐标、矩阵、相机和精度四条数学基础已经连接起来。下一篇教程会用 Stage 01 把它们做成可操作的坐标检查器。