在GBA上写光线追踪:定点数运算库
这篇文章是关于我的GBA库lib_hl中数学库的定点数部分。
定点数是什么?为什么要用定点数?
在之前的文章中,我已经介绍了GBA的硬件,它的CPU竟然居然理所当然没有浮点数运算单元!
我要写的是光线追踪程序,基本上都在做精确的数学运算,而这个CPU却连浮点数都不支持,那不是没得玩?
当然是有方法的:
1、使用软件浮点数,在软件层面模拟浮点数,但比起硬件浮点数慢了太多,光线追踪是运算密集型程序,这样肯定不行;
2、使用定点数,在电脑普遍没有浮点运算单元时,大家都是用定点数代替小数运算。定点数运算速度只比整数运算慢几倍,还是可以接受的。
定点数通过固定小数点位置,使用整数表示小数,与相比浮点数,定点数可表示范围比浮点数小,而且它的表示范围和精度不可兼得。
关于定点数的详细原理,参见我的另一篇文章(不打算写了),可以百度
hl_types.h
最开始写的是一个.h头文件,里面包含了将用到的数据类型和一些常见操作的宏定义。
无论是在什么程序中,对数据类型进行定义是非常必要的,因为int, long, long long这些类型在不同的编译器中长度是不同的,在32位/64位的情况下也是不同的,为了程序的强适应性,应该使用自己定义的长度可知的数据类型。
基础数据类型 代码如下:
typedef signed char s8; //8位有符号整数 typedef signed short s16; //16位有符号整数 typedef signed int s32; //32位有符号整数 typedef signed long long s64; //64位有符号整数 typedef unsigned char u8; //8位无符号整数 typedef unsigned short u16; //16位无符号整数 typedef unsigned int u32; //32位无符号整数 typedef unsigned long long u64; //64位无符号整数 typedef volatile signed char vs8; //易变型8位有符号整数 typedef volatile signed short vs16; //易变型16位有符号整数 typedef volatile signed int vs32; //易变型32位有符号整数 typedef volatile unsigned char vu8; //易变型8位无符号整数 typedef volatile unsigned short vu16; //易变型16位无符号整数 typedef volatile unsigned int vu32; //易变型32位无符号整数
然后是一些会随着32/64位系统变化的类型:
#ifdef _X64 typedef long long _stype; typedef unsigned long long _utype; #define _XLEN 8 #else typedef int _stype; typedef unsigned int _utype; #define _XLEN 4 #endif //通用指针类型 typedef void *t_pointer, *t_ptr; //整型地址类型 typedef _utype t_addr;
通过在64位环境下预定义一个_X64的宏,可以使_utype在32位时是4字节,64位数时是8字节长。虽然我们的GBA肯定是32位的,但假如我们要把程序迁移到64位电脑上,就要注意指针类型和地址的长度变化。
然后是一些常用的定义:
//布尔类型 typedef int Bool; #ifndef NULL #define NULL 0 #endif #ifndef TRUE #define TRUE 1 #endif #ifndef FALSE #define FALSE 0 #endif /*内联函数声明*/ #define _INLINE_ static inline /*获取元素相对结构体起始地址的偏移*/ #define _OFFSET(_type,_element) ((t_addr)&(((_type *)0)->_element)) ... #define BIT(n) (1<<(n)) //第n比特为1 (2^n) ... #define SETFLAG(v,flag) v=(v|flag) //设定Flag #define HASFLAG(v,flag) (v&flag) //是否有Flag #define HASFLAGS(v,flags) ((v&(flags))==(flags)) //是否有全部flags #define NOFLAG(v,flag) ((v&flag)==0) //没有Flag
...
其他定义后续我们需要时在补上,现在我们可以开始写数学库了。
hl_math.h
文件开头加上:
#pragma once #include <hl_system.h>
#pragma once是写给编译器看的,意思是这段代码只编译一次。
之所以要在头文件加这句话,是因为C中引用头文件,是通过直接把头文件的内存复制到#include的位置,如果在多个文件中都包含了同一个头文件,编译时就会导致宏、结构体等被多次定义,引起编译错误。
另一种适合所有编译器的写法是:
#ifndef _XXX_H #define _XXX_H 代码 ... #endif
定义定点数
之后开始编写真正的代码,先定义定点数类型:
//32位定点数 typedef s32 fp32; //32位定点数的小数位数 20bit //整数大小 -2048-2047,小数精度 0.000001 #define FP32_FBIT 20
#define FP32_1 (1<<FP32_FBIT) //fp32 1f #define FP32_H5 (1<<(FP32_FBIT-1)) //fp32 0.5f #define FP32_LIMIT1 (FP32_1-1) //fp32 不到1f的最大值 #define FP32_MAX 2147483647 #define FP32_MIN (-2147483647-1) #define FP32_MAXINT ( (1<<(31-FP32_FBIT))-1) #define FP32_MININT (-(1<<(31-FP32_FBIT))) #define FP32_PI (1686629713>>(29-FP32_FBIT)) #define FP32_SQRT2 (1518500249>>(30-FP32_FBIT)) #define FP32_SQRT3 (1859775393>>(30-FP32_FBIT)) #define FP32_F2(n) (1<<(FP32_FBIT-(n))) //fp32 1/(2^n) //16位定点数 typedef s16 fp16; //16位定点数的小数位数 10bit //整数大小 -32-31,小数精度 0.001 #define FP16_FBIT 10
#define FP16_1 (1<<FP16_FBIT) #define FP16_H5 (1<<(FP16_FBIT-1)) #define FP16_MAX 32767 #define FP16_MIN -32768 #define FP16_MAXINT ( (1<<(15-FP16_FBIT))-1) #define FP16_MININT (-(1<<(16-FP16_FBIT)))
可以看到我的定点数有32位的和16位的,32位叫fp32,主要用于精度要求比较高的大部分运算,16位的叫fp16,主要用于精度低的色彩等运算。
fp32为了运算精度,给小数部分分配了20位(可以说是非常重视精度),这样小数的分度值是1/220 ,到小数点后6位的精度,而整数只有12位,除去符号位,可表示211=2048,范围就是-2048~2047。
fp16的位长有效,给小数分配10位,也只有1/210=1/1024也就是0.001的精度,而整数只剩可怜的5位,范围是-32~31。
除了定义小数位长FBIT,我还定义了一些常见数值的对应的定点数,例如1,0.5,π。可以看到,定点数的1就是1*220,0.5就是0.5*220,这就是定点数的原理。
同样的原理我们可以写几个转换函数:
//int -> fp32 static inline fp32 fp32_int(int n) { return n << FP32_FBIT; } //float -> fp32 //static inline fp32 fp32_float(float f) { return (fp32)(f * (1 << FP32_FBIT)); } //int/100 -> fp32 static inline fp32 fp32_100f(int n) { return (((s64)n << FP32_FBIT) + 50) / 100; } //fp32 -> int static inline int int_fp32(fp32 f) { return f >> FP32_FBIT; }
看完代码对定点数的理解应该也深一些吧。
所有函数前都加上了static inline,inline是声明这个函数是内联函数,也就是在编译时会被展开,避免函数调用开销,对于我们这种常用且短小的运算函数,当然要加。但inline只是向编译器提个建议,编译器可能不听,如果它觉得这个函数太大,内联不划算,就不内联了。这时这个函数就变成了定义在头文件的普通函数,这会带来一个问题,如果头文件被多次包含会导致函数重定义,所以加上static,声明为静态函数,只是在声明它的文件中可见,避免命名冲突。其实,规范地写,应该使用之前定义的_INLNE_,以防切换到不支持staic inline特性的编译器。
定点数运算
之后就是运算函数了。首先是加减运算,和整数运算并无两样。它的运算原理如下:
假设:
整数A是小数a的定点数形式,即 A = a*fs (fs = 1<<FBIT)
整数B是小数b的定点数形式,即 B = b*fs (fs = 1<<FBIT)
则 定点数A 加 定点数B 的公式是:
A (+) B = a*fs (+) b*fs = (a+b)*fs = (A/fs+B/fs)*fs = A+B
//fp32 + fp32 **事实上没有用的必要 static inline fp32 fp32_add(fp32 a, fp32 b) { return a + b; } //fp32 - fp32 **事实上没有用的必要 static inline fp32 fp32_sub(fp32 a, fp32 b) { return a - b; }
然后是乘除法:
先看代码,区别是乘完后需要缩小2FBIT,除完后需要放大2FBIT。
//fp32 * fp32 (64位安全运算) static inline fp32 fp32_mul64(fp32 a, fp32 b) { return (((s64)a) * b) >> FP32_FBIT; } //fp32 / fp32 (64位安全运算) *b<1仍可能溢出 static inline fp32 fp32_div64(fp32 a, fp32 b) { return (((s64)a) << FP32_FBIT) / b; }
定点数A乘定点数B的推导过程:
A (x) B = (a*b)*fs = (A/fs)*(B/fs)*fs = (A*B)/fs
定点数A除定点数B的推导过程:
A (÷) B = (a/b)*fs = (A/fs)/(B/fs)*fs = (A/B)*fs
不难理解,定点数是小数乘了2FBIT得到的,如果两个定点数相乘,两次2FBIT就累积了,所以要除去一次2FBIT。
之后是一些常用的函数:
//fp32^2 static inline fp32 fp32_pow2(fp32 a) { return (((s64)a) * a) >> FP32_FBIT; } //返回结果是u64 static inline u64 fp32_pow2_64(fp32 a) { return (((s64)a) * a) >> FP32_FBIT; } static inline fp32 fp32_lerp(fp32 a, fp32 b, fp32 t) { return a + fp32_mul64(b - a, t); }
下一部分 数学函数库 也会包含一些定点数常用函数,例如开方和三角函数。
这里只列出小部分,其他若有需要请看源码。