[ 附件影像 RECORD ]
快速平方根倒数算法的数学原理与计算几何

快速平方根倒数算法的数学原理与计算几何

[ SCAN_URL ]
[ 归档时间 ]:2026-04-02 21:57 [ 课题责任人 ]:文止墨 [ 档案分类 ]:AIGC, 嵌入式, 文章, 游戏代码, 编程

导读

在二十世纪九十年代末期,三维(3D)电子游戏正处于其发展的初期阶段。当时计算机的硬件性能相较于今天而言极为有限,中央处理器(CPU)尚未配备如今这种高度优化的并行流水线和专用的高级浮点数学运算单元 。在一个需要处理快节奏、高强度图形渲染的三维环境中,数学运算所消耗的每一个时钟周期,都会直接转化为屏幕上可能出现的卡顿和帧率下降。在当时所有常见的数学运算中,计算一个浮点数的“平方根倒数”(即 $1/\sqrt{x}$)被认为是极其耗费计算资源的操作

当1999年发布的著名第一人称射击游戏《雷神之锤 III 竞技场》(Quake III Arena)的源代码最终被 id Software 公司公开时,程序员和数学家们在其中发现了一段令人瞠目结舌的代码 。这段被称为“快速平方根倒数”(Fast Inverse Square Root)的算法,以一种常理难以解释的方式,极其快速且精确地估算出了 $1/\sqrt{x}$ 的值 。在当时的硬件条件下,它的计算速度几乎是标准数学库运算速度的四倍,而误差却极小

这段 C 语言代码因其晦涩难懂的位操作逻辑以及开发者留在代码旁边的幽默注释而闻名于世。代码如下所示1

C
float InvSqrt(float x)
{
	float xhalf = 0.5f*x;
	int i = *(int*)&x; // get bits for floating VALUE 
	i = 0x5f375a86- (i>>1); // gives initial guess y0
	x = *(float*)&i; // convert bits BACK to float
	x = x*(1.5f-xhalf*x*x); // Newton step, repeating increases accuracy
	return x;
}

为了使你快速了解该算法的效果,我在这里放一个演示:点击进入演示页面

多年来,这段代码的真正起源一直是个谜,人们经常错误地将其归功于 id Software 的首席程序员约翰·卡马克(John Carmack)。但后来的历史考证表明,这段代码的演进涉及了多位早期的图形编程大师,如加里·塔罗利(Gary Tarolli)和泰耶·马蒂森(Terje Mathisen),并最终被追溯到二十世纪八十年代末由格雷格·沃尔什(Greg Walsh)和克里夫·莫勒(Cleve Moler)所奠定的基础概念

本文将对“快速平方根倒数”算法进行详尽的、逐行的解构分析。为了确保即使是计算机初学者和数学初学者也能毫无障碍地理解,本报告将剥离晦涩的计算机科学术语,通过生动的类比,从三维图形的几何学基础、计算机内存的存储架构、对数运算的奇妙性质,一直讲解到微积分中牛顿迭代法的强大威力。最终,读者将彻底明白这段代码中 0x5f375a86 这一常数的来源,以及为何这几行简短的代码被公认为计算机编程史上最优雅的底层黑客级优化之一

几何学的刚性需求:为什么我们需要平方根倒数?

要理解这个算法的精妙之处,首先必须明白它试图解决的具体问题。三维图形引擎严重依赖于线性代数和向量数学 。在数学和物理学中,“向量”(Vector)是一个既有大小(长度)又有方向的几何对象。在三维电子游戏中,向量被用来表示一切事物:从一个正在奔跑的角色的运动速度,到虚拟摄像机的观察视角,再到光源照射在物体表面的入射角度

当图形引擎需要计算光影和反射效果时——例如决定一根火把如何照亮一面粗糙的石墙——它必须大量使用一种称为“点积”(Dot Product)的数学运算 。为了让点积运算得出具有物理意义的正确结果(例如光线的强度),参与运算的向量必须仅仅表示方向,而不能受到其自身长度的干扰。在数学上,我们将长度被严格缩放为 $1.0$ 的向量称为“单位向量”(Unit Vector)或“归一化向量”(Normalized Vector)

将一个普通向量转化为单位向量的过程被称为“向量归一化”(Vector Normalization)。具体操作是:将该向量的每一个分量(即它在三维坐标系中的 $x$、$y$、$z$ 值)除以该向量的总长度。根据扩展到三维空间的毕达哥拉斯定理(勾股定理),一个向量的长度等于其各分量平方和的平方根

$$\text{长度} = \sqrt{x^2 + y^2 + z^2}$$

因此,要对向量 $\mathbf{v}$ 进行归一化,图形引擎必须将该向量乘以其长度的倒数:

$$\text{归一化向量} = \mathbf{v} \times \frac{1}{\sqrt{x^2 + y^2 + z^2}}$$

在像《雷神之锤 III》这样激烈且图形复杂的游戏中,这种归一化计算每秒钟需要发生数百万次 。这个公式要求计算机先计算出一个浮点数的平方根,然后用 $1$ 去除以这个平方根。在二十世纪九十年代的微处理器架构中,乘法和加法可能只需要几个 CPU 时钟周期即可完成,但是浮点数的除法和平方根运算却极其昂贵,往往需要占用几十甚至上百个时钟周期,导致处理器在此期间只能处于等待状态

面对这种硬件瓶颈,程序员们急需一种方法,能够绕过传统的平方根和除法指令,直接估算出 $1/\sqrt{x}$ 的值。如果能将这两个极其昂贵的操作合并为一个高度优化的近似计算,就能为 CPU 腾出海量的计算资源,从而直接提升画面的渲染帧率 。这就是“快速平方根倒数”算法诞生的历史背景。

计算机如何理解数字:浮点数的解剖学

为了读懂代码的第一步,我们必须先理解计算机是如何在底层存储包含小数的数字的。计算机的底层世界只存在二进制——即 $0$ 和 $1$ 的组合。用二进制来表示一个整数(比如 $5$ 或者是 $100$)非常直观。然而,要表示一个带有小数部分的数字(比如 $3.14159$ 或 $0.00001$)则需要一种极其特殊且复杂的数据结构,这种结构在计算机科学中被称为 IEEE 754 浮点数算术标准

科学记数法的二进制映射

对于初学者来说,理解 IEEE 754 标准最简单的类比就是我们在初中数学里学过的“科学记数法” 。在十进制的科学记数法中,一个数字通常被写成一个介于 $1$ 到 $10$ 之间的小数,乘以 $10$ 的某次幂。例如,数字 $123.45$ 在科学记数法中会被表示为:

$$1.2345 \times 10^2$$

在这个例子中,$1.2345$ 被称为“尾数”(Significand 或 Mantissa),而 $2$ 则是“指数”(Exponent)。这种表示法的核心特点是,小数点的位置可以根据指数的大小自由“浮动”,这正是“浮点数”(Floating Point)这一名称的由来

计算机的 IEEE 754 标准正是基于同样的逻辑,只不过它将底数从 $10$ 换成了二进制的 $2$。在算法代码中,参数 float x 指定了一个 32 位的单精度浮点数。这意味着计算机在内存中分配了 32 个微小的开关(比特/Bit),每个开关只能是 $0$ 或 $1$。这 32 个比特被严格划分为三个独立的部分

组成部分占用比特数具体功能说明
符号位 (Sign)1 位位于最高位。用于判断该数字是正数($0$)还是负数($1$)。由于我们计算的是平方根,输入必须是正数,因此这一位永远是 $0$。
指数 (Exponent)8 位用于表示 $2$ 的多少次幂。它决定了数字的“大小规模”。
尾数 (Mantissa)23 位用于表示小数点后面的精确数字。它决定了数字的“精度”。

这种设计包含两个极其巧妙的数学设定,这两个设定是算法成立的前提条件:

第一个设定是“隐含的开头 1”。因为在二进制系统中,除了数字 $0$ 之外,任何一个合法的科学记数法表示的数字,其最高位必定是 $1$(例如 $1.101 \times 2^3$)。既然第一个数字永远是 $1$,IEEE 754 标准的制定者们为了节省极其宝贵的存储空间,决定在内存中根本不保存这个 $1$。那 23 位的尾数实际上只存储了小数点后面的内容

第二个设定是“指数偏移”(Bias)。为了能够同时表示极大的数字(需要正指数)和极小的微观数字(需要负指数),而又不需要为指数单独设立一个符号位,标准规定,存在内存里的 8 位指数并不是真正的指数值,而是一个加上了 $127$ 这个固定偏移量(Bias)的“偏移指数” 。因此,如果真正的指数是 $E_{true}$,那么计算机实际存储的整数 $E = E_{true} + 127$

将上述规则转化为严谨的数学公式,一个正的 32 位浮点数 $x$ 的实际数值可以通过以下公式计算得出:

$$x = (1 + M) \times 2^{E – 127}$$

在这个公式中:

  • $M$ 代表尾数部分所对应的实际小数数值,由于隐含了开头的 $1$,所以 $M$ 必定是一个严格大于等于 $0$ 且小于 $1$ 的分数(即 $0 \le M < 1$)。
  • $E$ 代表那 8 位指数比特在内存中所对应的无符号整数值 。

理解了浮点数在内存中是如何被拆解和存放的,我们就获得了开启这段算法大门的钥匙。快速平方根倒数算法的本质,就是通过一种极度不符合常规的手段,直接对这 32 个微小的开关(比特)进行数学暴击,从而巧妙地绕开了沉重的公式计算

违背常理的欺骗:将浮点数伪装成整数

代码的第一步是计算输入值的一半 float xhalf = 0.5f*x;,这是为了最后一步的牛顿迭代法做准备。紧接着,算法迎来了它最具争议,也是最核心的一行代码:

C
int i = *(int*)&x; // get bits for floating VALUE 

在 C 语言的编程规范中,浮点数(float)和 32 位整数(int)虽然都占用 32 位的内存空间,但它们的内部排列方式(正如上一节所述)是完全不同的 。通常情况下,如果程序员想要将一个带有小数的浮点数转换为整数,CPU 会启动一个专门的转换程序,将小数部分舍去,将剩下的数值翻译成整数的二进制格式。

然而,这行代码并没有执行任何数值上的“转换”。这里使用了 C 语言中的“指针”(Pointer)操作。对于初学者来说,可以将内存想象成一长排带有编号的储物柜。变量 x 就存放在某个特定的储物柜中,它存放的格式是 IEEE 754 浮点数格式。代码 &x 的作用是获取这个储物柜的物理地址。接下来,强转符号 (int*) 相当于给 CPU 戴上了一副新的“有色眼镜”,命令 CPU:“去那个地址,把里面装的 32 个开关的状态原封不动地取出来,但是请你假装它是一个普通的整数来阅读它”

这种操作在计算机科学中被称为“类型双关”(Type Punning)或“指针别名”(Pointer Aliasing)。原作者甚至在最初的代码中留下注释,称其为“邪恶的浮点位级别黑客手段”(evil floating point bit level hacking)

为什么会有如此荒谬的操作?如果把一段按照科学记数法排列的浮点数代码强行当作普通整数来读取,会得到什么结果呢?

让我们用数学来推导这个结果。如果将这 32 个比特强行视为一个底层的十进制整数,我们将其命名为 $I_x$。因为指数部分 $E$ 位于整个 32 位序列的较高位置,它被向左移动了 23 位(即在它后面有 23 个属于尾数的坑位),而尾数 $M$ 占据了最底层的 23 位。因此,这个强行读取出来的整数 $I_x$ 的数学表达式为

$$I_x = 2^{23} \times E + (M \times 2^{23})$$

提取公因数 $2^{23}$ 后,我们得到:

$$I_x = 2^{23} \times (E + M)$$

请牢记这个被我们“错误”解读出来的整数公式 $I_x = 2^{23} \times (E + M)$。接下来,我们将见证数学史上一次令人惊叹的巧合,这也是整个算法之所以能够成立的核心灵魂。

无中生有的对数:魔法般的线性近似

现在,让我们暂时把刚才那段奇怪的整数代码放在一边,回到我们最初试图解决的数学问题上。我们的目标是计算 $\frac{1}{\sqrt{x}}$。在代数中,计算平方根等同于将一个数求 $1/2$ 次方。而计算它的倒数,则等同于将指数变为负数。因此,我们可以将问题写成指数形式:

$$\frac{1}{\sqrt{x}} = x^{-1/2} = x^{-0.5}$$

直接计算一个带有小数的负指数是非常困难的。但是,数学中有一个非常强大的工具可以将复杂的指数运算降维打击成简单的乘法运算,这个工具就是“对数”(Logarithm)。对数的一个基本法则是:一个带指数的数的对数,等于该指数乘以这个数的对数。如果我们对目标等式两边同时取以 $2$ 为底的对数,就会得到:

$$\log_2(x^{-0.5}) = -0.5 \times \log_2(x)$$

这个公式极其关键!它告诉我们一个天大的好消息:如果你能想办法求出 $x$ 的对数,那么你只需要把这个对数乘以 $-0.5$(在计算机里也就是除以 $-2$),就能直接得到你想要的平方根倒数的对数

那么,如何快速计算一个浮点数的对数呢?让我们把 IEEE 754 的浮点数定义公式 $x = (1 + M) \times 2^{E – 127}$ 拿出来,对它求以 $2$ 为底的对数:

$$\log_2(x) = \log_2((1 + M) \times 2^{E – 127})$$

根据对数的乘法法则(两个数相乘的对数等于这两个数的对数相加),这个等式可以拆解为:

$$\log_2(x) = \log_2(1 + M) + \log_2(2^{E – 127})$$

由于以 $2$ 为底的对数和 $2$ 的多少次幂是完全抵消的操作,等式右边第二项直接简化为了指数本身:

$$\log_2(x) = \log_2(1 + M) + E – 127$$

接下来,数学奇迹出现了。请观察公式中的第一项:$\log_2(1 + M)$。我们知道,尾数 $M$ 是一个严格介于 $0$ 和 $1$ 之间的小数。如果在平面直角坐标系中画出 $y = \log_2(1 + M)$ 在 $0$ 到 $1$ 之间的曲线,你会发现这条曲线出奇的平缓,它几乎就是一条笔直的直线!

因为这条曲线太像一条直线了,我们完全可以用一条最简单的直线方程 $y = M$ 来近似替代它。为了让这条直线在整体上更贴合对数曲线,我们可以人为地给这条直线增加一个微小的向上平移的修正值,我们将其称为 $\sigma$(Sigma) 。因此,我们得出了一个近似等式:

$$\log_2(1 + M) \approx M + \sigma$$

这种用直线来近似代替复杂曲线的方法在微积分中被称为线性近似(Linear Approximation)。将这个近似公式代回到我们刚才的对数等式中:

$$\log_2(x) \approx M + \sigma + E – 127$$

重组一下各项的顺序,把变量 $E$ 和 $M$ 放在一起:

$$\log_2(x) \approx (E + M) + \sigma – 127$$

等等,仔细看这个式子里的 $(E + M)$。你是否觉得它似曾相识?没错!在上一节中,当我们粗暴地把浮点数在内存里的比特当作整数读取出来时,那个被我们称为 $I_x$ 的整数,其公式恰好就是 $I_x = 2^{23} \times (E + M)$

如果我们将 $(E + M)$ 替换为 $\frac{I_x}{2^{23}}$,我们就可以将对数公式改写为:

$$\log_2(x) \approx \frac{I_x}{2^{23}} + \sigma – 127$$

这就是这段代码能够名垂青史的理论基石!这个等式以无可辩驳的数学逻辑证明了:当你在 C 语言中将一个浮点数的内存强行转换为一个整数时,你实际上并没有得到一串乱码。你得到的是这个浮点数以 $2$ 为底的对数的一个极其优美的近似值! 这个近似值仅仅是被放大了 $2^{23}$ 倍,并且减去了一个常数($127 – \sigma$)而已

通过一行“邪恶”的指针代码,程序员兵不血刃地跨越了 CPU 计算对数所需的无数个时钟周期,白嫖了一次极其昂贵的对数运算

移位与魔术数字:0x5f375a86 的诞生

有了 $x$ 的对数的整数表示形式,代码继续执行下一步运算。这一行不仅是初次近似的计算核心,也是魔术数字出现的环节:

C
i = 0x5f375a86 - (i >> 1); // gives initial guess y0

这段代码执行了两个独立的操作:一个是位移操作 (i >> 1),另一个是用一个十六进制的常数 0x5f375a86 减去位移后的结果

位移:用最底层的手段做除法

在二进制算术中,将一个数字的所有位向右移动一位(即右移操作 >> 1),在数学上完全等同于将这个整数除以 $2$ 并且向下取整 。对于初学者,你可以将其想象成十进制。在十进制中,把数字 $150$ 的小数点向左移动一位变成 $15.0$,实际上就是除以 $10$。在二进制中,向右移动位就是除以基数 $2$

回忆一下我们在对数部分推导出的关于平方根倒数的关键关系式:

$$\log_2(y) = -0.5 \times \log_2(x)$$

既然变量 i 当前保存的整数形式实质上代表了 $\log_2(x)$,那么将 i 向右移动一位(除以 $2$),就完美地完成了公式中乘积的一半(也就是 $0.5 \times \log_2(x)$)。而公式中的负号,则通过用一个常数减去这个右移后的 i 来实现,这比先取负数再加上常数要高效得多

推导魔术数字的真身

为什么不能直接把移位后的结果转换回浮点数,而必须用一个莫名其妙的十六进制常数去减它呢?因为 IEEE 754 浮点数标准中内置了 $2^{23}$ 的放大倍数以及 $127$ 的指数偏移量。我们在进行除以 $2$ 的数学操作时,不小心把这个存储框架本身也给除以 $2$ 破坏掉了 。为了让最终的结果依然符合 IEEE 754 的严格格式,使其在日后能够被 CPU 重新当作一个合法的浮点数来读取,我们必须引入一个补偿常数来修复被破坏的框架结构

让我们用严谨的代数推导来找到这个补偿常数究竟应该是多少。假设我们想要得到的最终结果是 $y = 1/\sqrt{x}$。根据我们在上一节推导出的对数与整数的对应关系方程:

对于目标浮点数 $y$,其整数形式 $I_y$ 与其对数的关系为:

$$\log_2(y) \approx \frac{I_y}{2^{23}} + \sigma – 127$$

对于输入的浮点数 $x$,其整数形式 $I_x$ 与其对数的关系为:

$$\log_2(x) \approx \frac{I_x}{2^{23}} + \sigma – 127$$

将这两个方程代入我们推导出的目标对数关系式 $\log_2(y) = -0.5 \times \log_2(x)$ 中:

$$\frac{I_y}{2^{23}} + \sigma – 127 \approx -0.5 \times \left( \frac{I_x}{2^{23}} + \sigma – 127 \right)$$

我们的目标是解出 $I_y$(即我们希望代码输出的那个新整数)。通过简单的代数移项,我们将等式左边的 $(\sigma – 127)$ 移动到右边:

$$\frac{I_y}{2^{23}} \approx -0.5 \times \left( \frac{I_x}{2^{23}} \right) – 0.5 \times (\sigma – 127) – (\sigma – 127)$$

合并右边关于 $(\sigma – 127)$ 的项:

$$\frac{I_y}{2^{23}} \approx -0.5 \times \left( \frac{I_x}{2^{23}} \right) – 1.5 \times (\sigma – 127)$$

等式两边同时乘以 $2^{23}$:

$$I_y \approx 1.5 \times 2^{23} \times (127 – \sigma) – 0.5 \times I_x$$

这个最终的等式,简直就像是为那行神奇的代码量身定制的一样。等式中的 $-0.5 \times I_x$ 完美对应了代码里的 - (i >> 1)。而剩下的那一长串数学算式 $1.5 \times 2^{23} \times (127 – \sigma)$,其中没有任何未知的变量,它完完全全是一个常数!

只要计算出这个常数的具体数值,一切就真相大白了。要计算这个常数,唯一的变量是我们之前为了拟合对数曲线而人为引入的微小修正值 $\sigma$ 。如果采用数学手段求解,为了让那条直线在所有的浮点数范围内产生尽可能小的相对误差,最优的 $\sigma$ 取值大约是 $0.0450466$

将这个最优 $\sigma$ 拔代入常数公式中:

$$\text{常数} = 1.5 \times 8388608 \times (127 – 0.0450466)$$

$$\text{常数} \approx 1597463007$$

将十进制整数 $1597463007$ 转换为计算机编程常用的十六进制格式,结果就是 0x5f3759df !这就是《雷神之锤 III》源代码中使用的最原始的那个魔术数字。它绝不是程序员拍脑袋随便敲出来的乱码,而是通过几何学、对数律以及 IEEE 754 内存存储机制精确推导出的数学结晶

优化与进化:为何代码使用的是 0x5f375a86?

细心的读者会发现,开头提到的代码中,使用的常数并非原版的 0x5f3759df,而是 0x5f375a86。这个微妙的变化发生在这段代码公开之后,引发了学术界对该算法的深刻探讨 。

2003年,普渡大学的数学家克里斯·洛蒙特(Chris Lomont)决定用严密的数学分析来探究这个原版常数究竟是不是数学上的“最优解”,抑或只是开发者通过试错找出的一个近似值 。洛蒙特完全通过解析数学的手段,使用微积分推导和 Mathematica 软件进行最小化函数计算,重新求导了尾数和指数的最优组合。他从纯理论上推导出了一个新的常数:0x5f37642f

然而,当洛蒙特将他推导出的新常数放入实际代码中进行千万次测试时,他发现了一个违背直觉的悖论 。正如后续我们将讨论的,这个算法并不是到此为止,它在计算出初始近似值后,还会进行一次牛顿迭代来进行数值精化。洛蒙特发现,他推导的常数虽然能在“初始近似步骤”提供更低的误差(将误差从原版的 3.437% 降到了 3.421%),但是在经历了最终的牛顿迭代步骤后,其最终误差反而比原版的还要大

这一发现揭示了算法的系统性深意:对于整个算法流程而言,“最好”的常数必须能够考虑到下一步牛顿迭代法对误差的重塑效果。纯粹解析几何推导出的初始最优解,并不等同于经过非线性迭代后的全局最优解

为了找到真正的全局最优解,洛蒙特放弃了单纯的理论计算,转而编写了一个庞大的枚举程序。他以原版常数为中心,对附近所有的整数进行暴力遍历和穷举测试,计算每一个可能常数在经历初始猜测加上一次牛顿迭代后的最终最大相对误差

通过这次暴力的算法搜寻,洛蒙特最终锁定了真正无懈可击的最佳常数:0x5f375a86 。这个常数相当于稍微调整了之前的 $\sigma$ 值(大约调整到了 $0.0430357$)。虽然它在第一步的误差略大,但它能和后续的牛顿迭代产生极佳的化学反应。以下表格详细对比了这几个常数在各个阶段的最大相对误差情况

常数起源与命名十六进制值初始猜测时的最大误差经过1次牛顿迭代后的误差经过2次牛顿迭代后的误差
《雷神之锤》原版常数0x5f3759df3.43758%0.175228%0.000466%
洛蒙特纯数学推导常数0x5f37642f3.42128%0.177585%0.000477%
洛蒙特暴力寻优常数0x5f375a863.43655%0.175124%0.000465%

如表所示,我们的代码采用了洛蒙特改良后的 0x5f375a86,它将算法最终的相对误差降到了难以置信的 0.175124% 。这也就解释了为什么当代更为严谨的代码库都会使用这个经过数学和计算机科学双重验证的进化版本 。

画龙点睛的抛光:牛顿-拉弗森迭代法

到目前为止,算法已经通过极其聪明的黑客手段得到了一个不错的估算值。代码执行到 x = *(float*)&i; 时,指针再次将我们计算出的整数 i 强制转换回正常的浮点数格式 。此时,这个浮点数 $y$ 距离真正的 $1/\sqrt{x}$ 只有不到 3.4% 的误差。

然而,在对视觉要求极高的三维图形渲染中,3.4% 的误差会导致严重的问题。归一化后的向量长度如果参差不齐,画面中的光照效果就会出现肉眼可见的闪烁(Jitter),反射的表面会显得斑驳不堪 。为了将这块粗糙的原石打磨成毫无瑕疵的宝石,将误差降低到 0.2% 以下,代码引入了最后一步,也是古典数学王冠上的明珠——牛顿-拉弗森方法(Newton-Raphson Method),通常简称为牛顿迭代法

初学者视角:切线如何寻找真相

牛顿迭代法是十七世纪由艾萨克·牛顿和约瑟夫·拉弗森发明的一种算法,其主要目的是快速逼近一个复杂函数的根(即函数曲线与横轴相交的那一点,使得 $f(x) = 0$ 的点)

对于数学初学者而言,可以通过一个简单的视觉比喻来理解。想象有一座呈现出下凹弧形的神秘山谷,我们站在山谷的某处,我们的目标是找到山谷最底部的海拔为零的位置(也就是根)。因为大雾弥漫,我们看不见谷底在哪里,但我们手里拿着一根绝对笔直的长竹竿。

牛顿迭代法的做法是:

  1. 初始猜测:我们先凭直觉猜一个大概位置,走到那个位置上(这对应于我们通过位移和常数计算出的粗糙结果 $y_0$)。
  2. 绘制切线:在当前位置,我们将笔直的竹竿顺着山坡的坡度贴在地面上。在数学上,这根顺着坡度笔直延伸的竹竿就是曲线在该点的“切线”(Tangent Line)。决定这根竹竿倾斜角度的数学工具,叫做“导数”(Derivative)。
  3. 顺藤摸瓜:我们顺着这根笔直的竹竿一直往下看,找到竹竿与海拔为零的水平地面的交点。
  4. 更新位置:因为竹竿是顺着坡度指引的,所以竹竿与地面的交点,必然比我们当前的位置更接近真正的谷底。我们走到竹竿指向的新交点,把这个新位置作为下一次猜测的起点(更新为 $y_1$)。

由于这个山谷的形状是平滑的,只要我们重复上述动作,每次找到新的切线交点,我们就会以极快的速度(通常每次迭代,精确的有效数字位数都会翻倍)冲向真正的谷底

推导牛顿法的代数方程

要将牛顿法应用于这行代码,我们必须首先构建一个特定的数学方程 $f(y)$,使得当这个方程等于 $0$ 时,$y$ 的值恰好是我们梦寐以求的平方根倒数。

我们设期望得到的结果为 $y = \frac{1}{\sqrt{x}}$。

等式两边同时平方,消去讨厌的根号:

$$y^2 = \frac{1}{x}$$

将方程右侧的 $x$ 和左侧的 $y$ 进行代数移项,以构建一个等于零的函数:

$$\frac{1}{y^2} – x = 0$$

因此,我们成功构建了目标函数:

$$f(y) = y^{-2} – x$$

在这个函数中,待求的未知数是 $y$,而我们最开始输入的数字 $x$ 在这一步的推导中被仅仅当作一个已知的常数来对待

根据微积分中牛顿迭代法的标准通用公式,如果我们当前的猜测值是 $y_n$,那么下一个更加精确的猜测值 $y_{n+1}$ 的计算公式为当前的猜测值减去该点处的函数值与该点处导数值的比率

$$y_{n+1} = y_n – \frac{f(y_n)}{f'(y_n)}$$

我们需要求出目标函数的导数 $f'(y)$。根据微积分中基本的幂求导法则(将指数乘到前面,并使指数减去 $1$),常数 $x$ 的导数为 $0$ 会直接消失,而 $y^{-2}$ 的导数则变为 $-2y^{-3}$

$$f'(y) = -2y^{-3} = -\frac{2}{y^3}$$

将 $f(y)$ 及其导数 $f'(y)$ 一并代入牛顿迭代公式的通用模版中

$$y_{n+1} = y_n – \frac{y_n^{-2} – x}{-2y_n^{-3}}$$

这个带有复杂负指数的繁琐分数可以通过初等代数进行大规模简化。我们将分子上的两项分别除以分母:

$$y_{n+1} = y_n – \left( \frac{y_n^{-2}}{-2y_n^{-3}} – \frac{x}{-2y_n^{-3}} \right)$$

利用指数相除即指数相减的原则($y^{-2} / y^{-3} = y^1$):

$$y_{n+1} = y_n – \left( -\frac{y_n}{2} + \frac{x y_n^3}{2} \right)$$

将括号外的负号乘入括号内:

$$y_{n+1} = y_n + \frac{y_n}{2} – \frac{x y_n^3}{2}$$

合并前面的两个同类项($1个 y_n$ 加上 $0.5个 y_n$ 等于 $1.5个 y_n$):

$$y_{n+1} = 1.5y_n – 0.5 x y_n^3$$

最后一步,为了最大程度减少乘法操作的数量以提高代码在 CPU 中的运行效率,我们从后面的项中提取出一个公因数 $y_n$

$$y_{n+1} = y_n \times (1.5 – 0.5 \times x \times y_n^2)$$

代数与 C 代码的完美融合

现在,请屏住呼吸,将我们经过微积分求导并经过重重代数化简得到的最终数学公式,与原代码的最后一行实质性计算进行逐字比对

C
x = x*(1.5f-xhalf*x*x); // Newton step, repeating increases accuracy

在这行代码中:

  • 左侧的 x 是用来接收结果的变量,对应等式左边的 $y_{n+1}$。
  • 等式右边开头乘以的那个 x,实质上是我们在上一步求出的初次估算值 $y_0$,对应公式里的外层 $y_n$。
  • 1.5f 毫无悬念地对应公式里的常数 $1.5$ 。
  • xhalf 是代码在最开头第一行就提前计算好并保存起来的 0.5f * x(也就是 $0.5x$)。这又是一个极度精打细算的优化,避免了在核心循环里重复计算除法 。
  • 最后的 x*x 是当前估算值的平方,恰好对应公式里的 $y_n^2$ 。

至此,一切豁然开朗。这行代码没有任何的魔法,它是极其严谨、纯粹的牛顿迭代法的代码化呈现

更令人惊叹的是,由于之前通过浮点位级别的黑客操作得到的初始估算值 $y_0$ 已经拥有了不可思议的准确度(仅有 3.4% 左右的误差),牛顿迭代法在这个极其平滑的区域内表现出了惊人的收敛速度 。经过一次这种只需加减乘运算、毫无除法和开方的牛顿计算,结果的误差瞬间跌落至不足 0.2% 。这种精度对于图形学中诸如法线贴图和环境光遮蔽的向量归一化而言,已经绰绰有余。在原版《雷神之锤》中,开发者曾经写下了第二次牛顿迭代的代码,但由于发现毫无必要,便用双斜杠 // 将其注释掉了,以榨取最后一丝计算性能

历史的长河:现代硬件架构下的算法退场

尽管快速平方根倒数算法被奉为软件工程和底层系统优化的不朽传奇,展现了当程序员深刻掌握了高等数学原理与底层硬件架构的交叉领域时所能创造的奇迹,但促使这套算法诞生的根本原因——硬件算力瓶颈——最终也成为了导致这套算法退出历史舞台的原因

随着三维图形计算从《雷神之锤》时代的小众游戏需求,迅速成长为涵盖科学计算、计算机视觉、以及人工智能等多个领域的通用计算需求,硬件制造商们深刻意识到了向量归一化对于系统整体性能的巨大影响。

1999 年,几乎在《雷神之锤 III》席卷全球的同时,英特尔(Intel)公司在其新一代的 x86 微处理器架构中引入了划时代的 SSE(Streaming SIMD Extensions,单指令多数据流扩展)指令集 。在这个全新的指令集中,芯片设计师们从硬件的硅片电路上,直接用逻辑门电路烧录了一条专门针对这项计算的硬件指令:rsqrtss(Reciprocal Square Root Scalar Single-Precision,标量单精度平方根倒数)

这项硬件指令的出现,彻底改变了游戏规则。以往需要通过 C 语言进行多行代码转换、移位运算、常数相减以及软件层面的牛顿迭代才能完成的计算,如今可以被 CPU 内部自带的专用硬件查找表(Lookup Tables)和硬连接(Hardwired)的迭代器在一瞬间并行完成 2

到 2009 年的测试基准(Benchmark)中,在当时主流的 Intel Core 2 处理器上调用硬件级的 rsqrtss 指令,执行一次平方根倒数计算甚至只需耗费大约 0.85 纳秒(Nanosecond)的时间 。这种纳秒级的执行速度,以数量级的优势无情地碾压了《雷神之锤》引擎中曾经叱咤风云的移位运算黑客代码 。由于现代计算机的速度已经达到当时计算机的几万倍,浮点数除法已经不再是制约系统性能的致命瓶颈

时至今日,如果你在使用 C++、Rust 或者其他现代编译型语言编写需要计算 $1/\sqrt{x}$ 的代码,几乎没有人在生产环境中会手动去敲下 0x5f375a86 这个数字。因为即使你这么做了,高度智能化的现代编译器(如 GCC 或 Clang)在进行代码优化分析时,也会在底层无情地剥离掉这些充满历史沧桑感的移位逻辑,并静悄悄地将它们替换为执行速度更快的单条机器码指令(如 rsqrtss),因为编译器比谁都清楚,硅片比代码跑得更快

即便如此,在电子游戏的编年史和计算机科学的发展脉络中,这段寥寥数行的快速平方根倒数算法依然闪烁着非凡的智慧光芒。它就像是一座桥梁,一端连接着离散且呆板的二进制数据结构,另一端连接着连续且优美的微积分与对数变换。它证明了在计算能力最匮乏的蛮荒时代,人类的数学直觉与工程智慧是如何交织在一起,突破硬件的物理极限,从而在屏幕上绘制出那个令人惊叹的三维数字世界的。这段代码如今虽然已从生产线的齿轮中退役,但作为一种计算机史上的独特非物质文化遗产,它将永远在各大高等学府的课堂以及程序员的谈资中被传唱

  1. 这并非《雷神之锤》最原始的版本,而是经过数学家克里斯·洛蒙特(Chris Lomont)优化后的改进版本。 ↩︎
  2. 对于嵌入式领域来说,像F103这类不带浮点运算单元的MCU里依然需要用到上述代码;但在F429这类带有FPU的MCU里,直接执行y = 1.0f / sqrtf(x);会更快一点。 ↩︎

[ 发起通讯连接 / INITIATE COMM-LINK ]

[SYS]: 您的回传节点(邮箱)将被严格保密。带有 * 的字段为必填项。


> 终止读取并返回主控制台 <