在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 inlineinline是声明这个函数是内联函数,也就是在编译时会被展开,避免函数调用开销,对于我们这种常用且短小的运算函数,当然要加。但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); }

下一部分 数学函数库 也会包含一些定点数常用函数,例如开方和三角函数。

这里只列出小部分,其他若有需要请看源码。

原文地址:https://www.cnblogs.com/h5l0/p/lib_hl_fixed-point.html