40 · 浮点误差与退化情况
1. 小数不是精确的实数
JavaScript Number 通常按双精度浮点存储,BufferAttribute 常用 Float32Array,GPU 还受着色器精度与设备实现约束。从 CPU 到 GPU 的过程中,有效精度可能降低。
console.log(0.1 + 0.2 === 0.3); // false
const gpuLike = new Float32Array([100000000, 100000001]);
console.log(gpuLike[0] === gpuLike[1]); // true:这一级别的差异已无法表示
因此 Vector3.equals() 适合检查精确存储值,不适合验证一般变换往返结果。
2. 容差应有业务含义
对于位置,可以用距离容差:
function samePosition(a, b, tolerance = 0.001) {
return a.distanceToSquared(b) <= tolerance * tolerance;
}
这里 tolerance 与坐标同单位。建筑模型可以接受 1 毫米,微小零件也许不能;不要把 Number.EPSILON 当成所有场景通用误差。它描述接近 1 时的浮点间隔,不是空间分辨率。
比较尺度跨度很大的标量可结合绝对和相对容差:
function nearlyEqual(a, b, absolute = 1e-9, relative = 1e-6) {
if (!Number.isFinite(a) || !Number.isFinite(b)) return false;
return Math.abs(a - b) <= absolute + relative * Math.max(Math.abs(a), Math.abs(b));
}
本例把非有限值视为无效数据,否则有限数与 Infinity 比较时可能误判为 Infinity <= Infinity。容差参数应为非负有限数。误差阈值也不能无限放宽,否则会把真正的坐标转换错误隐藏掉。
3. 零向量和近平行方向
function normalizeOrKeep(direction, previous, out, epsilon = 1e-8) {
if (!Number.isFinite(direction.x) || !Number.isFinite(direction.y) ||
!Number.isFinite(direction.z)) return false;
const lengthSq = direction.lengthSq();
if (!Number.isFinite(lengthSq)) return false; // 有限分量的平方和也可能溢出。
if (lengthSq <= epsilon * epsilon) {
out.copy(previous); // 调用者保证 previous 是有效单位方向
return false;
}
out.copy(direction).normalize();
return true;
}
返回 false 表示未产生新的可靠方向,调用者可以停止转向。Three.js 的某些 API 对零输入给出确定实现结果,例如保留零向量;这只是避免崩溃的行为,不代表退化几何拥有唯一答案。
构造叉积基底时,应检测归一化方向之间的夹角或叉积长度;原始叉积阈值受尺度影响。反射和平面投影通常要求单位法线,法线为零时应拒绝或跳过本次计算。
4. 奇异矩阵与病态矩阵不同
某个轴缩放为零,空间被压扁,逆矩阵不存在。r185 的 Matrix3.invert 遇到 determinant 严格等于零时返回零矩阵。不要把这个零矩阵当成有效的逆变换继续使用。
行列式非零也不保证逆向计算稳定。例如一个轴缩小到 1e-12,求逆会把该轴误差放大 1e12 倍。另一方面,整体均匀缩小也能得到很小行列式,却不一定意味着严重的相对病态。因此不能用固定的 abs(det)<常量 对所有尺度做万能判断。
日常可先约束缩放范围,检测矩阵元素是否有限,计算逆矩阵后验证 M * inverse(M) 是否接近单位矩阵,并结合输入尺度评估误差;更严格应用需要条件数分析。
5. 大世界坐标与时间精度
城市或地理应用中,直接把巨大的绝对坐标放入 Float32 顶点会丢失细小变化。常见办法是保留业务绝对坐标,而用相机附近的参考原点生成较小的渲染坐标;平移参考原点时,要同步物体、粒子、查询数据和缓存。
长时间运行的 Shader 若一直增大 float 时间,微小时间步最终可能难以区分。周期效果可以传取模时间,但必须保证跨周期连续;带历史状态的模拟不能随意清零时间。
6. 时间步也影响稳定性
渲染 dt 波动会影响显式积分和碰撞。将 dt 截断可以防止后台恢复后的巨大跳跃,但会丢弃真实时间。物理或确定性模拟通常使用固定步长累积器,并限制每帧补步数量;不要为追赶积压无限循环。
let accumulator = 0;
const fixedStep = 1 / 60;
function update(frameDt, simulate) {
accumulator = Math.min(accumulator + frameDt, fixedStep * 5);
while (accumulator >= fixedStep) {
simulate(fixedStep);
accumulator -= fixedStep;
}
}
该示例有意限制最多约五步并丢弃过多积压,适合交互演示。需要真实时间一致性的系统应明确补偿策略;固定步长本身也不会自动解决高速穿透。
练习(不附答案)
- 比较精确 equals 与距离容差对 project/unproject 往返结果的判断。
- 给归一化函数输入零向量、非常小向量和 NaN,设计明确的返回策略。
- 分别构造零缩放、单轴极小缩放、整体均匀小缩放,观察逆矩阵和误差。
- 模拟一次 2 秒的帧间隔,比较截断 dt、固定步长补步和无限追赶的结果与代价。