数学建模社区-数学中国

标题: 牛B的 John Carmack密码:0x5f3759df--Quake III的源代码分析 [打印本页]

作者: 5800    时间: 2009-11-26 23:24
标题: 牛B的 John Carmack密码:0x5f3759df--Quake III的源代码分析
有人在Quake III(雷神之锤3)的源代码里面发现这么一段用来求平方根的代码:
4 n/ g4 Y9 k( ?- U' L; \& p& y/*================SquareRootFloat================*/. ?5 ?* g) i5 @1 F
float SquareRootFloat(float number) {
' A. O3 B8 t: M& k! j7 plong i;
" p% u" S" \: d+ O3 }) }6 [float x, y;
! d* A$ M" L  V3 W; H: s- Sconst float f = 1.5F;
: f7 W. a' S' X- }) F$ qx = number * 0.5F;
- O8 A1 x7 o8 _y = number;
( h$ ~6 v3 M9 C6 Z# s7 Qi = * ( long * ) &y;. [; ]0 }% X& q1 D
i = 0x5f3759df - ( i >> 1 ); //注意这一行9 P/ a5 c. V! g% a: M; G0 I
y = * ( float * ) &i;
5 E7 A! y0 ^9 @- t2 P, Z5 B+ Yy = y * ( f - ( x * y * y ) );0 u0 \) b1 @) }- S. W
y = y * ( f - ( x * y * y ) );; _+ O# }$ `* ^
return number * y;. i  F8 z4 C% x. B8 @
}$ w2 H6 R1 [! |, y' H
0x5f3759df? 这是个什么东西? 学过数值分析就知道,算法里面求平方根一般采用0 L, z5 O# O9 s
的是无限逼近的方法,比如牛顿迭代法,抱歉当年我数值分析学的太烂,也讲不清楚
0 ?! f6 I1 X5 v. x# [。简单来说比如求5的平方根,选一个猜测值比如2,那么我们可以这么算
' t# s; s; M9 x% t: a% M5/2 = 2.5; 2.5+2/2 = 2.25; 5/2.25 = **; 2.25+**/2 = **x .... E% j. p% Z5 d3 P7 t
这样反复迭代下去,结果必定收敛于sqrt(5),没错,一般的求平方根都是这么算的; Q* N  ^  P; ]' [6 F
。而卡马克的不同之处在于,他选择了一个神秘的猜测值0x5f3759df作为起始,使得5 X) J( _* q3 Q( {
整个逼近过程收敛速度暴涨,对于Quake III所要求的精度10的负三次方,只需要一; C+ k  p) |) r% A# c
次迭代就能够得到结果。
/ ~2 v, a! Z8 m% {, H好吧,如果这还不算牛b,接着看。
; v7 P: N5 [) `- ]0 N( _/ S+ _! V普渡大学的数学家Chris Lomont看了以后觉得有趣,决定要研究一下卡马克弄出来的) W' |* \1 c0 |5 Z! o/ e
这个猜测值有什么奥秘。Lomont也是个牛人,在精心研究之后从理论上也推导出一个
" E. H" d8 R$ F3 ^1 A最佳猜测值,和卡马克的数字非常接近, 0x5f37642f。卡马克真牛,他是外星人吗?
8 @& {) f/ O1 ]5 Q7 h/ G) @
! P3 _' B$ O7 i5 \传奇并没有在这里结束。Lomont计算出结果以后非常满意,于是拿自己计算出的起始
7 d! d% A7 ?( e; h值和卡马克的神秘数字做比赛,看看谁的数字能够更快更精确的求得平方根。结果是+ u' t- P# U" G+ C- u) H7 w! ~
卡马克赢了... 谁也不知道卡马克是怎么找到这个数字的。/ o& J' J. E: T% r# h  s
最后Lomont怒了,采用暴力方法一个数字一个数字试过来,终于找到一个比卡马克数; ]4 l- @" w1 _4 t& j& f0 B
字要好上那么一丁点的数字,虽然实际上这两个数字所产生的结果非常近似,这个暴
' k5 p4 `4 E1 L3 l力得出的数字是0x5f375a86。% g5 j/ A3 K: O* ^
Lomont为此写下一篇论文,"Fast Inverse Square Root"。" N+ w: J, }# U; V
John Carmack, ID的无价之宝。! M* ]: f) _- R9 R$ ^% Z: J
//===============================================================% P9 y$ L# l% C' E+ l9 d" g2 y" A  L
日前在书上看到一段使用多项式逼近计算平方根的代码,至今都没搞明白作者是怎样推算出那个公式的。但在尝试解决问题的过程中,学到了不少东西,于是便有了这篇心得,写出来和大家共享。其中有错漏的地方,还请大家多多指教。
9 s; h, ]3 I4 |4 k: p) y的确,正如许多人所说的那样,现在有有FPU,有3DNow,有SIMD,讨论软件算法好像不合时宜。关于sqrt的话题其实早在2003年便已在 GameDev.net上得到了广泛的讨论(可见我实在非常火星了,当然不排除还有其他尚在冥王星的人,嘿嘿)。而尝试探究该话题则完全是出于本人的兴趣和好奇心(换句话说就是无知)。
4 x( F9 W0 h- }; P9 s- b( |我只是个beginner,所以这种大是大非的问题我也说不清楚(在GameDev.net上也有很多类似的争论)。但无论如何,Carmack在DOOM3中还是使用了软件算法,而多知道一点数学知识对3D编程来说也只有好处没坏处。3D图形编程其实就是数学,数学,还是数学。4 u* X) _, \4 w' m( r
文章原本是用HTML编排的,所以只截取了部分有比较有趣的东西放在这里。原文在我的个人主页上,同时也提供了2篇论文的下载:http://greatsorcerer.go2.icpcn.com/info/fastsqrt.html$ i" L' ?* W1 ~& B# Q9 R& X
=========================================================% d/ U8 j, K7 f$ `0 Y
在3D图形编程中,经常要求平方根或平方根的倒数,例如:求向量的长度或将向量归一化。C数学函数库中的sqrt具有理想的精度,但对于3D游戏程式来说速度太慢。我们希望能够在保证足够的精度的同时,进一步提高速度。
& M* h9 ?; B0 Z; z! ?0 ACarmack在QUAKE3中使用了下面的算法,它第一次在公众场合出现的时候,几乎震住了所有的人。据说该算法其实并不是Carmack发明的,它真正的作者是Nvidia的Gary Tarolli(未经证实)。
4 \% _/ a/ O$ n" p& L-----------------------------------
; G% P) ]' W" V+ B1 \//
2 H# q- T, S$ k4 A8 N; v- x, V; y// 计算参数x的平方根的倒数3 B  [, T8 }+ W) W
//
3 F& P( o0 D5 `2 Vfloat InvSqrt (float x)
( c- X3 b6 o+ @2 |5 }{
8 J3 e' d: y. ?7 f% C+ ufloat xhalf = 0.5f*x;3 [' C5 q/ \# y; |
int i = *(int*)&x;2 X! e+ Y$ M. X/ z# e, I
i = 0x5f3759df - (i >> 1); // 计算第一个近似根
7 h- g) _  g9 P8 ~+ ?; A2 B) p" Qx = *(float*)&i;
/ d$ d5 S* R2 }x = x*(1.5f - xhalf*x*x); // 牛顿迭代法0 I# E- `' ?. q* G1 p/ I1 f# r$ A
return x;
3 V+ z8 P6 m( u* F}1 l2 I* q- e  q2 n& y% b
----------------------------------
1 x3 d, N% N7 v: b该算法的本质其实就是牛顿迭代法(Newton-Raphson Method,简称NR),而NR的基础则是泰勒级数(Taylor Series)。NR是一种求方程的近似根的方法。首先要估计一个与方程的根比较靠近的数值,然后根据公式推算下一个更加近似的数值,不断重复直到可以获得满意的精度。其公式如下:
$ p; b" Q+ ], X! I2 b) n/ G------------------------------------ P1 o2 h' Q1 k
函数:y=f(x)
6 x# v$ A9 K* d其一阶导数为:y'=f'(x)
3 N# h8 Q; j- W; n则方程:f(x)=0 的第n+1个近似根为
# v6 |: U) D1 a' @x[n+1] = x[n] - f(x[n]) / f'(x[n]), V0 u7 `& l0 Z( p2 A. q4 b3 f
-----------------------------------
5 X' q0 G8 e+ ~1 `& ~7 fNR最关键的地方在于估计第一个近似根。如果该近似根与真根足够靠近的话,那么只需要少数几次迭代,就可以得到满意的解。" s1 a8 x) g1 q2 i5 Y1 u! J
现在回过头来看看如何利用牛顿法来解决我们的问题。求平方根的倒数,实际就是求方程1/(x^2)-a=0的解。将该方程按牛顿迭代法的公式展开为:4 N7 y" Z, Z- W
x[n+1]=1/2*x[n]*(3-a*x[n]*x[n])( L  K& \4 L% ]% q) s
将1/2放到括号里面,就得到了上面那个函数的倒数第二行。6 Y: l3 c/ n" d9 o
接着,我们要设法估计第一个近似根。这也是上面的函数最神奇的地方。它通过某种方法算出了一个与真根非常接近的近似根,因此它只需要使用一次迭代过程就获得了较满意的解。它是怎样做到的呢?所有的奥妙就在于这一行:
, D+ I/ t& I3 v" v/ N6 Z3 [" Wi = 0x5f3759df - (i >> 1); // 计算第一个近似根
: m( p4 O1 Q: ~8 t+ j* N: q+ D- N超级莫名其妙的语句,不是吗?但仔细想一下的话,还是可以理解的。我们知道,IEEE标准下,float类型的数据在32位系统上是这样表示的(大体来说就是这样,但省略了很多细节,有兴趣可以GOOGLE):
; }3 U4 Y3 h6 L, Z, D-------------------------------
6 i1 S" I' S4 I5 |2 \: K5 H! Jbits:31 30 ... 0: Z# d* p9 E- m$ b
31:符号位& w3 W7 G( P. H$ g7 \7 g% o  q) U
30-23:共8位,保存指数(E)
' B! O! A4 W7 u, C0 |' M7 O9 I22-0:共23位,保存尾数(M)- I9 x& _& }1 W0 ?8 F7 F- K# q
-------------------------------
% A7 z7 `4 F5 A: @4 D* w+ c; @/ m. P所以,32位的浮点数用十进制实数表示就是:M*2^E。开根然后倒数就是:M^(-1/2)*2^(-E/2)。现在就十分清晰了。语句i> >1其工作就是将指数除以2,实现2^(E/2)的部分。而前面用一个常数减去它,目的就是得到M^(1/2)同时反转所有指数的符号。2 x* D2 ^# F# A+ I) ]
至于那个0x5f3759df,呃,我只能说,的确是一个超级的Magic Number。5 d7 N/ k- v7 d' z
那个Magic Number是可以推导出来的,但我并不打算在这里讨论,因为实在太繁琐了。简单来说,其原理如下:因为IEEE的浮点数中,尾数M省略了最前面的1,所以实际的尾数是1+M。如果你在大学上数学课没有打瞌睡的话,那么当你看到(1+M)^(-1/2)这样的形式时,应该会马上联想的到它的泰勒级数展开,而该展开式的第一项就是常数。下面给出简单的推导过程:
7 g5 H6 q' M! b% H. T9 V-------------------------------; f( j0 G1 t$ R1 l$ A3 O
对于实数R>0,假设其在IEEE的浮点表示中,9 t, Z: Z6 \# Z
指数为E,尾数为M,则:
2 l* i/ B# C7 \8 m1 f5 y  t$ _% @R^(-1/2)
; k% @3 H( \2 G0 [5 R3 S: v& Q= (1+M)^(-1/2) * 2^(-E/2)& W- A3 }3 A/ g% \) C3 D7 w& I
将(1+M)^(-1/2)按泰勒级数展开,取第一项,得:
0 y( D7 E( r% Y9 p9 G0 D9 C& ]/ d! N2 b$ N原式# c* f' ?" A4 w/ ^8 E+ c* c1 W/ i
= (1-M/2) * 2^(-E/2)
2 Q/ K% b: x; s= 2^(-E/2) - (M/2) * 2^(-E/2)
) R3 G4 {' X1 ~0 c如果不考虑指数的符号的话,5 o! K; y2 o4 Q, J( f9 H% A2 W2 t
(M/2)*2^(E/2)正是(R>>1),
8 U& ]( b$ |5 r# E) V' I而在IEEE表示中,指数的符号只需简单地加上一个偏移即可,
: o* p  t/ M9 o4 V7 _而式子的前半部分刚好是个常数,所以原式可以转化为:
( m0 A/ W5 F! I+ ?" _! _原式 = C - (M/2)*2^(E/2) = C - (R>>1),其中C为常数
" J: K& \) w+ I3 Q# `7 P) @( v所以只需要解方程:
1 h8 s3 ^/ e* U% F3 hR^(-1/2)
& X) u9 x& E5 I0 }2 n( V= (1+M)^(-1/2) * 2^(-E/2)
( [& {  _  e: D= C - (R>>1)& Q: X+ H2 k/ @  u1 y: H: X
求出令到相对误差最小的C值就可以了) h! W6 _% X9 s' c% m! \7 y. g0 I
-------------------------------( v+ C8 [0 G/ n6 u6 Z/ J7 Q
上面的推导过程只是我个人的理解,并未得到证实。而Chris Lomont则在他的论文中详细讨论了最后那个方程的解法,并尝试在实际的机器上寻找最佳的常数C。有兴趣的朋友可以在文末找到他的论文的链接。+ G. v3 Y2 @8 i" W! {; }
所以,所谓的Magic Number,并不是从N元宇宙的某个星系由于时空扭曲而掉到地球上的,而是几百年前就有的数学理论。只要熟悉NR和泰勒级数,你我同样有能力作出类似的优化。2 C1 {1 B1 m- Q  I
在GameDev.net上有人做过测试,该函数的相对误差约为0.177585%,速度比C标准库的sqrt提高超过20%。如果增加一次迭代过程,相对误差可以降低到e-004 的级数,但速度也会降到和sqrt差不多。据说在DOOM3中,Carmack通过查找表进一步优化了该算法,精度近乎完美,而且速度也比原版提高了一截(正在努力弄源码,谁有发我一份)。2 n. _$ x) g0 a, @
值得注意的是,在Chris Lomont的演算中,理论上最优秀的常数(精度最高)是0x5f37642f,并且在实际测试中,如果只使用一次迭代的话,其效果也是最好的。但奇怪的是,经过两次NR后,在该常数下解的精度将降低得非常厉害(天知道是怎么回事!)。经过实际的测试,Chris Lomont认为,最优秀的常数是0x5f375a86。如果换成64位的double版本的话,算法还是一样的,而最优常数则为0x5fe6ec85e7de30da(又一个令人冒汗的Magic Number - -b)。
  E  B. t% u. W7 e+ M! m这个算法依赖于浮点数的内部表示和字节顺序,所以是不具移植性的。如果放到Mac上跑就会挂掉。如果想具备可移植性,还是乖乖用sqrt好了。但算法思想是通用的。大家可以尝试推算一下相应的平方根算法。
; y- j0 |) _" _6 ?% r( @$ L4 s. R5 Z下面给出Carmack在QUAKE3中使用的平方根算法。Carmack已经将QUAKE3的所有源代码捐给开源了,所以大家可以放心使用,不用担心会受到律师信。
8 j6 x8 n% ?0 B  I* d. C% ]---------------------------------# i% M$ [4 L/ j# z2 n& y
//: O( J6 T& t  Z- N
// Carmack在QUAKE3中使用的计算平方根的函数
0 R! f' e% }) S//
3 u6 H6 o5 D: \# V+ ?float CarmSqrt(float x){0 u) g! ]0 Z) ~  W1 ]' X/ N
union{" U  k: m8 l) w% M
int intPart;
0 _$ y' U( V. G& v/ p6 Lfloat floatPart;0 Y2 J; u; z5 P# k4 p; P5 Q- ?
} convertor;
% {0 J# C. v0 zunion{
) @) L8 F3 _4 N3 W3 `! ]int intPart;& Z+ B0 d0 R, ]  m$ ^: o5 c
float floatPart;9 ~* h" R3 g. ^7 [* Q5 y
} convertor2;; e* m% x& }5 X1 N; k
convertor.floatPart = x;
2 @2 R* J5 u9 \' v6 Zconvertor2.floatPart = x;; p, o& G  E* j: @' q' M
convertor.intPart = 0x1FBCF800 + (convertor.intPart >> 1);% ?1 U" u7 V5 f, i
convertor2.intPart = 0x5f3759df - (convertor2.intPart >> 1);# b, N4 Z1 W% H* }( z
return 0.5f*(convertor.floatPart + (x * convertor2.floatPart));
0 k( I& }8 Q8 ]# n; G7 j- m# y  E9 h}
作者: olh2008    时间: 2009-11-27 08:22
// 计算参数x的平方根的倒数( T% R; C) K$ G6 O' \, m. f/ r
/// k+ Y0 d2 ^+ ?
float InvSqrt (float x)
9 d. L/ z; Q4 z' e0 X{
& F! L' y7 M, D% Q2 Dfloat xhalf = 0.5f*x;% J: w# b7 ?! c* q0 c
int i = *(int*)&x;: C6 R8 N! W3 y# K7 e5 C# X
i = 0x5f3759df - (i >> 1); // 计算第一个近似根
* B5 g+ l+ k) w3 hx = *(float*)&i;: x* [; K8 o/ `! C" K
x = x*(1.5f - xhalf*x*x); // 牛顿迭代法
! }, {3 Q5 g. Nreturn x;
9 X8 B/ x( I8 s# {6 s6 Q# c}

6 c& N% t! o) A' d" G; Y* A这个函数好像并不能实现计算x的平方根的倒数,比如我输入参数为100,它得到的却是-NAN




欢迎光临 数学建模社区-数学中国 (http://www.madio.net/) Powered by Discuz! X2.5