【C++】极致优化——省去运行,直接编译出光追渲染BMP图像
在众多编程语言里,C++是一款犹如螳螂刀一样的编程语言,特别容易在你尝试使用它的特性的时候坑到自己。不过C++由于其语言特性,有时候可以做到一些别的语言难以做到的特殊优化。其中的constexpr特性就是一个很强大的东西,它可以把各种计算都提前到编译期间完成(运行期间你可以直接像读取常量一样取得计算结果)。虽然根据描述有点像宏表达式,但constexpr可以保证运算一定在编译期间完成,而非宏那样展开后其中会有可能有运行期间的变量需要在运行期间完成。constexpr的典型应用就是用来实现编译期间的哈希值计算,根据提供的常量字符串可以直接获取到其对应的哈希值用于哈希表的Key哈希等。
当各种各样的计算都可以提前到编译期间完成后,在运行期间,你的程序需要执行的指令和计算就会大大减少,你的程序就可以越来越轻巧,运行就会越来越快。这是毋庸置疑的。
于是,为了追求这极致的优化,我在编写一个光追Demo的时候,一个不小心,就优化过头了,甚至把运行的过程也给优化掉了——连运行都省了。直接编译出bmp位图,图像就是光追Demo。
我的这份源码,只需要在bash界面输入“make”,C++的编译器就会吃掉你将近70 GB的RAM,并在半小时后,生成一个bmp位图。(编译器:我屌尼玛的你把所有工作都交给我,却说这是优化?!)
我虽然只有64 GB的物理内存,但总之它通过交换虚拟内存的方式完成了编译(倒是别的程序也没有变得卡顿,可能是因为我的虚拟内存盘是固态盘吧)。不过和上一次的汇编NASM直接编译出光追场景对比的话,这一次因为我懒得实现BMP位图的分块和组装的过程了,所以暂时这份源码就只能在吃这么大内存(和20%左右的CPU)后,编译出尺寸为128 x 96的小图像了。
光追的原理我就不在这里介绍了。先说说如何通过使用gcc编译器工具链实现直接编译出BMP位图。
首先最初的思路是因为gcc工具链被用于单片机ROM的编译。单片机ROM并不可以是ELF格式(当然也不可以是PE格式的EXE),而是一个要按照单片机的PDF文档的描述来生成的运行于特定基址的raw文件,其开头通常是中断表IVT,之后紧跟数据和指令。中断表IVT中的Reset_Handler负责指示代码指令的入口地址在哪。
换句话说,那就是使用gcc可以实现生成任意文件,其中可以包含数据,也可以包含代码指令。
这其中,我需要编写链接器脚本。由链接器脚本指明程序的基址在哪,程序的入口点是哪个函数,哪些分段被放到哪些位置等。
在使用g++进行链接,并提供链接器脚本后,它会输出一个ELF文件,其中包含了各个分段应该被放置的位置,也就是对链接器脚本的解释。使用objcopy工具可以把ELF文件所描述文件结构提取出来,也就是执行ELF文件中对数据位置的解释,得到我们需要的raw文件,在这里就是BMP位图文件了。
不过我要生成的可不是一个程序,而是一个BMP位图,它有位图文件头和位图信息头,以及图像数据。它并不需要代码指令。
但由于本身gcc不是拿来这样玩的,它本来的作用是被用于编译程序,而不是生成程序以外的文件(我这还是个图像文件呢)。所以我还是得假装自己在写程序,而且还要在程序代码里,引用我需要它出现在目标文件的结构体和变量等。因为gcc的链接时间优化可以把你没有引用到的变量和函数去除,只留下真正需要被执行的指令,参考这篇文章:《【单片机】将HAL优化成空气——STM32CubeIDE开启链接时间优化》。(P.S.弹幕流大佬在这方面误会了C编译器的优化。他认为只要包含了任何非自己写的头文件包括C标准库头文件,他生成的程序二进制里就会出现他不要的东西。)
其中的BMFH、BMIF、Pixels分别代表位图文件头,位图信息头,位图像素数据。这三个东西在“入口函数dummy()”的引用下,得以在目标文件中不被优化掉,成功存活。为了让这些东西在正确的位置对号入座,我需要在链接器脚本里指定它们的位置,并且还要让它们自己带有特定的段属性。
而链接器脚本的内容,除了安排BMP图像的文件结构以外,还需要做一件事——把刚刚提到的“入口函数dummy()”干掉。因为入口函数它是个函数,我没有给它指定它的所属分段,所以它默认是在.text分段。在链接器脚本里,把你不要的分段放入“/DISCARD/”组,就可以将其移除。我将.text分段全部放到了/DISCARD/里面,生成的BMP位图里,就不会有dummy()函数对应的汇编指令了。好一个过河拆桥。
在搞定了位图文件头部的组装以后,接下来是位图的文件体,也就是像素。和上次一样,我打算让图像的分辨率可以用宏来指定。这意味着图像的大小并非固定,而是活动的。我需要创建一个大小能够随宏改变的数组,并且要对数组的每一个元素都进行初始化。
在C语言里,你对全局变量做初始化的时候,你如果提供常量,那么生成的二进制文件里,这个数组如果存在,它的对应元素就是你给的常量值。而如果你提供的是一个表达式,这个表达式对应的指令需要在程序初始化的时候由CRT负责调用,指令执行后才能把数组的内容初始化了。
C++其实也一样,而且对于全局的类实例,C++有专门负责构造类的指令,在.ctor分段里;对应的析构类的指令在.dtor分段里。但C++拥有一个C语言没有的东西:constexpr。使用constexpr函数对全局变量做初始化,可以保证初始化的代码在编译期间成功Eval,算出结果,然后被填入你要初始化的变量。
但对于全局的数组做初始化的时候我一开始有点懵,因为数组的大小可变,而我想要通过模板套娃的方式,实现类似于 int arr[] = {func(0), func(1), func(2), func(3)...}; 这样的初始化。结果发现这样是不行的:模板不能套娃太多,要么编译器报模板套娃太多的错,要么直接Segmentation Fault(当时我设置了-ftemplate-depth的值为一个很大的值)。
(备注:图中那一屏幕的“ELjxxxxELjxxxx…”都是包含了模板参数的数组导出符号,本来是用来视线数组的编译期间初始化的。)
后来放弃了模板的方式,但我上网一搜,发现其实可以使用std::array来实现全局数组的编译期间初始化。它代码是这样写的:constexpr auto 数组名字{[]() constexpr
{
std::array<元素类型, 数组长度> result{};
for(size_t i = 1; i < 数组长度; i++)
{
result = ...
}
return result;
}()};其中,你不仅可以对result[i]进行写入,你还可以读写result[i]、result、result。
我最初怀疑std::array会不会包含一些它的成员变量,导致我不能生成正确的位图信息。结果发现我多虑了,上述代码确实在我的BMP文件里插入了我要的字节数组,并且能让位图正确显示。
而且这种方式比模板的方式好用多了——至少编译速度很快,编译期间瞬间就完成了数组的初始化。
这样一来,就万事俱备只欠光追了。既然用的语言是C++,岂能不面向对象?我这就写了个vec4四维向量类,可以做加减乘除,点乘,取模,对xyz分量的叉乘,镜面反射,线性插值等方法。class vec4
{
public:
float x, y, z, w;
constexpr vec4() : x(0), y(0), z(0), w(0) {}
constexpr vec4(const float s) : x(s), y(s), z(s), w(s) {}
constexpr vec4(const float _x, const float _y, const float _z, const float _w) : x(_x), y(_y), z(_z), w(_w) {}
constexpr vec4(const vec4& v) : x(v.x), y(v.y), z(v.z), w(v.w) {}
constexpr vec4 operator + () const
{
return *this;
}
constexpr vec4 operator - () const
{
return vec4(-x, -y, -z, -w);
}
constexpr vec4 operator + (const vec4& v) const
{
return vec4
(
x + v.x,
y + v.y,
z + v.z,
w + v.w
);
}
constexpr vec4 operator - (const vec4& v) const
{
return vec4
(
x - v.x,
y - v.y,
z - v.z,
w - v.w
);
}
constexpr vec4 operator * (const float s) const
{
return vec4
(
x * s,
y * s,
z * s,
w * s
);
}
constexpr vec4 operator * (const vec4& v) const
{
return vec4
(
x * v.x,
y * v.y,
z * v.z,
w * v.w
);
}
constexpr vec4 operator / (const vec4& v) const
{
return vec4
(
x / v.x,
y / v.y,
z / v.z,
w / v.w
);
}
constexpr vec4& operator += (const vec4& v)
{
x += v.x;
y += v.y;
z += v.z;
w += v.w;
return *this;
}
constexpr vec4& operator -= (const vec4& v)
{
x -= v.x;
y -= v.y;
z -= v.z;
w -= v.w;
return *this;
}
constexpr vec4& operator *= (const float s)
{
x *= s;
y *= s;
z *= s;
w *= s;
return *this;
}
constexpr vec4& operator *= (const vec4& v)
{
x *= v.x;
y *= v.y;
z *= v.z;
w *= v.w;
return *this;
}
constexpr vec4& operator /= (const vec4& v)
{
x /= v.x;
y /= v.y;
z /= v.z;
w /= v.w;
return *this;
}
constexpr vec4& operator ++ ()
{
x ++;
y ++;
z ++;
w ++;
return *this;
}
constexpr vec4& operator -- ()
{
x --;
y --;
z --;
w --;
return *this;
}
constexpr vec4 operator ++ (int)
{
vec4 ret(*this);
x ++;
y ++;
z ++;
w ++;
return ret;
}
constexpr vec4 operator -- (int)
{
vec4 ret(*this);
x --;
y --;
z --;
w --;
return ret;
}
constexpr float dot(const vec4& v) const
{
return x * v.x + y * v.y + z * v.z + w * v.w;
}
constexpr vec4 cross(const vec4& v) const
{
return vec4
(
y * v.z - v.y * z,
z * v.x - v.z * x,
x * v.y - v.x - y,
0.0
);
}
constexpr float length() const
{
return sqrtf(dot(*this));
}
constexpr float distance(const vec4& v) const
{
return ((*this) - v).length();
}
constexpr vec4 normalize() const
{
float len = length();
if(fabsf(len) >= FloatEpsilon)
{
return (*this) / len;
}
}
constexpr vec4 reflect(const vec4& normal) const
{
return (*this) - normal * normal.dot(*this) * 2.0f;
}
constexpr vec4 mix(const vec4& v, const float a) const
{
return (*this) * (1.0f - a) + v * a;
}
};然后我打算对场景中的物体也做成一个类,然后我想让它继承出子类,每一种子类描述一种物体,比如球体、立方体等。然而C++17的constexpr并不允许你的父类被转换为子类,然后访问子类的成员。也不会允许你使用虚函数。
已经写好的代码并不想删了重来,不如就做个缝合怪,把子类的成员都写进父类,然后删除子类,让父类用一个enum判断自己是什么子类,再去做对应的行为。
最后写一个场景类。这个场景类存储场景的设定,包括场景中有多少个球体,地板是什么颜色,太阳光的方向和颜色,天光,雾色,雾浓度,摄像头位置等。建立场景,然后提供一个光追的方法,给出“视线”的起点和方向,用这个视线去追踪光源。
因为之前写过场景物体的子类,但后来发现现在这种情况不能用类继承,而子类的构造函数的调用倒是写了不少。破罐子破摔,直接写两个同名函数假装自己是子类的构造函数。
大功告成。如果想要看完整的源码,请移步:https://github.com/MickeyMeowMeowHouse/CppCTRT
要编译源码,你需要 gcc-multilib 来保证对 -m32 平台的编译。
感谢支持。 66666太强了!! 下次准备用啥做渲染 Ayala 发表于 2021-1-7 12:41
下次准备用啥做渲染
批处理 学习了,学习了
页:
[1]