《游戏引擎实现原理》10.3 数值稳健:epsilon、饱和与除零

数值计算的另一类问题是边界而非精度:比较不能用等号、除法会炸出 Infinity、NaN 会顺着运算扩散。本节实现一组数值稳健工具——相对 epsilon 比较、saturate、clamp、safeDiv、sanitize、safeNormalize,并用 vitest 把每个边界行为钉成断言。

10.3 数值稳健:epsilon、饱和与除零

10.1 讲浮点不可复现,10.2 用定点数换确定性。但数值计算里还有一类问题,和「精度」「确定性」都没关系,只和边界有关:

  • 两个浮点数「差不多相等」,怎么判断?
  • 一个比例值该落在 [0, 1] 里,结果算出了 1.0000000002 或 NaN,怎么办?
  • 一次除法,分母恰好是 0,结果炸成 Infinity,然后这个 Infinity 顺着运算一路污染下去,最后整个场景的位置都变成 NaN。

这些问题不解决,程序不会崩,但会静默地产生错误结果——而静默的错误比崩溃更难查。这一节实现 helio 的数值稳健工具,把每个边界行为都钉成可断言的测试。

为什么不能写 a === b

浮点相等判断最经典的坑,10.1 已经埋下:0.1 + 0.2 === 0.3 是 false。两条数学上等价的路径,浮点里常常不相等。所以判断「相等」要换成「差得足够小」:

|a - b| <= epsilon

但这里有个陷阱:epsilon 取多少? 如果取固定的绝对值,比如 1e-6,那比较 1e8 和 1e8 + 1 时会说「相等」(因为差 1 > 1e-6 其实是 false……但比较 1e8 和 1e8 + 0.5 呢?)——总之,固定的绝对阈值要么对大数太严、要么对小数太松。

正解是相对 epsilon:让阈值随参与比较的量级缩放。

|a - b| <= epsilon * max(1, |a|, |b|)

那个 max(1, ...) 是为了兜底:当 a 和 b 都接近 0 时,相对阈值会退化成 0,所以用 1 兜住,让「接近零的量」用绝对阈值判断。这一行是 helio 里所有浮点比较的地基。

为什么非得用相对阈值?因为浮点在大数上会丢掉低位。本机实测:1e16 + 1 === 1e16 是 true——在 1e16 这个量级上,双精度根本表示不出「加 1」这件事,差直接被抹成 0。于是同一组数字,用固定绝对阈值 1e-6 和用相对阈值,结论会相反:

比较绝对阈值 1e-6相对阈值 1e-6
0.1 + 0.2 vs 0.3差 5.5e-17 ≤ 1e-6,相等相等
1e8 vs 1e8 + 1差 1 > 1e-6,不等阈值 100 ≥ 1,相等

第二行就是分歧点:在 1e8 的量级上,「差 1」的相对误差只有 1e-8,本应算作相等;但绝对阈值 1e-6 会判它们不等。用哪个阈值,取决于你的量级——这正是 approxEqual 用 max(1, |a|, |b|) 缩放的原因。

可运行产物:一组数值稳健工具

下面是 helio 内核里 src/math/numerical-robustness.ts 的完整实现。它一共七个导出:epsilon 比较两个、饱和两个、除法防护一个、NaN 净化一个、安全归一化一个。层级 L1,Node 直跑。

// file: src/math/numerical-robustness.ts
// tier: L1
// verify: npx vitest run src/math/numerical-robustness.test.ts
//
// helio 内核:数值稳健工具。epsilon 比较、饱和、安全除法、NaN 净化。

/** 默认相对误差阈值。 */
export const EPS = 1e-6;

/** 相对 epsilon 比较:对大数宽容、对小数严格。 */
export function approxEqual(a: number, b: number, eps: number = EPS): boolean {
  const diff = Math.abs(a - b);
  const scale = Math.max(1, Math.abs(a), Math.abs(b));
  return diff <= eps * scale;
}

/** 绝对 epsilon 比较:适合「本该等于 0」的量。 */
export function approxZero(a: number, eps: number = EPS): boolean {
  return Math.abs(a) <= eps;
}

/** 饱和到 [0, 1]。 */
export function saturate(x: number): number {
  if (Number.isNaN(x)) return 0;
  if (x < 0) return 0;
  if (x > 1) return 1;
  return x;
}

/** 钳制到 [lo, hi],并保证 lo <= hi。 */
export function clamp(x: number, lo: number, hi: number): number {
  if (Number.isNaN(x)) return lo;
  if (lo > hi) return lo;
  if (x < lo) return lo;
  if (x > hi) return hi;
  return x;
}

/** 安全除法:分母接近 0、非有限或结果非有限时返回 fallback。 */
export function safeDiv(a: number, b: number, fallback = 0, eps: number = EPS): number {
  if (!Number.isFinite(b) || Math.abs(b) < eps) return fallback;
  const r = a / b;
  return Number.isFinite(r) ? r : fallback;
}

/** NaN / Infinity 净化:非有限值一律换成 fallback。 */
export function sanitize(x: number, fallback = 0): number {
  return Number.isFinite(x) ? x : fallback;
}

/** 安全归一化:长度为 0 时返回零向量,而不是 NaN。 */
export function safeNormalize(x: number, y: number, z: number): [number, number, number] {
  const len = Math.sqrt(x * x + y * y + z * z);
  if (len < EPS) return [0, 0, 0];
  return [x / len, y / len, z / len];
}

七个函数里有四个专门对付「异常值」:

  • saturate 把比例压进 [0, 1],并且把 NaN 映射成 0——因为一个 NaN 比例继续往下传,只会污染更多东西,不如就地归零。
  • clamp 比 saturate 通用,并且处理了「区间本身倒置」(lo > hi)的退化情形,此时退化为 lo。
  • safeDiv 是除零防护的核心:分母接近 0(小于 EPS)、分母本身是 Infinity/NaN、或者商算出来是非有限值,三种情况统统返回 fallback。注意它连「分母非有限」也挡了——因为 1 / Infinity 会得到 0,那是个看似正常实则错误的结果。
  • safeNormalize 处理的是向量归一化的经典陷阱:零向量除以长度 0 会得到 NaN。它把零向量直接返回零向量,让调用方拿到一个「安全但无意义」的值,而不是让 NaN 扩散出去。

测试:把每个边界都钉死

数值稳健工具的价值全在边界上,所以测试也要盯着边界写。下面 src/math/numerical-robustness.test.ts 覆盖了非结合性、饱和、除零、NaN、零向量五组边界。

// file: src/math/numerical-robustness.test.ts
// tier: L1
// verify: npx vitest run src/math/numerical-robustness.test.ts

import { describe, expect, it } from 'vitest';
import {
  EPS,
  approxEqual,
  approxZero,
  clamp,
  safeDiv,
  safeNormalize,
  sanitize,
  saturate,
} from './numerical-robustness.ts';

describe('epsilon 比较', () => {
  it('0.1+0.2 与 0.3 在相对阈值下相等', () => {
    expect(0.1 + 0.2 === 0.3).toBe(false);
    expect(approxEqual(0.1 + 0.2, 0.3)).toBe(true);
  });

  it('相对比较对大数宽容', () => {
    expect(approxEqual(1e8, 1e8 + 1)).toBe(true);
  });

  it('approxZero 判断「本该为零」', () => {
    expect(approxZero(1e-9)).toBe(true);
    expect(approxZero(1e-3)).toBe(false);
  });
});

describe('饱和与钳制', () => {
  it('saturate 把值压到 [0,1]', () => {
    expect(saturate(-0.5)).toBe(0);
    expect(saturate(1.5)).toBe(1);
    expect(saturate(0.3)).toBe(0.3);
    expect(saturate(Number.NaN)).toBe(0);
  });

  it('clamp 处理区间倒置', () => {
    expect(clamp(5, 0, 10)).toBe(5);
    expect(clamp(-1, 0, 10)).toBe(0);
    expect(clamp(11, 0, 10)).toBe(10);
    expect(clamp(5, 10, 0)).toBe(10); // lo > hi 时退化为 lo
  });
});

describe('除零与 NaN 防护', () => {
  it('分母接近 0 时返回 fallback', () => {
    expect(safeDiv(1, 0)).toBe(0);
    expect(safeDiv(1, 1e-12)).toBe(0);
    expect(safeDiv(1, 2)).toBe(0.5);
  });

  it('safeDiv 挡住非有限分母', () => {
    expect(safeDiv(1, Number.NaN, -1)).toBe(-1);
    expect(safeDiv(1, Number.POSITIVE_INFINITY, -1)).toBe(-1);
  });

  it('sanitize 把 NaN/Inf 换成 fallback', () => {
    expect(sanitize(Number.NaN, 7)).toBe(7);
    expect(sanitize(Number.POSITIVE_INFINITY, 7)).toBe(7);
    expect(sanitize(3.14, 7)).toBe(3.14);
  });

  it('safeNormalize 对零向量返回零而非 NaN', () => {
    expect(safeNormalize(0, 0, 0)).toEqual([0, 0, 0]);
    const [x, y, z] = safeNormalize(3, 0, 0);
    expect(x).toBe(1);
    expect(y).toBe(0);
    expect(z).toBe(0);
  });

  it('EPS 是默认阈值', () => {
    expect(EPS).toBe(1e-6);
  });
});

本机实测:npx vitest run src/math/numerical-robustness.test.ts,10 个用例全部通过。几个值得单独看的断言:

  • approxEqual(1e8, 1e8 + 1) 是 true——相对阈值 1e-6 * 1e8 = 100,而差只有 1,所以判「相等」。这正是相对 epsilon 的意图:在 1e8 这个量级上,「差 1」确实可以忽略。
  • safeDiv(1, 1e-12) 是 0 而不是 1e12——因为 1e-12 < EPS,被判为「接近 0 的分母」,返回 fallback。这是刻意的:一个极小的分母往往意味着上一步算错了(比如法向量退化),返回一个荒谬的大数不如返回 fallback。
  • saturate(Number.NaN) 是 0——NaN 被就地归零,不再往下传。
  • safeNormalize(0, 0, 0) 是 [0, 0, 0]——零向量不会被归一化成 [NaN, NaN, NaN]。

NaN 为什么会「传染」,怎么挡住它

NaN(Not a Number)有一个恶劣的性质:任何与它相关的算术结果都是 NaN。本机实测这组语义(node -e 直接打印):

NaN+1 = NaN | NaN*0 = NaN | NaN===NaN = false
1/0 = Infinity | 0/0 = NaN | -1/0 = -Infinity
1/Infinity = 0 | isFinite(1/0) = false
Number.isNaN(NaN) = true

四行里有三处值得警惕:NaN * 0 仍是 NaN(直觉上「任何数乘 0 都该是 0」,但 NaN 不遵守);0 / 0 得到的是 NaN 而不是 Infinity(两个非有限结果,来源却不同);1 / Infinity = 0 这个「看似正常」的结果,恰恰是 safeDiv 要连「分母非有限」也一起挡掉的原因——如果只挡 b === 0,1 / Infinity 会静默返回 0,把错误藏起来。于是一个 NaN 混进位置向量,一帧之内就能污染整个变换链,几帧后场景里所有物体都消失——而代码里没有任何一行报错。

防线有三层:

  1. 源头拦截。 除法用 safeDiv,归一化用 safeNormalize,让 NaN 根本没有产生的机会。
  2. 出口净化。 在「写回组件」「上传 GPU」「序列化」这些边界上,用 sanitize 扫一遍,把任何漏网的 NaN/Infinity 换成安全值,并记一条日志。
  3. 检测而非假设。 用 Number.isNaN(x) 而不是 x === NaN——后者永远返回 false,是个经典的自欺欺人。

sanitize 属于第 2 层。它不尝试「修复」NaN,只是把它换成一个明确的 fallback 并放行——因为一个 NaN 往往说明上游逻辑有问题,掩盖它不如让它在日志里留下痕迹。工程上更好的做法是 sanitize 命中时打一条警告,让它变成可观测的事件。

除零、饱和在引擎里的位置

这些工具不是学院派的花活,它们在引擎里都有明确的落点:

工具典型落点不做的后果
approxEqual碰撞检测的接触判断、状态机的浮点条件本该触发的碰撞因差 1e-16 而不触发
saturate颜色分量、光照强度、时间缩放、插值系数颜色算出 1.0000001,越界后渲染异常
clamp相机角度、音量、物理速度上限数值失控,角色飞出场景
safeDiv归一化、投影、光照衰减、反三角函数分母为 0,位置变 NaN
safeNormalize方向向量、法线、速度方向零向量归一化,法线变 NaN
sanitize组件写回、序列化、上传 GPU 之前NaN 静默扩散

注意这些工具的使用位置:它们都在「边界」上——数据进入下一层、进入下一帧、写进组件、上传 GPU 的地方。核心循环里可以尽情用快的裸运算,但每跨过一次边界,就过一遍这些工具。这是「快路径保持快、边界保持稳」的分工。

一个具体的例子:《微光岛》的角色移动里,方向向量来自「目标点减当前位置」,当角色恰好站在目标点上时这个向量是零向量。如果直接归一化就会得到 NaN,角色下一帧位置变成 NaN,然后整个场景跟着 NaN。safeNormalize 把这一步挡下来——零方向返回零向量,角色原地不动,等下一帧有了方向再走。没有这一层,一个「正好踩在点上」的巧合就能让游戏白屏。

常见坑

  • 用 x === NaN 判断 NaN。 永远 false。要用 Number.isNaN(x),或者更宽松的 Number.isFinite(x)(它同时排除 Infinity 和 NaN)。
  • 只挡「分母等于 0」。 分母是 1e-300 时也会算出天文数字。要用「小于 epsilon」判定,把「接近 0」一并挡住。
  • clamp 时不处理区间倒置。 如果 lo > hi,一个朴素的 clamp 会返回莫名其妙的值。要么在函数里兜底,要么断言 lo <= hi。
  • 用固定绝对 epsilon 做所有比较。 对大数太严、对小数太松。默认用相对 epsilon,只在「本该为零」的量上用绝对 epsilon。
  • 把 NaN 当作「还没算完」的占位符。 NaN 是传染性的,占位符应该用明确的哨兵值(比如 -1 或独立的 valid 标志)。
  • 在热循环里无脑调用 sanitize。 它每次都走一遍 Number.isFinite,放对位置(边界)比放得多更重要。

三节下来,第 10 章给 helio 的数学层补上了最后一块:浮点会因平台而异(10.1),定点用整数换确定性(10.2),数值工具守住边界(10.3)。 有了这套东西,数学层就不只是「算得对」,而是「在任何平台上都算得一样、在边界上不出事」。下一章进入数据导向设计,从缓存与访存代价开始,把「怎么摆数据」这件事讲透。

阅读导航:上一节:10.2 定点数:为确定性模拟准备 · 下一节:11.1 缓存层级与访存代价 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径