本次光线追踪系列从基础重新开始,主要参照 Ray Tracing in One Weekend ,具体实现代码框架见 https://github.com/RayTracing/raytracing.github.io/。本文只是主要精炼光追相关理论,具体实现可参照原文。
本部分主要来实现反射函数及金属特性;
一、材质抽象类 material
接下来我们会涉及到处理不同的材质,所以我们可以封装一个材质类material。
其中的抽象函数叫scatter(),意思为散射,因为射线起点和方向的改变,不一定是反射,还可能是折射等,所以这种行为统称为散射。函数返回值为bool,表明这次的散射是否成功。此外,该方法要能够产生散射射线scattered,且能反映散射过程中各颜色通道衰减了多少,用attenuation表示。
注意:虽然这个变量叫作attenuation(衰减),但在代码逻辑中,该变量其实是反射量。特此说明。
class material
{
public:
//r_in为入射光线, scattered为散射光线, attenuation 意思为衰减量,实际为各通道的反射率
virtual bool scatter(const ray &r_in, const hit_record &rec, vec3 &attenuation, ray &scattered) const = 0;
};
hit_record里面也要加上一个material的指针,以记录此次命中物体的材质。
//记录命中信息
struct hit_record
{
float t; //命中射线的长度
vec3 p; //命中终点坐标
vec3 normal; //命中点的法向量
material *mat_ptr; //new
};
球体类也要加上material指针:
class sphere : public hittable
{
public:
vec3 center;
float radius;
material *mat_ptr; /* NEW */
...
sphere(vec3 cen, float r, material *m) : center(cen), radius(r), mat_ptr(m){}; //new
//如果命中了,命中记录保存到rec
virtual bool hit(const ray &r, float t_min, float t_max, hit_record &rec) const
{
...
if (discriminant > 0)
{
float temp = (-b - sqrt(discriminant)) / a; //小实数根
if (temp < t_max && temp > t_min)
{
...
rec.mat_ptr = mat_ptr; /* NEW */
return true;
}
temp = (-b + sqrt(discriminant)) / a; //大实数根
if (temp < t_max && temp > t_min)
{
...
rec.mat_ptr = mat_ptr; /* NEW */
return true;
}
}
return false;
}
};
这样一来,当射线命中我们的球体表面时,mat_ptr会指向该物体的材质。以便在color()能够获取hit_record中的mat_ptr,对不同材质的散射光线做进一步处理。
1.1 封装Lambert(兰伯特)材质类
上一节我们实现了不光滑的表面产生均匀散射而形成的材质类型,这叫做Lambert材质,把它封装成一种材质,继承自材质类material。
跟上一节的代码相比,新增了一个叫做albedo(反射率)的变量,光线能量的衰减量(即吸收量)应该为1-albedo,但不知道为什么代码中albedo和attenuation划等号了,特此说明。
class lambertian : public material
{
public:
vec3 albedo; //反射率
lambertian(const vec3 &a) : albedo(a) {}
virtual bool scatter(const ray &r_in, const hit_record &rec, vec3 &attenuation, ray &scattered) const
{
vec3 s_world = rec.p + rec.normal + random_in_unit_sphere();
scattered = ray(rec.p, s_world - rec.p); //scattered为散射光线
attenuation = albedo; //注意这是各通道的反射率!
return true;
}
};
1.2 更新color()函数
color()新增了一个depth参数,用于限制递归深度,例如限制最多递归50层。此外,直接调用mat_ptr->scatter()来获取当前命中材质的反射率变量attenuation和下一步的散射光线scattered,然后通过反射率变量attenuation和递归color()的颜色值进行向量乘法,从而将下一条射线的采样结果,与本次命中材质上各通道发生的衰减,结合起来。
//发射一条射线,并采样该射线最终输出到屏幕的颜色值值
vec3 color(const ray &r, hittable *world, int depth){
hit_record rec;
if (world->hit(r, 0.001, MAXFLOAT, rec)) //射线命中物体
{
ray scattered; //散射光线
vec3 attenuation; //其实是反射率!
if (depth < 50 && rec.mat_ptr->scatter(r, rec, attenuation, scattered)) //不超过递归深度,且发生了散射
{
return attenuation * color(scattered, world, depth + 1);
}
else
{
return vec3(0, 0, 0);
}
}
else
{
vec3 unit_direction = unit_vector(r.direction());
float t = 0.5 * (unit_direction.y() + 1.0);
return (1.0 - t) * vec3(1.0, 1.0, 1.0) + t * vec3(0.5, 0.7, 1.0);
}
}
可能递归理解起来有点绕,但我们这样来理解,每一次执行color()函数,(不超过递归深度,且发生了散射时)会返回:
当前射线命中的材质本身的反射率 * (射线散射后所产生的)新射线所采样到的最终返回值。
例如上一节漫反射材质反射的color()函数:
//发射一条射线,并采样该射线最终输出到屏幕的颜色值值
vec3 color(const ray &r, hittable *world)
{
...
vec3 s_world = rec.p + rec.normal + random_in_unit_sphere(); //1.单位球内的一个点s
return 0.5 * color(ray(rec.p, s_world - rec.p), world); //2.递归采样
...
}
其实是将反射率定义为了vec3(0.5, 0.5, 0.5),然后
return vec3(0.5,0.5,0.5) * color(漫反射材质的反射射线, world);
1.3 更新光追入口函数
void RayTracing()
{
list[0] = new sphere(vec3(0, 0, -1), 0.5, new lambertian(vec3(0.5, 0.5, 0.5)));
list[1] = new sphere(vec3(0, -100.5, -1), 100, new lambertian(vec3(0.5, 0.5, 0.5)));//new
list[2] = new sphere(vec3(1, 0, -1), 0.3, new lambertian(vec3(0.5, 0.5, 0.5))); //new
list[3] = new sphere(vec3(-1, 0, -1), 0.3, new lambertian(vec3(0.5, 0.5, 0.5))); //new
world = new hittable_list(list, 4);
ns = 200;
camera cam;
for (int j = ny - 1; j >= 0; j --)
{
for (int i = 0; i < nx; i++)
{
...
for(int s = 0; s < ns; s++)
{
...
ray r = cam.get_ray(u, v);
col += color(r, world, 0); new
}
...
}
}
}
尽管得到的效果和上节完全一样,但我们现在可以进一步继承材质类material,实现更多不同材质的散射结果。同时也可以设置不同的反射率通道的值,保留(=1)或削弱(<1)散射光线采样到的各个颜色通道。
例如,改变上面两个球体的反射率:
list[0] = new sphere(vec3(0, 0, -1), 0.5, new lambertian(vec3(1.0, 0.7, 0.0)));
list[1] = new sphere(vec3(0, -100.5, -1), 100, new lambertian(vec3(0.0, 1.0, 1.0)));//new
list[2] = new sphere(vec3(1, 0, -1), 0.3, new lambertian(vec3(0.8, 0.6, 0.2)));//new
list[3] = new sphere(vec3(-1, 0, -1), 0.3, new lambertian(vec3(0.8, 0.8, 0.8)));//new
接下来,实现金属材质类。
二、反射函数
绝对光滑的金属表面,并不会像Lambert材质那样随机散射,而是会像镜子那样,反射出一个关于表面法向量对称的反射向量,如下图所示:
其中,N为单位法向量,由上图可知(红色箭头对应的)反射向量:
v在N上面的投影的绝对值,就是B的长度。因为v和N的夹角大于90度,导致v⋅N<0,因此B的长度为−(v⋅N),综上可得反射公式为:
写成反射函数:
vec3 reflect(const vec3& v, const vec3& n) {
return v - 2*dot(v,n)*n;
}
因为reflect()返回的是反射光线的向量,也就是反射光线的方向,所以反射光线scattered的代码表达为:
vec3 p = 反射点;
vec3 r = reflect(v,n); //反射向量
ray scattered = ray(p, r); //反射光线
目前已经可以实现镜面反射的效果了。此外,我们可以让反射光线稍微偏移一点,来实现金属的哑光效果。具体做法就是在反射光线的t=1的位置,偏移一个小球的量,这个偏移量比单位球小一点:
如果希望偏移量比单位球小一点,可以设fuzz为一个0到1的系数,然后乘以单位球内的某个点的坐标,得到偏移量x。加上偏移后的反射射线逻辑修改如下:
vec3 p = 反射点;
vec3 r = reflect(v, n); //反射向量
vec3 x = fuzz * random_in_unit_sphere(); //偏移量
ray scattered = ray(p, r+x); //反射光线
三、金属材质类Metal
class metal : public material
{
public:
vec3 albedo;
float fuzz;
metal(const vec3 &a, float f) : albedo(a)
{
fuzz = f < 1 ? f : 1;
}
virtual bool scatter(const ray &r_in, const hit_record &rec, vec3 &attenuation, ray &scattered) const
{
vec3 v = unit_vector(r_in.direction());
vec3 n = rec.normal;
vec3 p = rec.p;
vec3 r = reflect(v, n);
vec3 offset = fuzz * random_in_unit_sphere();
scattered = ray(p, r + offset);
attenuation = albedo;
return (dot(scattered.direction(), rec.normal) > 0);
}
};
新增两个金属球体,fuzz分别设置为0.3和1.0:
hittable *list[4];
...
list[2] = new sphere(vec3(1, 0, -1), 0.3, new metal(vec3(0.8, 0.6, 0.2), 0.3)); //new
list[3] = new sphere(vec3(-1, 0, -1), 0.3, new metal(vec3(0.8, 0.8, 0.8), 1.0)); //new
如果将fuzz直接设置为0,则表示完全反射:
...
list[2] = new sphere(vec3(1, 0, -1), 0.3, new metal(vec3(0.8, 0.6, 0.2), 0.0)); //new
list[3] = new sphere(vec3(-1, 0, -1), 0.3, new metal(vec3(0.8, 0.8, 0.8), 0.0)); //new