相关代码已上传到 Github :MZRHnbnb/Software-RayTracing
在软光栅基本实现完成后,接下来就是打算实现一下软光追的逻辑了。光线追踪区别于光栅化是另一种渲染方法,很明显,光线追踪模拟了现实世界的光线的传播,所得到的画面和光栅化相比肯定是会更加的真实的。但代价也就是光线追踪耗时会更多。
在 Games101 课程其实将光线追踪的整体理论知识和逻辑讲得非常详细了,但是对于如何实现的思路却比较少提及。虽然理论知识可能也很难,但是代码实现上的种种问题其实更加难,所以这里给大家分享一下我的软光追实现思路和方法。
与三角形和 AABB 包围盒的求交
首先是最重要的部分,光线与三角形和 AABB 包围盒的求交。我们都知道光线追踪的主要原理就是从摄像机的每一个像素发出一个光线射线,看看能够打到场景的哪个三角形上。而我们判定光线是否打到某个三角形上就需要对光线和三角形进行求交。
而求交的方法说起来也不算特别难,我们设摄像机的位置坐标是 O ,沿着光线方向的单位向量 是 D ,过了 t 个时间后,光线传播到的位置坐标 了。而如果光线要打到三角形的某个点上,我们还要想办法知道它打到的点怎么能表示出来。而很显然我们当然还是用重心坐标,在三角形上的某个点都可以用以下的重心坐标表示,P_0,P_1,P_2 代表三角形的三个顶点,u,v 都是未知数即
而我们要求的是光线打到了这个三角形的哪个位置,那当然是把光线在 t 个时间传播到哪个位置和三角形上某个点的式子联立起来了。
联立得
可以发现在这个方程组,我们只有三个未知数 t,u,v 。t 的用途是用来找最早到达的三角形,这样就无需光栅化那样需要 Z Buffer 才能解决遮挡问题了;而 u,v 的作用当然是用来知道重心坐标,知道具体在三角形的那个位置,之后才能继续着色。而且可以发现,这个方程式实际上大写的字母都是 xyz 那样的坐标表示的,而且 xyz 互相的计算当然是互不影响的,也就是说我们实质可以根据这个方程式得到以下由三个方程式组成的方程组。
而根据克拉默法则(Cramer's Rule)我们知道三个未知数,三个方程式当然是可以解出三个未知数的值的。要解这个方程组有很多方法,但相比之下效率比较高的就是MT 算法(Möller-Trumbore) 。简单来说, MT 算法就是对原本的方程组进行了一些变换。定义三角形的两条边的向量 E1 E2 和从三角形的 P_0 顶点到光线起点的向量如下,必须对应正确否则重心坐标也会算错。
则方程组可以写成矩阵的形式
随后根据克拉默法则可以得到
看上去似乎很复杂一堆行列式,代码实现起来很困难,但是如果我们利用混合积的性质就可以轻松的把复杂的行列式变成点乘和叉乘的计算,效率大大提高。
写成这样,我们代码逻辑就非常清晰了。但我们还要注意的是 a 在这里也是判断光线是否和三角形相交的标准,如果 a 接近 0 ,那么说明光线和三角形几乎平行了。而且重心坐标,t 也是判定光线和三角形是否相交的依据。总结下来就是如果光线和三角形无交点,就会满足以下其中一个条件。
1. a = 0
2. u < 0 或 v < 0 或 u+v > 1
3. t < 0
所以代码逻辑也十分清晰了,具体实现如下。
cpp
//MT算法计算光线与三角形求交
//tuv引用传递,将算完的量传递出去
//注意对混合积可以进行轮换对称,减少运算量,且由于我的三角形坐标点一直由齐次坐标表示,利用Eigen的.head<3>()进行转换成三维向量
bool Intersect_Triangle(const Camera& camera, const Triangle& triangle, const Eigen::Vector3f& ray, float& t, float& u, float& v) {
Eigen::Vector3f E1 = (triangle.triangle_point[1] - triangle.triangle_point[0]).head<3>();
Eigen::Vector3f E2 = (triangle.triangle_point[2] - triangle.triangle_point[0]).head<3>();
Eigen::Vector3f h = ray.cross(E2);//h=D×E2
float a = E1.dot(h);//a=E1*h
//a接近0和三角形平行无交点
if (std::abs(a) < 1e-6f) {
return false;
}
float f = 1.0f / a;
Eigen::Vector3f s = camera.position - triangle.triangle_point[0].head<3>();
Eigen::Vector3f q = s.cross(E1);//q=s×E1
//根据公式算出tuv
u = f * s.dot(h);
v = f * ray.dot(q);
t = f * E2.dot(q);
if (u < 0 || v < 0 || u + v > 1.0f || t < 1e-6) return false;
return true;
}
解决了三角形的求交,现在的问题就是场景内的三角形很多时候都会非常多,**如果我对每个光线都对场景内三角形一个个进行求交,那我复杂度不就爆炸了吗?**为了解决这个性能问题,我们发现可以将三角形用一个包围盒(Bounding Box)给包起来,如果光线和某个包围盒有交,那么再对这个包围盒里面的三角形进行逐个求交。这样不仅能快速排除很多根本没有交点的三角形,而且对一个轴对齐包围盒(即每一条边都平行于世界坐标系的 X Y Z 轴,Axis-Aligned Bounding Box,简写 AABB )求交的速度还非常快。
具体原理 Games101 其实已经讲得很清楚,此处不再赘述。简单来说就是每个包围盒可以由三个"对面"进行确定,每个对面计算出光线的进入时间 t_min 和出去时间 t_max ,然后求出最大的 t_min 和最小的 t_max ,只要最大的 t_min 比最小的 t_max 小,那就说明光线和包围盒是有交的。
而确定一个轴对称包围盒,只需要知道两个点即可,一个"最小"点和一个"最大"点。这里简单来说就是"最小"的点是包围盒每个轴上是最小值的那个顶点,"最大"点就是包围盒每个轴上是最大值的那个点了。而因为是轴对称包围盒,所以说光线通过包围盒的时候 X Y Z 轴也是互不影响的我们可以独立计算每个对面的 t 。即点 和
。
所以计算的逻辑也非常简单(假设光线从 min 小坐标的方向来),如果我要算和 yOz 平面平行的那个对面什么时候光线会进入,那当然是解方程 ;如果要算和 xOz 平面平行的对面什么时候光线会出去,那当然是解方程
。以此类推,其实可以发现这些方程都非常好解。最后取进入时间的最大值和出去时间的最小值比较即可。而且为了提速,如果摄像机就在包围盒内,那就可以直接 return 了,无需继续算,判断点是否在包围盒内也几乎完全不费时。
cpp
//判断一个点是否在包围盒内
bool In_boundingbox(const Bounding_Box& bounding_box, const Eigen::Vector3f& position) {
return position.x() <= bounding_box.max_point.x() && position.x() >= bounding_box.min_point.x() &&
position.y() <= bounding_box.max_point.y() && position.y() >= bounding_box.min_point.y() &&
position.z() <= bounding_box.max_point.z() && position.z() >= bounding_box.min_point.z();
}
//判断光线与包围盒是否相交
bool Intersect_AABB(const Bounding_Box& bounding_box, const Eigen::Vector3f& ray,const Camera& camera) {
//在包围盒内直接true,必然有交
if (In_boundingbox(bounding_box, camera.position)) return true;
float txmin, tymin, tzmin;
float txmax, tymax, tzmax;
//如果光线的x坐标是0,说明和yOz平面平行,但光线仍然有可能和包围盒有交,赋值极小值和极大值不影响后续计算
if (std::abs(ray.x()) < 1e-6f) {
txmin = -std::numeric_limits<float>::infinity();
txmax = std::numeric_limits<float>::infinity();
}
else {
txmin = (bounding_box.min_point.x() - camera.position.x()) / ray.x();
txmax = (bounding_box.max_point.x() - camera.position.x()) / ray.x();
}
//光线有可能从max大坐标方向过来,此时txmax会变成进入时间,所以必须检查保持txmin小于txmax
if (txmin > txmax) std::swap(txmin, txmax);
//后续基本同理
if (std::abs(ray.y()) < 1e-6f) {
tymin = -std::numeric_limits<float>::infinity();
tymax = std::numeric_limits<float>::infinity();
}
else {
tymin = (bounding_box.min_point.y() - camera.position.y()) / ray.y();
tymax = (bounding_box.max_point.y() - camera.position.y()) / ray.y();
}
if (tymin > tymax) std::swap(tymin, tymax);
if (std::abs(ray.z()) < 1e-6f) {
tzmin = -std::numeric_limits<float>::infinity();
tzmax = std::numeric_limits<float>::infinity();
}
else {
tzmin = (bounding_box.min_point.z() - camera.position.z()) / ray.z();
tzmax = (bounding_box.max_point.z() - camera.position.z()) / ray.z();
}
if (tzmin > tzmax) std::swap(tzmin, tzmax);
//找进入时间最大值和出去时间最小值
float tmin = std::max(std::max(txmin, tymin), tzmin);
float tmax = std::min(std::min(txmax, tymax), tzmax);
//进入时间最大值比出去时间最小值小且tmax大于0则成立
return tmin <= tmax && tmax >= 0.0f;
}
从像素射出的光线
我们都知道光线追踪是要从每个像素都独立的射出一条光线,所以这个计算这个光线的向量也是非常重要的一环。在光栅化需要完成 MVP 矩阵变换,视图变换等等一系列流程,而光线追踪仅仅需要进行 M 矩阵变换,将局部坐标转为世界坐标即可,后续计算都是用到世界坐标计算(记得法线也需要进行 M 矩阵变换,否则物体如果经过旋转法线对应方向也应该改变)。
而为了计算更加简单,之前光栅化中我用欧拉角来表示相机位置因为容易算 V 矩阵,光线追踪我就采取一个坐标来表示相机位置,由两个向量一个 forward 表示相机朝向的方向,一个 up 表示相机顶端朝向方向。这两个向量就可以确定相机朝向的方向了,如果想要知道左右朝向那也只需要通过叉乘 forward×up 就可以得到左右朝向的向量 right 了。具体如图所示

cpp
struct Camera {
Eigen::Vector3f position;
Eigen::Vector3f forward;
Eigen::Vector3f up;
float fov, aspect, nr, fr;
};
而在这里我们还是依然输入这四个变量 fov aspect near far (防止命名冲突代码里使用了 nr 和 fr ),利用这些来定义一个视锥来进行计算。我们想要计算从像素射出的光线,那肯定是需要计算像素在世界坐标中的位置,再减去相机的位置坐标就是光线向量了 。我们计算的时候当然是需要将像素映射到近平面的某个位置的,我们可以得到一个像素在近平面的 (x,y) 坐标。
然后,我们给 camera 的 forward up right 向量就派上用场了。我们可以想象一下,从 camera 的 position 点出发,沿着 forward 向量走 near 长度,就到达了近平面的中心位置了。而我们以近平面为坐标原点,以 right up 向量为 xy 轴,我们刚刚已经知道这个像素在近平面的 xy 坐标是可以算出来的。所以可以得到一个像素在世界坐标的坐标点公式应该是
所以这并不算困难,但是将像素映射到近平面还是需要小心不要算错,先归一化坐标再乘上对应近平面的长宽量。
cpp
//光线追踪
void Ray_Tracing(cv::Mat& image, Camera& camera, const std::vector<BVH>& bvh, const std::vector<Triangle>& triangles) {
//取出屏幕宽高
int height = image.rows;
int width = image.cols;
//计算出right向量
Eigen::Vector3f right = (camera.forward.cross(camera.up)).normalized();
float near_height = camera.nr * tan(camera.fov * 0.5f) * 2;//计算得到近平面的高度
float near_width = near_height * camera.aspect;//利用aspect宽高比计算出近平面宽度
//遍历屏幕像素
for (int r = 0; r < height; ++r) {
for (int c = 0; c < width; ++c) {
//加0.5意为取像素中心,除以屏幕宽高归一化,然后以近平面中心为坐标原点乘上对应近平面宽高
//要注意opencv特性是,y轴朝下,rc的计算方法不一样
float nr = (0.5f - (r + 0.5f) / height) * near_height;
float nc = ((c + 0.5f) / width - 0.5f) * near_width;
//根据公式得到像素世界坐标
Eigen::Vector3f Pixel_position = camera.position + camera.nr * camera.forward + nc * right + nr * camera.up;
//相减得到当前像素的光线向量
Eigen::Vector3f Ray = (Pixel_position - camera.position).normalized();
//进行渲染
Paint_Pixel(image, camera, bvh, triangles, Ray, r, c);
}
}
}
构建 BVH 树
现在我们已经可以知道光线向量,还知道光线和三角形,AABB 如何求交,接下来主要要解决的就是复杂度问题。刚刚也提过我们不能说对场景每个三角形都求交计算一遍这样太慢了,我们需要利用一些手段去优化。
优化的方法有很多比如八叉树,KD-Tree 还有 BVH。八叉树的逻辑比较简单,比如对于一个包围盒直接等比例划分成八个小包围盒就可以了,一直递归下去知道包围盒内三角形数量足够少; KD-Tree 是每一次都对包围盒的某一个轴方向进行划分,一般是沿最长的那个轴,为了保持包围盒不会过于细长,最终其实就是一个二叉树。显然 KD-Tree 的深度会更深。
八叉树和 KD-Tree 都各有优势,但是它们都有共同的问题是:如果场景内的三角形都集中在某一些位置,而其他很多位置都是空气什么也没有,那么这两种数据结构都会有一些空节点存在,比较低效;而且更重要的是划分包围盒的时候可能会出现某些三角形会同时分属不同的几个包围盒,不仅难处理还重复存储。所以为了解决这些麻烦,采用 BVH 是更好的选择。 BVH 是通过对包围盒内的三角形根据某个标准进行排序,然后自行决定应该如何划分,划分成两个部分后再分别重新计算两个包围盒。包围盒的计算十分简单,比起刚刚的八叉树的 KD-Tree 以"包围盒"进行划分,此处以"三角形"划分显然是更加好做且高效的。而且每次重算包围盒也防止了场景空间大出现空节点的问题了。
说着很简单做起来实际也不难。说实话如果对树形结构的数据结构比较熟悉实现起来是比较简单的,如果大家对此有兴趣可以查阅此前我对于线段树的讲解,里面对如何构建和使用有更详细的说明,且和 BVH 是有类似的。【数据结构进阶】万字图解线段树:从前缀和到区间修改的完美进化 (洛谷P3372 附C++模板)-CSDN博客
首先当然先确定好 BVH 树里面每个节点应该存储的信息了。为了节省内存,我个人打算是采用动态开点 的方式进行构建 BVH 树,所以节点内肯定需要存储左子节点和右子节点的 id ,方便后续寻址。而且我还规定只要左子节点和右子节点都是 -1 那当前这个节点就必然是叶子节点了 。包围盒与当前包围盒内的三角形编号也必不可少。要注意的是我这里为了降低复杂度,在读取了全部三角形后给所有三角形在数组里的 id 作为其编号,不仅排序的时候简洁而且在 BVH 树里一直用编号代指三角形也对内存比较友好。通过 id 就可以直接在 triangles 数组里找到对应三角形了。类似指针的逻辑,用指针还要担心指针的各自出错,利用 id 编号下标直接寻址更加简单不会出错。
cpp
struct BVH {
int left_son, right_son;//左右子节点
Bounding_Box bounding_box;//包围盒
std::vector<int> box_triangle_id;//包围盒内的三角形id
//利用构造函数初始化,包围盒max点赋值极小值,min点赋值极大值
BVH() {
left_son = right_son = -1;
bounding_box.max_point = { -std::numeric_limits<float>::infinity() ,-std::numeric_limits<float>::infinity() ,-std::numeric_limits<float>::infinity() };
bounding_box.min_point = { std::numeric_limits<float>::infinity() ,std::numeric_limits<float>::infinity() ,std::numeric_limits<float>::infinity() };
}
};
对于每一层的计算,我们面对的都是一堆三角形,还是无序的,但我们还是需要先计算包围盒,然后再对其进行排序。而要知道我要排序的是哪一堆三角形,我们可以通过两个函数参数 l r 来进行确定,对原三角形数组的区间 l,r 进行排序。而排序的标准我打算是采用每个三角形的重心,以重心 centroid 作为排序标准。当然还有利用表面积的启发式排序方法,但重心的逻辑相对比较简单,在三角形数组读取的时候提前算好直接在 BVH 里取用即可。
cpp
//建立BVH过程
int to_build_BVH(const std::vector<Triangle>& triangles, std::vector<int>& triangle_id, std::vector<BVH>& bvh, int& cnt, int l, int r) {
BVH temp;
int u = ++cnt;//分配编号
bvh.push_back(temp);//分配编号后直接push进入vector数组,让编号对应正确
//计算包围盒
for (int i = l; i <= r; ++i) {
for (int j = 0; j <= 2; ++j) {
bvh[u].bounding_box.min_point.x() = std::min(bvh[u].bounding_box.min_point.x(), triangles[triangle_id[i]].triangle_point[j].x());
bvh[u].bounding_box.min_point.y() = std::min(bvh[u].bounding_box.min_point.y(), triangles[triangle_id[i]].triangle_point[j].y());
bvh[u].bounding_box.min_point.z() = std::min(bvh[u].bounding_box.min_point.z(), triangles[triangle_id[i]].triangle_point[j].z());
bvh[u].bounding_box.max_point.x() = std::max(bvh[u].bounding_box.max_point.x(), triangles[triangle_id[i]].triangle_point[j].x());
bvh[u].bounding_box.max_point.y() = std::max(bvh[u].bounding_box.max_point.y(), triangles[triangle_id[i]].triangle_point[j].y());
bvh[u].bounding_box.max_point.z() = std::max(bvh[u].bounding_box.max_point.z(), triangles[triangle_id[i]].triangle_point[j].z());
}
}
//三角形数量小于某个值,此处设定为8,到达叶子节点
if (r - l + 1 <= 8) {
bvh[u].left_son = bvh[u].right_son = -1;//设置为-1代表到达叶子节点
//叶子节点存储三角形id
for (int i = l; i <= r; ++i) {
bvh[u].box_triangle_id.push_back(triangle_id[i]);
}
return u;
}
//找最长轴进行划分bounding box
float mx = bvh[u].bounding_box.max_point.x() - bvh[u].bounding_box.min_point.x();
float my = bvh[u].bounding_box.max_point.y() - bvh[u].bounding_box.min_point.y();
float mz = bvh[u].bounding_box.max_point.z() - bvh[u].bounding_box.min_point.z();
float tmax = std::max(std::max(mx, my), mz);
int option = 0;
if (std::abs(tmax - mx) < 1e-6) option = 1;
else if (std::abs(tmax - my) < 1e-6) option = 2;
else option = 3;
//根据最长轴进行排序,利用Lambda语法进行排序的自定义
sort(triangle_id.begin() + l, triangle_id.begin() + r + 1, [&triangles,option](int a,int b) {
if (option == 1) return triangles[a].centroid.x() < triangles[b].centroid.x();
else if(option==2) return triangles[a].centroid.y() < triangles[b].centroid.y();
return triangles[a].centroid.z() < triangles[b].centroid.z();
});
//直接对半划分
int mid = (l + r) >> 1;
//递归左右子节点
bvh[u].left_son = to_build_BVH(triangles, triangle_id, bvh, cnt, l, mid);
bvh[u].right_son = to_build_BVH(triangles, triangle_id, bvh, cnt, mid + 1, r);
return u;
}
//建立BVH
int build_BVH(const std::vector<Triangle>& triangles, std::vector<BVH>& bvh) {
//triangle_id用于记录每个三角形的id,排序过程只排id
std::vector<int> triangle_id;
//提前分配内存更高效
triangle_id.reserve(triangles.size());
//记录初始id
for (int i = 0; i < triangles.size(); ++i) triangle_id.push_back(i);
//cnt用于分配编号
int cnt = -1;
//返回根节点,实际上默认根节点为0
return to_build_BVH(triangles, triangle_id, bvh, cnt, 0, triangles.size() - 1);
}
建立好 BVH 树,查找逻辑就更加简单了。根据我刚刚的写法,其实相当于直接默认了 BVH 的根节点就是 0 。所以我们对于每一个像素的光线,从根节点 0 进入查找即可。
cpp
//利用BVH找出和哪个三角形首次相交
//函数内的参数p指的是当前节点编号
void Intersect(const Eigen::Vector3f& ray, const std::vector<BVH>& bvh, const Camera& camera, const std::vector<Triangle>& triangles, int p, int& id, Eigen::Vector3f& bary, float& t) {
//和包围盒无相交return
if (!Intersect_AABB(bvh[p].bounding_box, ray, camera)) return;
//都是-1到达叶子节点
if (bvh[p].left_son == -1 && bvh[p].right_son == -1) {
//对这个包围盒的所有三角形求交
for (const auto& i : bvh[p].box_triangle_id) {
Eigen::Vector3f temp;
float nt;
bool st = Intersect_Triangle(camera, triangles[i], ray, nt, temp.y(), temp.z());
//无相交或者现在比之前有过的相交t大则不算
if (!st||nt>t) continue;
//符合条件更新t,bary,id
temp.x() = 1.0f - temp.y() - temp.z();
t = nt;
bary = temp;
id = i;
}
}
//左右子节点不等于-1的说明存在子节点,继续往下递归
if (bvh[p].left_son != -1) {
Intersect(ray, bvh, camera, triangles, bvh[p].left_son, id, bary, t);
}
if (bvh[p].right_son != -1) {
Intersect(ray, bvh, camera, triangles, bvh[p].right_son, id, bary, t);
}
return;
}
到此,我们通过射出的光线找到三角形的重心坐标的逻辑就彻底完成了,在刚刚的 Paint_Pixel 函数就可以加入找到求交位置的代码了,找到了相交的三角形,就可以继续后续渲染。
cpp
//计算光线求交的位置,给像素赋值颜色
void Paint_Pixel(cv::Mat& image, const Camera& camera, const std::vector<BVH>& bvh, const std::vector<Triangle>& triangles, const Eigen::Vector3f& ray, int r, int c) {
float t = std::numeric_limits<float>::infinity();//初始化为极大值
Eigen::Vector3f bary;//打到的三角形的重心坐标
int id;//打到哪个三角形
Intersect(ray, bvh, camera, triangles, 0, id, bary, t);
if (t == std::numeric_limits<float>::infinity()) {
//没打到任何三角形则设为黑色
image.at<cv::Vec3b>(r, c) = cv::Vec3b(0, 0, 0);
return;
}
//==========//
//此处为在着色点着色的逻辑,暂未实现
//==========//
}