文章目录
项目地址: https://github.com/Shadowithin/OpenCVRayTracer/tree/duck
Motivation
最近战地V的太平洋战场终于发布了,战地V也是首批支持RTX光追的游戏,只可惜我的GTX显卡开了光追后帧数惨不忍睹,即使是1080也没有一战之力。不过不得不佩服DICE,把后处理特效拉满之后区别还是微乎其微的。
RTX ON 虽美,但是需要钞能力。那么穷逼就不配拥有光追了吗?当然不,光追又不是老黄发明的,它的诞生比GPU还要早,前人已经有很多探索,并且也有成熟的应用比如V-Ray。当然老黄和游戏开发者也是做了很多努力,才能把光追实时搬到游戏里。所以我打算在我大概相当于9代i3贫弱的7700k上实现一个简单的递归式Raytracer。
Recursive Ray Tracing
Recursive Ray Tracing 即递归式光线追踪,又称 Whitted-style Ray Tracing,是光线追踪的基础算法之一。光线追踪以及后来的路径追踪都有算法可以实现更高级的效果,但是递归算法形式简单、容易理解,也能取得不错的观感。所以,我认为从递归式光线追踪开始是一个不错的选择。
递归式光线追踪,这里直接借用毛星云 《Real-Time Rendering 3rd》 提炼总结的概括:“光线追踪方法主要思想是从视点向成像平面上的像素发射光线,找到与该光线相交的最近物体的交点,如果该点处的表面是散射面,则计算光源直接照射该点产生的颜色;如果该点处表面是镜面或折射面,则继续向反射或折射方向跟踪另一条光线,如此递归下去,直到光线逃逸出场景或达到设定的最大递归深度。” 其中,最主要的就是射线与各种几何体的求交运算,以及反射与折射方向的计算。如图所示:
在实现上我借鉴了 Tiny Raytracer,但是使用Opencv实现。因为求交涉及大量的向量运算,Opencv有自己的矩阵运算库,而且Opencv也支持实时显示图像。这样就不需要我把大量的精力都浪费在写矩阵运算库以及图像的操作和编解码上,同时还可以不需要每次都把图像存入硬盘再查看,运行时即可查看。最主要是OpenCV用的比较多。
球面
- Why sphere first ?
射线与球的相交是比较容易计算的,而且球也比较有立体感,球的反射折射也比较好看。所以从球面开始。
球面相交
交点计算的核心思路就是,射线到球中心的距离小于球的半径则相交,否则不相交。如若相交,则通过射线所在过球心的横切圆计算交点。如图所示:
- 射线 Ray :P(t) = R0 + Rd * t;
- 球面 Sphere :(P - Pc)^2 = r^2;
射线方程是一个显式方程,球面方程是一个隐式方程,那直接将显式带入到隐式不就可以求解了吗?理论上是这样,但是,这是一个二次方程会有两个实根,而我们只需要射线与球面的第一个交点。而这两个解不好判断哪个是第一个交点,所以我还是采用几何方法求解交点。
根据射线方程,与球面的交点可以用 t 表示。tp = norm(R0Pc) * cos<R0Pc, Rd>;
而 t = tp - t’;
又 t’ = sqrt( r^2 - d^2); d = sqrt( norm(R0Pc)^2 - tp^2);
即可求得 t ,代码如下:
bool ray_intersect(const Vec3f &orig, const Vec3f &dir, float &t0) const {
Vec3f L = center - orig;
float tca = L .dot(dir);
float d2 = L.dot(L) - tca * tca;
if (d2 > radius*radius) return false;
float thc = sqrtf(radius*radius - d2);
t0 = tca - thc;
float t1 = tca + thc;
if (t0 < 0) t0 = t1; //光源在球内部
if (t0 < 0) return false; //交点在视点反方向,不符
return true;
}
Phong Shading
求到了交点,那么在不考虑反射与折射的情况下,像素的颜色即由交点的颜色决定。这里采用Phong Shading,如下图所示。其具体计算不是重点,所以就不赘述了。
阴影
决定一个点是否在阴影中,则从该点向光源方向做光线投射,检测是否和其他物体是否相交。如果有,则说明该点在这盏光源的阴影中。如图所示:
代码:
for (auto &light : lights){
Vec3f light_dir = normalize(light.position - point);
Vec3f shadow_orig = point + N * 1e-3;
Vec3f point_shadow, N_shadow;
Material material_shadow;
if (scene_intersect(shadow_orig, light_dir, spheres, point_shadow, N_shadow, material_shadow)) continue;
diffuse_light_intensity += light.intensity * max(light_dir.dot(N), 0.f);
specular_light_intensity += light.color * powf(max(0.f, -dir.dot(reflect(-light_dir, N))), material.specular_exponent);
}
反射
对于镜面反射,R = S + P;
S = - I·N·N; P = I + S;
所以 R = - ·N ·N + I -I·N·N = I - 2·I·N·N;代码如下:
Vec3f reflect(const Vec3f &l, const Vec3f &n) {
Vec3f L = normalize(l);
Vec3f N = normalize(n);
return L - 2 * N*(N.dot(L)) ;
}
折射
折射遵从Snell定律,即 n1·sinθ1 = n2·sinθ2 。
T = t2 + t1;
而 |l1| = sinθ1; l2 = -N·cosθ1; l1 = L - l2 = L + N·cosθ1;
所以t2 = -N·cosθ2, t1 = l1 / |l1| · sinθ2 = (L + N·cosθ1)/sinθ1·sinθ2 = (L + N·cosθ1)·n1/n2;
又 cosθ2 = sqrt(1 - sin^2(θ2)) = sqrt( 1 - (n1/n2·sinθ1)^2) = sqrt( 1 - (n1/n2)^2 · (1 - cos^2(θ1)));
令n1/n2 = eta;
所以 T = t1 + t2 = (L + N·cosθ1)·eta - N·cosθ2 = L·eta + N·(eta·cosθ1 - cosθ2) = L·eta + N·(eta·cosθ1 - cosθ2) ;
代码如下:
Vec3f refract(const Vec3f &I, const Vec3f &N, const float &refractive_index) { // Snell's law
float cosi = -max(-1.f, min(1.f, I.dot(N)));
float etai = 1, etat = refractive_index;
Vec3f n = N;
if (cosi < 0) {
cosi = -cosi;
std::swap(etai, etat); n = -N;
}
float eta = etai / etat;
float k = 1 - eta * eta*(1 - cosi * cosi);
return k < 0 ? Vec3f(0, 0, 0) : I * eta + n * (eta * cosi - sqrtf(k));
//从光密介质射入光疏介质时,存在全反射临界角,k可能小于0, 而这时不存在折射光线。
}
Put it together
对于每一个像素点,都进行如下流程,反射与折射的计算都依据递归计算,停止条件为超过递归深度便停止。
代码如下:
Vec3f cast_ray(const Vec3f &orig, const Vec3f &dir, const vector<Sphere> &spheres, const vector<Light> &lights, size_t depth=0) {
Vec3f point, N;
Material material;
if (depth > 4 || !scene_intersect(orig, dir, spheres, point, N, material)) {
int x = max(min(bkwidth - 1, int((atan2(dir[2], dir[0]) / (2 * M_PI) + 0.5) * bkwidth)), 0);
int y = max(min(bkheight - 1, int(acos(dir[1]) / M_PI * bkheight)), 0);
//return Vec3f(0.8, 0.7, 0.2);
return background.at<Vec3f>(y, x);
}
Vec3f reflect_dir = normalize(reflect(dir, N));
Vec3f reflect_orig = point + N * 1e-3;
Vec3f reflect_color = cast_ray(reflect_orig, reflect_dir, spheres, lights, depth + 1);
Vec3f refract_dir = normalize(refract(dir, N, material.refractive_index));
Vec3f refract_orig = refract_dir.dot(N) > 0 ? point + N * 1e-3 : point - N * 1e-3;
Vec3f refract_color = cast_ray(refract_orig, refract_dir, spheres, lights, depth + 1);
float diffuse_light_intensity = 0;
Vec3f specular_light_intensity = Vec3f(0, 0, 0);
for (auto &light : lights){
Vec3f light_dir = normalize(light.position - point);
Vec3f shadow_orig = light_dir.dot(N) > 0 ? point + N * 1e-3 : point - N * 1e-3;
Vec3f point_shadow, N_shadow;
Material material_shadow;
if (scene_intersect(shadow_orig, light_dir, spheres, point_shadow, N_shadow, material_shadow)) continue;
diffuse_light_intensity += light.intensity * max(light_dir.dot(N), 0.f);
specular_light_intensity += light.color * powf(max(0.f, -dir.dot(reflect(-light_dir, N))), material.specular_exponent);
}
return material.diffuse_color * diffuse_light_intensity * material.albedo[0] + specular_light_intensity * material.albedo[1] + reflect_color * material.albedo[2] + refract_color * material.albedo[3] ;
}
对于渲染一幅图像,即遍历所有像素执行上述操作,而上述操作是可以完全并行化的,可以用多线程加速,从我贫弱的7700k上再挤出一点算力。代码如下:
void render(const vector<Sphere> &spheres, const vector<Light> &lights, Mat &frame) {
#pragma omp parallel for
for (int j = 0; j < height; j++) {
for (int i = 0; i < width; i++) {
float x = (2 * (i + 0.5) / (float)width - 1)*tan(fov / 2.) * width / (float)height;
float y = -(2 * (j + 0.5) / (float)height - 1)*tan(fov / 2.);
Vec3f dir = normalize(Vec3f(x, y, -1));
frame.at<Vec4b>(j, i) = Scalar(cast_ray(Vec3f(0, 0, 0), dir, spheres, lights)) * 255;
}
}
}
背景
加上背景图像,tah dah!
右上角的球是镜面反射,中间是玻璃折射,好像还挺好看。So, let’s call it a day !
三角面
别急, 虽然我们得到了初步的效果。但是,在真实场景中我们很少会碰到正球体的情况,而模型多用顶点和三角面表示。所以为了不只是能画球,接下来还要做与三角面相交。
平面相交
首先,三角面属于一个平面的一部分,所以我们先计算射线与无限大平面的交点。
平面方程: N·P + D = 0;
射线方程: P(t) = R0 + Rd·t;
与平面不平行的射线与平面最多一个交点,这次终于可以带入求解了。
解得 t = - ( D + n·R0 ) / ( n·Rd ); 再加上对交点位置的简单限制,可以得到一个正方形平面。如下图所示:
代码如下:
float floor_dist = std::numeric_limits<float>::max();
if (fabs(dir[1]) > 0) {
float d = -(orig[1] + 4) / dir[1];
Vec3f pt = orig + dir*d;
if (d>0 && d < spheres_dist && fabs(pt[0]) < 10 && pt[2]<-10 && pt[2]>-30) {//控制正方形棋盘大小
floor_dist = d;
hit = pt;
N = Vec3f(0, 1, 0);
material.diffuse_color = (int(.5*hit[0]+10 ) + int(.5*hit[2])) & 1 ? Vec3f(.3, .3, .3) : Vec3f(.3, .2, .1);
//控制棋盘色块颜色和间隔
return floor_dist < 1000;
}
}
三角面相交
其实,三角面求交的思路,与刚刚画棋盘格的方式很像。先求射线与三角形所在的无限大平面的交点,再计算这个交点是否在三角形内。
在此之前,我们需要引入三角形的重心坐标 ( barycentric coordinates )。通过质心坐标来表示三角形的参数方程。
平面上的点与三角形的三个顶点的关系为:P = αP0 + βP1 + γP2,其中 α + β + γ = 1,若 P 在三角形内部则有 α >= 0 && β >=0 && γ >= 0。所以 P = (1 - β - γ)P0 + βP1 + γP2; 而射线方程: P(t) = R0 + Rd·t; 所以解 R0 + Rd·t = (1 - β - γ)P0 + βP1 + γP2 方程即可。变形得:
令 E1 = P0 - P1; E2 = P0 - P2; S = P0 - R0; 根据Cramer法则:
那么行列式怎么算呢,这里要用到向量的混合积公式。
a x b · c = det(a, b, c);
代码如下:
bool Model::ray_triangle_intersect(const int &fi, const Vec3f &orig, const Vec3f &dir, float &tnear) {
Vec3f edge1 = vert(fi, 1) - vert(fi, 0);
Vec3f edge2 = vert(fi, 2) - vert(fi, 0);
Vec3f pvec = dir.cross(edge2);
float det = edge1 .dot(pvec);
if (det < 1e-5) return false;
Vec3f tvec = orig - vert(fi, 0);
float u = tvec .dot(pvec);
if (u < 0 || u > det) return false;
Vec3f qvec = tvec.cross(edge1);
float v = dir.dot(qvec);
if (v < 0 || u + v > det) return false;
tnear = edge2.dot(qvec) * (1. / det);
return tnear > 1e-5;
}
立方体
当知道如何和三角面求交后,已经可以得到最后的结果了。但是,如果让每一条射线都去算和每个三角面是否相交,计算量就太大了。在加载模型的的时候我们是可以得到魔性的立方体包围盒的。如果射线与包围盒不相交,那么与模型自然不相交。这样减少很大一部分计算。
AABB包围盒
我们将包围盒的一组平行的平面称为slab,一个包围盒就有3组slab。对每一对slab,我们计算射线与这两个无限大平面的交点。将两个交点按照参数 t ( P(t) = R0 + Rd·t ) 的大小排序,记为 timin 与 timax (i = 1,2,3)。计算:
tmin = max(t1min, t2min, t3min);
tmax = min(t1max, t2max, t3max);
若 tmin < tmax 则相交,否则不相交。
代码:
bool Model::ray_bbox_intersect(const Vec3f &orig, const Vec3f &dir) {
float tmin = -std::numeric_limits<float>::max();
float tmax = std::numeric_limits<float>::max();
for (int i = 0; i < 3; i++){
Vec3f normal = Vec3f(0, 0, 0);
normal[i] = 1;
float t1 = -(-mincorner[i] + normal.dot(orig)) / (normal.dot(dir));
float t2 = -(-maxcorner[i] + normal.dot(orig)) / (normal.dot(dir));
t1 > t2 ? tmax = min(t1, tmax), tmin = min(t2, tmin) : tmax = min(t2, tmax), tmin = min(t1, tmin);
}
return tmin < tmax;
}
这是正交投影到一个二维平面的情况,直观感受上确实是符合的。事实上,其思想与 Liang-Barsky 剪裁相似,确实是正确的。我们再向原场景中添加一个鸭子模型,然后赋予其玻璃材质。
最终结果: