中EN
概念页

数值表示与浮点数

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 位符号 ss、8 位指数 ee(偏置 127)、23 位尾数 mm,尾数前有一个不存储的隐含 1:

v=s×2(e−127)×(1+m)v = s \times 2^{(e-127)} \times (1+m)

例:位模式 0x3E200000 中 s=+1s=+1、e=124e=124、m=14m=\tfrac14,因此 v=2−3×54=0.15625v = 2^{-3}\times\tfrac54 = 0.15625。

量级与精度的权衡:有效位数是固定的(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 是满足 1+ε≠11+\varepsilon \ne 1 的最小值,IEEE-754 单精度下为 ε=2−23≈1.192×10−7\varepsilon = 2^{-23} \approx 1.192\times10^{-7}。只有最低位不同的两个浮点数相差 1 个 ULP (units in the last place);若无偏指数为 xx,则

1 ULP=2x ε1\ \text{ULP} = 2^{x}\,\varepsilon

所以 1.0 处的 1 ULP 等于 ε\varepsilon,2.0 处是 2ε2\varepsilon,4.0 处是 4ε4\varepsilon。利用 a≥b  ⟺  a+1 ULP>ba \ge b \iff a + 1\,\text{ULP} > b,Naughty Dog 的角色对白系统只实现了大于和小于比较,大于等于和小于等于通过加减 1 ULP 实现。

对游戏的影响:浮点秒表在 2202^{20} 秒(约 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 =1.1012×22=1.101_2\times2^2,内存表示 0x40d00000;1.0 为 0x3f800000,调试时值得记住。最小次正规数 2−149≈1.4013×10−452^{-149}\approx1.4013\times10^{-45},若没有次正规数则最小值为 2−127≈5.877×10−392^{-127}\approx5.877\times10^{-39}(书中写作 1.0×2−1271.0\times2^{-127},均与本页验算一致)。
  • 在 [2e,2e+1)[2^e,2^{e+1}) 中浮点间距为 2e−232^{e-23};舍入到最近保证误差不超过半个间距,于是 ϵm=2−24≈5.96×10−8\epsilon_m=2^{-24}\approx5.96\times10^{-8}。注意这与 C++ FLT_EPSILON=2−23=2^{-23}(1 与下一个浮点数的间距)差一倍,读代码时要分清用的是哪种定义。
  • 位模式加 1 就得到下一个更大的浮点数(尾数满了会进位到指数,最大有限值加 1 得到 +∞+\infty),这就是 NextFloatUp();−0-0 和 +0+0 的位模式不相邻,要特殊处理。
  • !(x == x) 可判断 NaN;std::bit_cast 是唯一合法的按位重解释方式。
  • 半精度(5 位指数、10 位尾数)在 pbrt 中只用于图像像素存储。 误差界 γn\gamma_n、灾难性抵消以及它们在光线求交中的应用见 浮点误差与鲁棒光线求交。

PBRT 的视角:误差无关变换与区间运算(PBRT 4e B.2.9–B.2.10、B.6.2)

  • 误差无关变换 (error-free transformation, EFT):用 FMA 可以精确求出一次乘法的舍入误差。TwoProd(a,b):p=a⊗bp=a\otimes b,e=fma(a,b,−p)e=\mathrm{fma}(a,b,-p),则 ab=p+eab=p+e 精确成立(Ogita 等 2005);TwoSum() 用 6 次加减得到和及其误差(Møller 1965、Knuth 1969)。CompensatedSum 是 Kahan(1965)补偿求和。
  • DifferenceOfProducts(a,b,c,d) 计算 ab−cdab-cd:先算 c⊗dc\otimes d 的舍入误差,再用一次 FMA 得到 ab−cdab-cd 的初值并补回误差,避免直接相减时的灾难性抵消。pbrt 用它算二次方程判别式 b2−4acb^2-4ac、3×3/4×4 矩阵的余子式;方法归于 Kahan,误差分析见 Jeannerod 等 2013。
  • 区间运算 (interval arithmetic):Interval 用运算符重载把普通浮点计算换成区间计算,每次运算把下界向下舍入、上界向上舍入,保证结果区间一定包含真值(Moore 1966)。乘法要取四个端点乘积的最小、最大值,例如 [−2,3]×[−2,3]=[−6,9][-2,3]\times[-2,3]=[-6,9]。另有 Abs、Sqrt、Quadratic 及三角函数等。
  • 原书问题(B.2.10 区间加法公式):书中写作 [a,b]⊕[c,d]⊂[RoundDown(a+c),RoundUp(c+d)][a,b]\oplus[c,d]\subset[\mathrm{RoundDown}(a+c),\mathrm{RoundUp}(c+d)](据网页公式 SVG 字形解码核实),上界应为 RoundUp(b+d)\mathrm{RoundUp}(b+d)。反例:[1,20]+[3,4][1,20]+[3,4] 真值为 [4,24][4,24],按书中公式上界只有 3+4=73+4=7,区间不包含真值。
  • 原书问题(附录 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 。

  1. Game_Engine_Architecture_4Ed_-_Jason_Gregory.pdf
  2. Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 I 第 3 章 3.3.1–3.3.3 节
  3. PBRT 4e source excerpt: chapter 06 (Shapes)
  4. Pharr, Jakob, Humphreys《Physically Based Rendering: From Theory to Implementation》第 4 版(MIT Press, 2023)第 6 章 6.8.1 节,pbr-book.org/4ed/Shapes
  5. PBRT 4e source excerpt: chapter B (Utilities)
  6. Pharr, Jakob, Humphreys《Physically Based Rendering: From Theory to Implementation》第 4 版(MIT Press, 2023)附录 B B.2–B.7 节与延伸阅读,pbr-book.org/4ed/Utilities

相关页面

图形学顶点压缩与精度Vertex Compression & Precision网格数据像纹理一样可以压缩:传输用的整体压缩(Draco 等)一次性解码,渲染用的定长压缩让顶点着色器在 GPU 上逐顶点解码,从而降低内存、带宽并改善缓存。另一面是精度问题:大世界中单一坐标系会让远离原点的物体抖动,需要相对相机的变换或分段原点。游戏开发C++ 内存布局与对齐C++ Memory Layout & Alignment理解 C/C++ 程序在内存中的样子,是写高性能引擎代码的前提。主要内容包括:声明与定义、内部与外部链接;可执行映像的几个段(text、data、BSS、rodata)以及栈和堆;结构体成员的自然对齐和填充;继承和虚函数如何改变类的布局(vpointer 和 vtable)。GEA 也简要回顾了面向对象:多重继承容易产生致命菱形,所以通常只允许 mix-in;组合往往比继承更好。游戏开发计算机硬件与内存层次Computer Hardware & Memory Hierarchy从冯·诺依曼结构讲起,依次是 CPU 的组成(ALU、VPU、寄存器、控制单元、时钟)、总线与字长、机器语言、汇编和寻址方式,再到内存体系:内存映射、虚拟内存和 TLB,以及为了缩小“内存鸿沟”而设计的寄存器文件、多级缓存(缓存行、标签、组相联、替换和写策略、一致性协议)和 NUMA(PS3 的 SPU 本地存储、PS2 的 scratchpad)。对游戏程序员最重要的结论是:数据要连续、紧凑、顺序访问;热循环的代码要小。游戏开发SIMD 向量化SIMD VectorizationSIMD(单指令多数据)用一条机器指令并行处理多个数据。x86 上从 MMX 发展到 SSE(128 位,4 个 float)、AVX(256 位,8 个)和 AVX-512(512 位,16 个)。GEA 用 SSE 内建函数演示:数组相加、点积、向量×矩阵、矩阵×矩阵,强调两点——避免寄存器内的水平相加(先转置数据),以及把 SIMD 寄存器看成 N 条并行的「通道 (lane)」而不只是一个 4D 向量;条件分支则用向量谓词化 (vector predication / select) 代替。图形学坐标空间Coordinate Spaces顶点从模型到屏幕要依次经过模型空间、世界空间、观察空间、裁剪空间、标准化设备坐标 (NDC)、窗口坐标。每一步变换都有明确目的,大部分可以用 4×4 矩阵表示并相乘合并。游戏数学伪随机数生成Pseudorandom Number Generation游戏引擎到处要用随机数,但随机数生成器 (RNG) 并不真正随机:它只是给出一个复杂但完全确定、预先定义的序列,因此严格说应叫伪随机数生成器 (PRNG)。好坏取决于周期(多久重复)和能否通过各种公认的随机性测试。GEA 介绍了线性同余、梅森旋转、Marsaglia 的 Mother-of-All / Xorshift / KISS99,以及 PCG。游戏开发游戏本地化与 UnicodeGame Localization & Unicode本地化(国际化,I18N)是把软件改编为其他语言版本,最好从第一天起规划并贯穿每个开发步骤。GEA 介绍了 Unicode 与 UTF-32/UTF-8/UTF-16/UCS-2 编码、char 与 wchart、Windows 的字符串 API 约定、主机上的选择,字符串之外的本地化问题,以及核心基础设施——字符串数据库 + 按 ID 查找当前语言字符串的运行时函数,并以 Naughty Dog 的本地化工具为例。游戏开发游戏时间线与时钟Game Timelines & Clocks游戏引擎要同时处理多种「时间」:真实时间、游戏时间、动画片段的局部时间、CPU 周期等。GEA 用抽象时间线 (abstract timeline) 统一看待它们,并讨论帧时间 \Delta t 的测量方法、高精度计时器、时间单位与时钟变量类型的取舍,以及断点恢复后的异常 \Delta t 处理。(卷 I 第 8 章 引言、8.4–8.5 节)游戏开发人机接口设备Human Interface Devices人机接口设备 (HID) 是玩家向游戏提供输入、游戏向玩家提供反馈的硬件:手柄、键鼠、摇杆、Wii 遥控器、方向盘、跳舞毯、吉他等。GEA 从三个层面介绍它们:游戏如何与设备通信(轮询、中断、无线协议),设备提供哪几类输入,以及设备能输出哪些反馈。引擎如何加工这些原始输入见 引擎输入系统。(卷 I 9.1–9.4 节)图形学显示编码与 sRGBDisplay Encoding & sRGB光照、纹理等计算假设数值是线性的,但显示缓冲和纹理使用非线性编码。简短的回答是:着色器输出 [0,1] 的颜色要取 1/2.2 次幂(gamma 校正),读入的纹理和颜色做相反的变换,多数情况下可以让 GPU 自动完成。忽略它会导致画面偏暗、色相偏移、光照叠加错误和抗锯齿边缘"绳索化"。游戏开发动画压缩Animation Compression未压缩的动画每关节每样本最多 10 个 32 位浮点通道,数据量巨大。GEA 列出的压缩手段按力度递增:省略无用通道、量化(如 16 位 RL 编码)、降低采样率或省略近似线性的关键帧、用 B 样条拟合曲线、小波压缩,以及最根本的:只把需要的片段放进内存(选择性加载与流式加载)(GEA4 卷 II 13.8)。图形学GPGPU 编程入门Introduction to GPGPU ProgrammingGEA 把 GPGPU 讲成「SIMD 向量化推到极致」:GPU 结合 SIMD(向量化 ALU)与 MIMD(一种抢占式多线程),即 NVIDIA 所说的 SIMT。适合 GPU 的任务必须是数据并行的——每个元素的计算不依赖其他元素的结果。写法与 CPU 向量化相反:不把循环硬编码成每次 4/8/16 个,而是保留单通道的循环体,抽成计算核函数 (compute kernel),由 GPU 替我们「跑循环」。一个「GPU 线程」其实就是一条 SIMD 通道。游戏数学浮点误差与鲁棒光线求交Floating-Point Error & Robust Ray Intersection光线追踪中,交点的浮点误差会让次级光线(阴影、反射)从表面"下方"出发、错误地再次击中自身(自相交,俗称 shadow acne),或因偏移过大漏掉近处遮挡(漂浮的阴影)。PBRT 第 4 版 6.8 节不靠经验 "ray epsilon",而是用前向误差分析为每个交点算出保守的误差盒,再沿法线把光线原点推出误差盒,做到可证明不自相交、偏移又极小。游戏数学描述统计与显著性检验Descriptive Statistics & Significance Testing概率是"已知随机过程、预测结果",统计则反过来——"已知结果数据、反推随机过程的性质"。Schreiber 与 Romero 第 24 章(p.583–602)给出设计师分析试玩与运营数据最常用的工具:均值、中位数与标准差的对照解读,离群值的处理,统计显著性与 Z 检验,相关系数,以及安斯库姆四重奏、样本量、样本偏差、埋点 bug、相关≠因果等常见错误。本页还补充了原书对 p 值的解释与主流统计学观点的分歧。游戏开发无锁编程与内存序Lock-Free Programming & Memory Ordering「无锁 (lock-free)」的真正含义不是不用互斥锁,而是从不让线程阻塞(作者说叫 block-free 更贴切)。数据竞争有三个来源:关键操作被打断、编译器与 CPU 的指令重排、缓存一致性协议优化造成的内存序问题。对应的工具是原子指令(TAS、XCHG、CAS、LL/SC)和内存屏障/栅栏(acquire、release、full fence),C++11 用 std::atomic 与 std::memoryorder 把它们封装成可移植接口。在此基础上可以写出自旋锁、可重入锁、读写锁,以及基于「本地准备 + 一次原子提交」事务的无锁数据结构。

输入关键词开始搜索

滚轮或双指缩放 · 拖动平移 · 双击放大 / 还原