数值表示与浮点数
Numeric Representation & Floating Point
游戏引擎里到处是数值,所以要清楚它们在机器中如何存储:二进制和十六进制、补码整数、定点数、IEEE-754 浮点数(量级与精度此消彼长、次正规数、机器 epsilon、ULP),以及多字节值的大小端问题和安全的类型双关 (type punning) 方法。书中一个直观的例子是:用 32 位浮点数记录以秒为单位的游戏时钟,大约 12.14 天后,加 1/30 秒就不再改变它的值。
进制与整数(卷 I 3.3.1.1–3.3.1.2)
- 十六进制中一位正好对应 4 位二进制,两位对应一个字节,所以很常用(如
0xFF = 255);C++ 中以 0 开头的字面量是八进制。 - 有符号整数可以用“符号位 + 绝对值”表示(存在正零和负零),但大多数处理器用二进制补码 (two's complement):只有一个零,32 位下
0xFFFFFFFF表示 −1,0x80000000是最小值 −2,147,483,648。
定点数(卷 I 3.3.1.3)
人为规定多少位表示整数部分、多少位表示小数部分。例如 1 位符号、16 位整数、15 位小数时,−173.25 编码为 0x8056A000。缺点是量级和精度都受限:这种格式的绝对值上限只有 65,535 左右,所以要用浮点数。
IEEE-754 浮点数(卷 I 3.3.1.4)
32 位浮点数从高位到低位依次是 1 位符号 、8 位指数 (偏置 127)、23 位尾数 ,尾数前有一个不存储的隐含 1:
例:位模式 0x3E200000 中 、、,因此 。
量级与精度的权衡:有效位数是固定的(23 位尾数加隐含位共 24 位),指数只是把这些有效位移到更高或更低的量级。FLT_MAX ≈ 3.403×10^38(0x7F7FFFFF)可以看作 24 个二进制 1 左移 127 位,后面 104 个零并不对应任何真实的位,所以从 FLT_MAX 减去一个“小”数结果不变。指数 255 留给 NaN 和无穷大。
次正规数 (subnormal / denormal):最小正规数是 FLT_MIN = 2^-126 ≈ 1.175×10^-38,它和 0 之间有一段空隙。偏置指数为 0 时把数解释为次正规数(指数按 1 处理,隐含位改为 0),用等间距的值填满这段空隙;最接近 0 的正次正规数是 FLT_TRUE_MIN。这样就保证了 a == b 和 a - b == 0.0f 等价;如果没有次正规数,a != b 时 a - b 也可能为 0。
机器 epsilon 与 ULP:机器 epsilon 是满足 的最小值,IEEE-754 单精度下为 。只有最低位不同的两个浮点数相差 1 个 ULP (units in the last place);若无偏指数为 ,则
所以 1.0 处的 1 ULP 等于 ,2.0 处是 ,4.0 处是 。利用 ,Naughty Dog 的角色对白系统只实现了大于和小于比较,大于等于和小于等于通过加减 1 ULP 实现。
对游戏的影响:浮点秒表在 秒(约 12.14 天)之后,加 1/30 秒就无效了。只需要精确到 1/30 秒时,这个时长通常够用;但要毫秒级精度就会很快出问题。时间测量的做法见卷 I 第 8 章。
基本类型与定宽类型(卷 I 3.3.2)
char(通常 8 位,有无符号由编译器决定)、int(目标平台最高效的大小)、short、long、float(32 位 IEEE)、double(64 位)、bool(从不只占 1 位,有的编译器用 8 位,有的用 32 位)。过去各引擎自己定义定宽类型(Naughty Dog 用 F32、U8/I8 … U64/I64);C++11 起有 <cstdint> 中的 std::int8_t … std::uint64_t。
原书问题:GEA 3.3.2 说
int在 64 位架构(如 Intel Core i7)上“通常是 64 位”。实际上主流 64 位平台的数据模型(Windows 的 LLP64、Linux/macOS 的 LP64)中int仍是 32 位,变化的是long(LP64 下 64 位)和指针;原书也补了一句“还取决于编译器选项和操作系统”。需要确定宽度时应使用<cstdint>定宽类型。
字节序(卷 I 3.3.2.1)
- 小端 (little-endian):低位字节存在低地址,
0xABCD1234存为34 12 CD AB(Intel)。大端 (big-endian):高位字节存在低地址,存为AB CD 12 34(Wii、Xbox 360、PS3 所用 PowerPC 的默认模式)。 - 在小端 PC 上生成的数据,到大端主机上会读错。解决办法有两个:写成文本(浪费空间),或者让工具在写二进制文件前就按目标平台交换字节序。
- 交换时必须知道每个成员的位置和宽度,逐个成员交换(
swapU16、swapU32),不能把整个结构体当字节数组一把翻转。gcc 提供__builtin_bswapXX(),但不可移植。 - 浮点数可以当作同宽整数来交换。但用
reinterpret_cast指针做类型双关,在严格别名 (strict aliasing) 规则下属于未定义行为,可能引发优化 bug;用 union 也不能完全幸免。标准认可的做法是memcpy到另一种类型的变量里(优化器通常会把这次拷贝消掉),或者用 C++20 的std::bit_cast。
KB 与 KiB(卷 I 3.3.3)
SI 前缀是 1000 的幂(kB、MB…),IEC 前缀是 1024 的幂(KiB、MiB、GiB…,1998 年制定)。GEA 全书使用 IEC 单位。
与其他来源的关系
RTR4 侧重 GPU 端的精度问题,如半精度、顶点压缩、深度精度,见 顶点压缩与精度;本页是 CPU 端的通用基础。两者没有冲突,只是关注点不同。
PBRT 的视角:误差分析需要的浮点细节(PBRT 4e 6.8.1)
PBRT 对 IEEE 754 的介绍与上文 GEA 一致(单精度 1+8+23 位、偏置 127、隐含的前导 1、次正规数、±0、无穷与 NaN),补充了几处可以验算的数值和工程细节:
- 6.5 ,内存表示
0x40d00000;1.0 为0x3f800000,调试时值得记住。最小次正规数 ,若没有次正规数则最小值为 (书中写作 ,均与本页验算一致)。 - 在 中浮点间距为 ;舍入到最近保证误差不超过半个间距,于是 。注意这与 C++
FLT_EPSILON(1 与下一个浮点数的间距)差一倍,读代码时要分清用的是哪种定义。 - 位模式加 1 就得到下一个更大的浮点数(尾数满了会进位到指数,最大有限值加 1 得到 ),这就是
NextFloatUp(); 和 的位模式不相邻,要特殊处理。 !(x == x)可判断 NaN;std::bit_cast是唯一合法的按位重解释方式。- 半精度(5 位指数、10 位尾数)在 pbrt 中只用于图像像素存储。 误差界 、灾难性抵消以及它们在光线求交中的应用见 浮点误差与鲁棒光线求交。
PBRT 的视角:误差无关变换与区间运算(PBRT 4e B.2.9–B.2.10、B.6.2)
- 误差无关变换 (error-free transformation, EFT):用 FMA 可以精确求出一次乘法的舍入误差。
TwoProd(a,b):,,则 精确成立(Ogita 等 2005);TwoSum()用 6 次加减得到和及其误差(Møller 1965、Knuth 1969)。CompensatedSum是 Kahan(1965)补偿求和。 DifferenceOfProducts(a,b,c,d)计算 :先算 的舍入误差,再用一次 FMA 得到 的初值并补回误差,避免直接相减时的灾难性抵消。pbrt 用它算二次方程判别式 、3×3/4×4 矩阵的余子式;方法归于 Kahan,误差分析见 Jeannerod 等 2013。- 区间运算 (interval arithmetic):
Interval用运算符重载把普通浮点计算换成区间计算,每次运算把下界向下舍入、上界向上舍入,保证结果区间一定包含真值(Moore 1966)。乘法要取四个端点乘积的最小、最大值,例如 。另有Abs、Sqrt、Quadratic及三角函数等。 - 原书问题(B.2.10 区间加法公式):书中写作 (据网页公式 SVG 字形解码核实),上界应为 。反例: 真值为 ,按书中公式上界只有 ,区间不包含真值。
- 原书问题(附录 B 参考文献):Møller 1965 的题名列作 "Quasi double precision in floating-point arithmetic",实际题名为 "Quasi double-precision in floating point addition"(BIT 5(1):37–50,DOI 10.1007/BF01975722,据 Crossref 核对)——这篇讲的正是
TwoSum()所用的加法误差补偿。 - 原子浮点:pbrt 的
AtomicFloat把浮点数按位存进std::atomic<uint32_t>,用 compare-exchange 循环实现Add();浮点加法不满足结合律,所以多线程累加的结果在不同次运行之间不完全一致,pbrt 认为这可以接受(用于AddSplat())。
观点对照:std::atomic 能否用于浮点
| 来源 | 说法 |
|---|---|
| PBRT 4e B.6.2 原文 | "The std::atomic template cannot be used with floating-point types."(std::atomic 模板不能用于浮点类型);并说不支持原子操作的"主要原因之一"是浮点运算一般不满足结合律,多线程原子累加的结果会随执行顺序变化 |
| C++ 标准(cppreference「std::atomic」条目) | C++11 起 std::atomic<float> 就合法(任何可平凡复制的类型都可以),支持 load/store/exchange/compare_exchange_*;但 C++11–C++17 的浮点实例没有 fetch_add/fetch_sub/operator+=。C++20 起浮点特化才提供这些加减操作 |
| pbrt-v4 源码 | CMakeLists.txt 设 CMAKE_CXX_STANDARD 17(2026-10-13 核对 master),即 C++17 |
书中这句话按字面是错的:pbrt 所用的 C++17 里 std::atomic<float> 可以实例化,缺的只是原子加减。准确说法是"C++17 的 std::atomic 不为浮点类型提供原子算术(fetch_add)";正因如此,pbrt 的 AtomicFloat 要把位模式存进 std::atomic<uint32_t>,自己用 compare-exchange 循环实现 Add()。书中给的理由也说不通:AtomicFloat::Add() 同样不满足结合律,pbrt 照用不误(认为可以接受),而 C++20 也直接加入了浮点 fetch_add。按 C++20,std::atomic<float>::fetch_add 可以直接替代 AtomicFloat::Add(),结合律问题依然存在。来源:https://pbr-book.org/4ed/Utilities/Parallelism (B.6.2);https://en.cppreference.com/w/cpp/atomic/atomic ;https://github.com/mmp/pbrt-v4/blob/master/CMakeLists.txt 。
出处
- Game_Engine_Architecture_4Ed_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 I 第 3 章 3.3.1–3.3.3 节
- PBRT 4e source excerpt: chapter 06 (Shapes)
- Pharr, Jakob, Humphreys《Physically Based Rendering: From Theory to Implementation》第 4 版(MIT Press, 2023)第 6 章 6.8.1 节,pbr-book.org/4ed/Shapes
- PBRT 4e source excerpt: chapter B (Utilities)
- Pharr, Jakob, Humphreys《Physically Based Rendering: From Theory to Implementation》第 4 版(MIT Press, 2023)附录 B B.2–B.7 节与延伸阅读,pbr-book.org/4ed/Utilities