S01E23:计算几何中的误差来源分析

1,298 阅读4分钟

说明

从开篇时,我们就遇到过计算精度问题,在前面的代码中,当我们遇到需要判断点是否重合,向量是否平行/垂直,向量是否是零(长度过短),都是笼统地用小于 0.0001 或者 Swift 提供的 Float 类型最小值Float.leastNormalMagnitude来简单判断。

事实上,这些是不合理的,不加区分全导致出现莫名其妙的问题,本文我们来简单讨论下这些误差的种类,并尝试做出针对性的处理。需要说明的是,我们是为了工程上的合理,项目中的无 bug,效果的相对一致,并不刻意追求数学原理上的完美和精确。

二进制与浮点数

按照国际标准 IEEE 754,任意一个二进制浮点数可以表示成:符号位 + 指数位 + 尾数位。以 Float 类型为例,32 位浮点型,最高一位是符号位s,接着的8位是指数位E,最后的23位是有效数字M。

那么误差发生在哪里?一是 10 进制与 2 进制转换,会有不等价问题;二是有效数字长度有限,指数长度有限,带来的精度丢弃或溢出问题。

如何处理

对于这些涉及数学原理和计算机原理的问题,本系列文章统一不做处理!!!是的,拿来直接用,不做处理。

还有一个问题,浮点数还会分规格化和非规格化(Normal or Subnormal),本系列中统一按规格化处理,即最小值Float.leastNormalMagnitude,而不是Float.leastNonzeroMagnitude

运算过程

运算过程带来的问题大概有两种:

  • 计算结果溢出:比如计算过程中用到平方,就可能会太大或太小导致溢出;
  • 正逆运算无法抵消:比如乘以 3 再除以3,cos 运算再 acos 运算,理论上应该回到初始值,但是最后结果不一致。

如何处理

针对这些问题,也没有完美的策略,一般来说我们可以调整代码,采用提前计算或延迟计算某些值,优先避免溢出,再考虑精度问题。

比如,我们要计算 10 万个点的平均值,当然是先将它们加起来,再除以总数量,计算又快精度也高。但是这样在求和的过程中,就有溢出的风险,所以可以将每个点先除以总数量,再把这些微小值加起来得到平均值。但这样显然速度慢一些,精度也差一些。

而延迟计算的本质,其实是将问题统一丢给编译器,期待现代编译器的强大优化能力,能够帮助我们简化运算,并提高精度。比如先乘以 3 再除以 3,这样的表达式会被编译器直接优化掉。

近似算法与性能优化

在 3D 和 AR 中,为了提高效率,是有很多近似算法和性能优化算法的,它们也会带来误差。一般来说,我们可以认为:系统自带和知名第三方框架中的函数和运算都是可靠的,除非是极小或极大的特殊情况,否则无需处理。

另一种是,则是我们自己的近似算法,比如在计算向量长度时,用长度的平方来代替长度,比较角度大小时用 cosA 来代替 A 进行比较。

如何处理

如果是系统框架或知名第三方的,一般不用处理,绝大部分情况下是够用了。

而自己的替代算法也需要自行处理。比如使用向量长度平方来比较大小,也比较好处理:原来是计算 ||ab|| > 100,现在应该是 ||ab|| * ||ab|| > 100 * 100。

而三角函数其实是需要特殊处理的,如果只是简单比较夹角的大小(0~180):a > b,那么改为 cos(a) < cos(b) 并没有太多问题。

但是当我们在判断是否接近垂直或平行时,就可能会有问题:比如当 x 接近 90 度时,判断 cos(x) 绝对值小于 0.01 精度高,而判断 sin(x) 大于 0.99 则精度差。这是为什么呢?要从函数图像说起,严格地说是图像的斜率(即导数): 不管是 sin(x) 还是 cos(x),它们的规律是:斜率在 y = 0 点变化最大,在 y = ±1 时,斜率最小。这意味着:以弧度制计算,当 x 接近 90 度时,x 每变化 0. 01,cos(x) 值也会变化 -0.01;而 sin(x) 值变化 ±0.00005,几乎相当于不变。

这就是为什么,我们在判断向量是否垂直时,会使用 cos(x) (点乘)来判断;而判断向量是否平行时,则会使用 sin(x) (叉乘)来判断;

几何数据的物理意义

一般来说,3D 和 AR 中,我们并不是太在乎误差的存在,我们只希望它相对稳定且统一就行。

尤其是在 AR 中,和真实世界相关联,当两个点距离 0.0001 时,即 0.1 毫米时,就几乎看不出区别了,除非你离的特别近或者放大去看细节。所以距离和长度,需要考虑观察者的距离(最终显示出来的大小)。

而对于角度来说,当两个方向夹角小于 0.0001时,也是看不出区别的,当然角度有特殊性,如果足够远就可以看到偏差,十公里外区别就很明显。所以当我们用点乘来判断向量是否平行时,应该考虑向量长度的影响;同理,用叉乘判断向量是否垂直时,也要考虑向量长度的影响。

另一个需要注意的是,在 3D 和 AR 中,物理量还有可能是来自用户的输入(比如用户的移动、点击、选中等),当用户移动了 0.1 毫米,或者旋转了 0.0001 弧度,这几乎没有意义,很可能是无意义的干扰而已。比如对一条极短的向量进行归一化,很可能就放大了误差,意义不大。

如何处理

我认为合理处理的方法就是:

  • 区别不同的物理量,如距离,长度,角度等,分别进行处理。如计算角度时应该忽略向量长度的影响;
  • 提供近似相等选项,允许误差范围内相等;

比如下面的代码,使用误差范围来计算,并在计算角度时消除了向量长度的影响。为了方便后续计算,返回角度关系的同时,还返回点乘和叉乘的值。

extension simd_float3 {
    func almostSamePoint(to point:simd_float3, tol:Float = 0.0001) -> (isSame:Bool, distanceSquared:Float) {
        let distanceSquared = distance_squared(self, point)
        return (isSame:distanceSquared < tol * tol, distanceSquared:distanceSquared)
    }
    
    func almostParallelRelative(to vector:simd_float3, tol:Float = 0.0001) -> (isParallel:Bool, crossValue:simd_float3) {
        let lengthS1 = length_squared(self)
        let lengthS2 = length_squared(vector)
        let crossValue = cross(self, vector)
        
        let isParallel = length_squared(crossValue)/lengthS1/lengthS2 < tol * tol
        
        return (isParallel:isParallel, crossValue:crossValue)
    }
    
    func almostPerpendicular(to vector:simd_float3, tol:Float = 0.0001) -> (isPerpendicular:Bool, dotValue:Float) {
        let lengthS1 = length_squared(self)
        let lengthS2 = length_squared(vector)
        let dotValue = dot(self, vector)
        let isPerpendicular = dotValue * dotValue / lengthS1 / lengthS2 < tol * tol
        
        return (isPerpendicular:isPerpendicular, dotValue:dotValue)
    }
}

总结

计算几何并不是数学,误差只要控制在一定范围内且稳定,就可以满足工程需要。总结下来其实就两个办法:

  • 不管,不处理;
  • 根据具体情况,自己想办法;

听起来很扯蛋,但这就是事实。

项目代码

本系列文章代码已发布在 github:ComputationalGeometrySwift