《雷神之锤3》的代码在圈子里火了二十多年,不是因为游戏本身多好玩,而是因为一段只有几行的快速平方根倒数算法,和那句后来成了传说级的注释“what the fuck”。我最早接触这套代码是在读大学的时候,当时纯粹是冲着0x5f3759df这个魔法数字去的,结果一头扎进id Tech 3的源码里,越看越觉得这根本就是一本1999年写就的、极具攻击性的性能优化教科书。今天这篇就专门聊聊这套代码里真正值钱的东西,从那个著名的浮点数魔法讲起,再把引擎的模块划分、渲染技巧、网络同步这些老而弥坚的设计抽出来拆一拆,最后聊聊现代开发者还能从这些老古董身上抄到什么作业。
1. 先看传奇:快速平方根倒数算法的技术拆解
在正式聊引擎架构之前,我们必须先把那段让无数人熬夜研究的代码说清楚。它解决的问题非常朴素:给定一个浮点数x,马上算出1/sqrt(x)。这个运算在3D图形里无处不在,向量归一化、光照计算、反射向量、碰撞检测,全都要用到。当年CPU没有硬件指令,软件实现又慢得离谱,于是id Software用了一个堪称暴力的数学技巧。
1.1 一行魔数背后的浮点数数学原理
先看一眼代码最常见的形态:
c复制float Q_rsqrt( float number )
{
long i;
float x2, y;
const float threehalfs = 1.5F;
x2 = number * 0.5F;
y = number;
i = * ( long * ) &y; // evil floating point bit level hacking
i = 0x5f3759df - ( i >> 1 ); // what the fuck?
y = * ( float * ) &i;
y = y * ( threehalfs - ( x2 * y * y ) ); // 1st iteration
// y = y * ( threehalfs - ( x2 * y * y ) ); // 2nd iteration
return y;
}
这段代码的精华在第一眼看上去完全不可理喻的两行:把浮点数指针当作长整型指针去读,然后减一个魔法数字。要搞懂它,需要先回忆一下IEEE 754单精度浮点数的存储方式。
一个float在内存里是32位,分布是:1位符号位、8位指数位、23位尾数位。对于一个正的规格化浮点数,它的实际值可以写成:
code复制value = (1 + m) * 2^(e - 127)
其中e是指数位作为无符号整数读出来的值,m是尾数位作为小数部分,范围在[0, 1)。如果我们把这个浮点数对应的32位整型记为I,那I的数值大约就是:
code复制I ≈ 2^23 * (e + m)
这里e + m本质上就是log2(value)加上一个常数偏移的近似。为什么“大约”?因为log2(1 + m)并不是线性的,它和m的偏差就在那23位尾数上,但作为一阶近似已经够用了。
现在我们要算1/sqrt(x),取以2为底的对数:
code复制log2(1/sqrt(x)) = -0.5 * log2(x)
如果用整数I来近似log2(x),那么对一个数取平方根的倒数,反映在整数层面就几乎等价于把整型右移一位、取反、然后补一个常数调整。i >> 1就是除以2,前面的减法本质上是做了-0.5 * log2(x)这个运算,而0x5f3759df这个魔法数字就是用来修正前面“取对数近似”的误差,同时也把指数偏移量127那些常数换算进来了。
注意:这里的“运算”全部是在整数位操作层面完成的,并没有真正算对数。这就是它的快:一次整数减法和一次移位,就得到了初始估计值。这个初始值的相对误差大概在3%以内,对于图形学里动辄上百万次的归一化运算来说,已经非常能打了。
1.2 牛顿迭代法的两次逼近与精度分析
初始估计值精度只有3%左右,直接用当然不行,所以后面还跟了一次牛顿迭代。我们要求解的方程是:
code复制f(y) = 1/y^2 - x =
