three.js地图数学基础(八):浮点精度

浮点精度:坐标正确,为什么画面仍会抖

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

1. 浮点数不是均匀刻度

规格化二进制浮点数可写成:
value=(−1)s×(1.f)2×2evalue=(-1)^s\times(1.f)_2\times2^e value=(−1)s×(1.f)2×2e

Float32 有:

  • 1 个符号位;
  • 8 个指数位;
  • 23 个显式尾数位,加隐藏的最高位,共 24 位有效二进制精度。

当数值落在 [2^e,2^{e+1}),相邻 Float32 的间距约为:
ULP32=2e−23 \boxed{ULP_{32}=2^{e-23}} ULP32=2e−23

ULP(unit in the last place)随量级指数增长。小数附近刻度很密,十亿附近刻度很粗。

2. 一个地图尺度表

若一整个 Web Mercator (EPSG:3857) 世界的渲染宽度定义为:
worldSize=tileSize⋅2zoomworldSize=tileSize\cdot2^{zoom} 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 ULP_{64}=2^{e-52} ULP64=2e−52

2^31 附近,Float64 ULP 是:
231−52=2−21≈4.77×10−72^{31-52}=2^{-21}\approx4.77\times10^{-7} 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 X_r=(x_m-o_x)\cdot worldSize Xr=(xm−ox)⋅worldSize
Zr=(ym−oy)⋅worldSize Z_r=(y_m-o_y)\cdot worldSize Zr=(ym−oy)⋅worldSize

高度单独处理:
Yr=hp−ho Y_r=h_p-h_o Yr=hp−ho

于是:
pr=(Xr,Yr,Zr) \boxed{p_r=(X_r,Y_r,Z_r)} pr=(Xr,Yr,Zr)

关键计算顺序 :上述 (xm−ox)⋅worldSize (x_m - o_x) \cdot worldSize (xm−ox)⋅worldSize 的减法与乘法必须全程在 CPU Float64 下完成 ,最后得到的小数值结果才转换为 Float32 上传给 GPU。如果误将 xm x_m xm 与 ox o_x ox 先转换为大数绝对坐标再相减,将重新引入精度丢失。

本书把 Mercator y 映射到 Three.js +Z,高度映射到 +Y。这正是 toRenderLocal 的契约。

原点可选择:

  • 当前相机中心;
  • 当前视口锚点;
  • 每个瓦片的左上角;
  • 一个短时间内稳定的区域锚点。

Stage 01 选择"当前瓦片左上角",所以 local x/z 都约在 [0,512]。后续连续地图会选择视口附近的稳定原点,并让所有可见瓦片共享同一帧语义。

5. 原点移动不是无成本的

如果每个微小相机变化都移动原点,所有实例矩阵或 uniform 会不停更新;若几何顶点已经预先烘焙到某个原点,还可能被迫重建。一个实用策略应定义:

  1. 何时 origin 仍可复用;
  2. 超过多远才 rebase;
  3. rebase 时更新实例矩阵、相机和拾取结构的顺序;
  4. 同一帧哪些对象必须共享同一个 origin generation;
  5. 旧异步结果如何识别自己基于过期 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+lowvalue=high+low 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. 精度契约

  1. 经纬度、Mercator 和视口原点在 CPU 上保持 Float64;
  2. 在上传 Float32 前减去附近原点;
  3. MVT tile-local 尽量保持整数或小范围浮点;
  4. 同一帧的相机、实例和拾取使用同一 origin generation;
  5. high/low 只有在确有跨大范围绝对坐标需求时才引入;
  6. 定位抖动时分别检查位置 ULP 与深度缓冲,不混为一谈。

至此,坐标、矩阵、相机和精度四条数学基础已经连接起来。下一篇教程会用 Stage 01 把它们做成可操作的坐标检查器。


相关推荐
SendTomo1 小时前
跨设备文件互传新方案
javascript·网络·webrtc·html5·p2p
__zRainy__1 小时前
Node系列 · Node基础:全局变量与全局对象
开发语言·前端·javascript
sunly_2 小时前
TypeScript总结:15、类型速查
前端·javascript·typescript
Brown.alexis2 小时前
es6知识点3-自备使用
前端·javascript·es6
breeze jiang2 小时前
Next.js App Router 父子组件 RSC 拆分实战:从 Redis Hash 到客户端交互的最小化边界
javascript·redis·哈希算法
丫头,冲鸭!!!2 小时前
记账网站-post功能
javascript·个人开发
鬼手点金2 小时前
Scrapy + Playwright 完整示例(JS 动态渲染网页)
开发语言·javascript·爬虫·python·scrapy·html·json
逝水无殇2 小时前
HTML 文本格式化详解:掌握 <strong>、<em>、<mark>、<del> 等常用标签
前端·javascript·html
光影少年3 小时前
react离线缓存、图片缓存方案
开发语言·前端·javascript·react native·react.js·缓存·前端框架