题图有三个球从左往右,像不像玻璃球,橡胶球和金属球?这就是我们本篇文章会渲染出来的一张图,究极好看,360度吹爆图形学。我们要开始接上一篇文章继续来实现这个光线追踪渲染器。陈宇晖:用Java实现一个光线追踪渲染器zhuanlan.zhihu.com
其实我不是太喜欢把一篇分很多部分写,我喜欢把一个主题一口气写完成一篇文章,可惜知乎专栏的文章竟然还有文字字数限制,只好把这一篇文章拆开成两部分。而且刚好写到核心部分,就当划一下重点吧~
Chapter 8: Metal
今天要实现的是精美的镜面反射效果镜面反射
反射公式的数学推导
8.1 镜面反射
会发生镜面反射的物体,比如说镜子,如果是我们熟悉的球体的话,金属球算一个。根据一个物体的材质,光线应该会有不同的反应才对。金属球显然和上面发生漫反射的球不是同一个材质的,因此我们开始导入材料这个类。
我们写一个Material抽象类,把漫反射球和金属球都归为这个Material的子类。它们都会发生反射scatter。同时,我们的HitRecord类里,也应该记录我们撞击点撞击的材料是什么,所以添加一个Material类的成员变量matPtr。
public abstract class Material {
public abstract boolean scatter(Ray r, HitRecord rec, Wrapper wrapper);
}
比如Ch7模拟发生漫反射的材料,我们称作Lambertian。具体漫反射的原理,上一章已经讲过了,我们这里主要是为了得到反射光线,然后给color方法使用即可。
/**** @param r 光线* @param rec 碰撞点* @param attenuation 衰减系数* @param scattered 反射光线* @return true*/
public boolean scatter(Ray r, HitRecord rec, Wrapper wrapper) {
Vec3 target = rec.p.Add(rec.normal).Add(randomInUnitSphere()); //相对位置->绝对位置 (p + N) + S wrapper.scattered = new Ray(rec.p, target.Subtract(rec.p)); //源点p 方向->ps wrapper.attenuation = albedo;
return true;
}
你可能看不懂Wrapper wrapper是什么意思,很正常,这是我写的一个包装类,用来传递(保存)修改的变量的。因为我们在scatter方法里,根据撞击点(由HitRecord给出),计算得到了反射光线,并保存了材料衰减系数(这个系数我们在初始化球的时候给出)。保存下来的这两个参数,在我们的别的类里还可以使用,比如说Display类的color方法里,我们要它们来确定最后的像素颜色。
public class Wrapper{
Ray scattered; //反射光线 Vec3 attenuation; //材料系数
public Wrapper() {
scattered = new Ray();
attenuation = new Vec3();
}
}
好的,目前为止,仍未开始写金属球,上面的部分都是为了开始写金属球做准备。那我们赶紧开始写Metal类吧~
public class Metal extends Material{
Vec3 albedo; //反射率 float fuzz; //镜面模糊
public Metal() {
}
public Metal(Vec3 albedo, float f) {
this.albedo = albedo;
if(f < 1){
this.fuzz = f;
}
else {
this.fuzz = 1;
}
}
@Override
public boolean scatter(Ray r, HitRecord rec, Wrapper wrapper) {
Vec3 ref = reflect(r.direction(), rec.normal.normalize());
wrapper.scattered = new Ray(rec.p, ref.Add(randomInUnitSphere().Scale(fuzz))); //p->ref wrapper.attenuation = albedo;
return (ref.dot(rec.normal) > 0);
}
/*** 推导出反射光线向量* @param v 入射光线* @param n 撞击点的法向量(单位向量)* @return 反射光线*/
Vec3 reflect(Vec3 v, Vec3 n)
{
return v.Subtract(n.Scale(v.dot(n)*2));
//return v - 2 * dot(v, n)*n; }
}
去掉构造方法,我们直接看scatter方法,这个方法里一开始就调用一个reflect方法,这是计算反射光线的,即给一个入射光光线和撞击点的法向量,我们就能算出反射光线(这里我们把这个方法想象成一个黑盒子即可,具体推导见下方)。然后这个反射光线经过一个镜面模糊化后,把这个反射光光线传出去,给color使用,理解上应该不会有太大的困难。
color方法里,我们势必会进行一些修改,主要是碰撞这块。这里我们得出新的反射光线,从包装类里传过来后,反射光线作为一条新的光线,在进行一定的能量衰减后,重新发射出去,我们控制一下递归深度为50,一条光线反射次数超过这个数,直接返回黑色,即表现为阴影。
HitRecord rec = new HitRecord();
if(world.hit(r, 0.001f, Float.MAX_VALUE, rec)){
//任何物体有撞击点 Wrapper wrapper = new Wrapper();
if(depth < 50 && rec.matPtr.scatter(r, rec, wrapper)){
return color(wrapper.scattered, world, depth+1).Multiply(wrapper.attenuation);
}else{
return new Vec3(0,0,0);
}
}
下面我们开始推导反射公式以及介绍下这个镜面模糊化。
8.2 反射公式的数学推导反射公式的推导
如图,这里入射光是v,单位法向量是N,都是我们传入的参数,红色的箭头是反射光线,这里就叫R吧,是我们要求的。通过基础数学向量知识,我们知道
,由于V已知,我们只要算B向量即可。
显然,B和N具有相同的方向,唯一的区别就是长度不同,我们的N是单位向量,这里要用到向量点积的知识,即
相当于v在n上的投影,尤其是n为单位向量的时候。
其中,
是1,
刚好就是B的长度,注意他们的角度是钝角,注意正负号。B的长度有了,那么B的向量就可知了~
根据
,推得
到此,反射光线数学推导结束。
注意我们的代码里有个fuzz参数,它是用来控制模糊程度的,对于我们生活中,金属球的表面可能不会像镜子一样光滑吧?所以我们要稍微微调下反射方向,如下图,反射方向被扩散到在一个球的范围里,如果这个球设的越大,反射光线照射的越发散,对应镜面越不光滑。
逼话少说,上图!我把“地面”弄成一个金属球了,这里我设置了三档fuzz,分别是0,0.1和1。具体表现如下:fuzz = 0fuzz = 0.1fuzz = 1
perfect!感叹于图形学的美丽,称为程序员的浪漫真的不过分吧~
Chapter 9: Dielectrics
这一章是可能是这本书最难的一章了,但仅限于数学推导上,我们仍然分成两部分来介绍折射公式的推导
实现
9.1 折射公式的推导折射示意图
上图是模拟光从光疏介质到光密介质的折射现象图。
入射光VP是单位向量,折射光PR也单位向量,这里先忽略反射光。向上是单位法向量PN。因此这三个向量长度都为1。
入射角
,折射角
,请忽略上面的度数显示;光密介质折射率
,光疏介质折射率
。
延长VP至B,直到R和B在同一水平线上,并且延长PR到y轴,交点为N'。
明确需求:我们要求得折射光PR向量。
由简单的向量加减可得:
上面问题转为求出PN'向量和N'R向量。
(1)先算PN'向量:
由三角公式
然后PN'和PN是反方向的,所以
(2)算N'R向量:
设
其中,由简单的向量加减:
由三角公式:
由折射定律可知:
经过三角公式变形,得到入射角和反射角的关系
再化简下k
(3)上式全部代入,整理一下:
好了,推导结束,通过入射光向量和法向量就可以求出折射光向量,式子中的入射角可以用这两个向量的点乘算出。
代码如下:
/**** @param v 单位入射光线向量* @param n 单位法向量* @param nt 折射介质/入射介质* @param wrapper 包装类 传递折射光线向量* @return 是否有折射*/
public boolean refract(Vec3 v, Vec3 n, float nt, Wrapper wrapper){
Vec3 uv = v.normalize();
float cos_a1 = -1.0f * uv.dot(n);
float temp = 1.0f - nt*nt*(1.0f-cos_a1*cos_a1);
if(temp > 0.0f){
wrapper.refracted = uv.Scale(nt).Add(n.Scale((float)(nt*cos_a1 - Math.sqrt(temp))));
return true;
}
else {
return false;
}
}
9.2 折射工程实现
发生折射的同时,往往伴随着反射。上一步我们是暂时忽略反射的存在,现在要考虑进来。我们用到了Schlick's approximation逼近公式。这里就不去管证明和推导了,直接使用,详细介绍请看wiki。
/*** 反射系数的求解 逼近公式* @param cosine 入射角余弦* @param ref_idx n2/n1* @return 反射系数*/
float schlick(float cosine, float ref_idx) {
float r0 = (1-ref_idx) / (1+ref_idx);
r0 = r0*r0;
return (float)(r0 + (1-r0)*Math.pow((1 - cosine),5));
}
有了反射系数,我们考虑反射光线和折射光线的叠加,因为我们一个点有100次采样,所以我们根据这个反射系数,让其中一部分光去走反射的光路,另一部分走折射的光路。这里用一个随机数来确定。
/* 反射光线和折射光线的叠加 */
if (Math.random() < reflect_prob) {
wrapper.scattered = new Ray(rec.p, reflected);
}
else {
wrapper.scattered = new Ray(rec.p, wrapper.refracted);
}
最后考虑全反射,当没有折射光线计算出来,即refract返回是false时,我们判断为全反射,此时把反射系数设为1,即发生了全反射。
会发生折射的玻璃球又是一种新的材质,所以我们要写一个新的类叫Dielectic。这个类继承自Material,所以要重写scatter。我们要先判断是从光疏介质射到光密介质还是光密介质射到光疏介质,从入射光与法向量的夹角来判断,因此我们用点乘实现。
整个类的实现代码如下:
public class Dielectic extends Material{
float ref_idx; //ref_idx是指光密介质的折射指数和光疏介质的折射指数的比值
public Dielectic() {
}
public Dielectic(float ref_idx) {
this.ref_idx = ref_idx;
}
@Override
public boolean scatter(Ray r, HitRecord rec, Wrapper wrapper) {
Vec3 outward_normal; //入射时的法向量 Vec3 reflected = reflect(r.direction(), rec.normal);
float ni_over_nt; //sin_a2 / sin_a1 折射介质的折射指数和入射介质的入射指数的比值 float reflect_prob; //反射系数 float cosine;
wrapper.attenuation = new Vec3(1,1,1);
if (r.direction().dot(rec.normal) > 0) {
// 空气->球 光疏->光密 // 法向量取个反 outward_normal = rec.normal.Scale(-1);
ni_over_nt = ref_idx;
cosine = r.direction().dot(rec.normal) / ( r.direction().length() * rec.normal.length() ); //入射角余弦 }
else {
// 球->空气 outward_normal = rec.normal;
ni_over_nt = 1.0f / ref_idx;
cosine = -r.direction().dot(rec.normal) / ( r.direction().length() * rec.normal.length() ); //入射角余弦 }
if (refract(r.direction(), outward_normal, ni_over_nt, wrapper)) {
//发生了折射 计算反射系数 reflect_prob = schlick(cosine, ref_idx);
//wrapper.scattered = new Ray(rec.p, wrapper.refracted); //若没有考虑全反射 则此处直接输出折射光线 }
else {
//计算折射光线方向向量的函数返回false,即出现全反射。 //wrapper.scattered = new Ray(rec.p, reflected); reflect_prob = 1.0f;
//return false; }
/* 反射光线和折射光线的叠加 */
if (Math.random() < reflect_prob) {
wrapper.scattered = new Ray(rec.p, reflected);
}
else {
wrapper.scattered = new Ray(rec.p, wrapper.refracted);
}
return true;
}
/**** @param v 单位入射光线向量* @param n 单位法向量* @param nt 折射介质/入射介质* @param wrapper 包装类 传递折射光线向量* @return 是否有折射*/
public boolean refract(Vec3 v, Vec3 n, float nt, Wrapper wrapper){
Vec3 uv = v.normalize();
float cos_a1 = -1.0f * uv.dot(n);
float temp = 1.0f - nt*nt*(1.0f-cos_a1*cos_a1);
if(temp > 0.0f){
wrapper.refracted = uv.Scale(nt).Add(n.Scale((float)(nt*cos_a1 - Math.sqrt(temp))));
return true;
}
else {
return false;
}
}
/*** 推导出反射光线向量* @param v 入射光线* @param n 撞击点的法向量(单位向量)* @return 反射光线*/
Vec3 reflect(Vec3 v, Vec3 n)
{
return v.Subtract(n.Scale(v.dot(n)*2));
//return v - 2 * dot(v, n)*n; }
/*** 反射系数的求解 逼近公式* @param cosine 入射角余弦* @param ref_idx n2/n1* @return 反射系数*/
float schlick(float cosine, float ref_idx) {
float r0 = (1-ref_idx) / (1+ref_idx);
r0 = r0*r0;
return (float)(r0 + (1-r0)*Math.pow((1 - cosine),5));
}
}
出图:
Chapter 10: Positionable camera
之前我们相机的位置是固定在原点(0,0,0)的,这就显得很死板,作为一个灵活的渲染器,我们当然要能到处移动相机的位置,从各个不同的角度观察我们创建的三维世界!
我们要引入新的一个参数角度
,代表了我们的视野,专业点叫field of view。有了
,再加上已知画布离我们距离为1,我们就可以算出
。再引入一个参数叫宽高比aspect,就能算出宽=h*aspect。
引入了坐标新的表示方法,我们还要建立相机的专属坐标系,就用uvw来表示吧。
这里w就相当于坐标系里的z轴,lookfrom-lookat这个向量的方向就是w轴。
我们定义一个一般都不会去变动的相机倾斜角为(0,1,0),一般叫view up。有了w轴和这个相机倾斜角,我们可以通过叉乘计算出u轴方向,再由w轴和u轴叉乘算出v轴方向。
然后用新的坐标代入,计算出新的lower_left,horizontal,vertical,我们的画布就可以用新的坐标重新开始工作啦~
/**** @param lookfrom 相机位置* @param lookat 观察点* @param vup 相机的倾斜方向 view up* @param vfov 视野 field of view* @param aspect 宽高比*/
public Camera(Vec3 lookfrom, Vec3 lookat, Vec3 vup, float vfov, float aspect){
Vec3 u, v, w;
float theta = (float)(vfov * Math.PI / 180);
float half_height = (float)( Math.tan(theta/2) );
float half_width = aspect * half_height;
origin = lookfrom;
w = lookfrom.Subtract(lookat).normalize(); //相当于新的z u = vup.cross(w).normalize(); //相当于新的x v = w.cross(u).normalize(); //相当于新的y lower_left = origin.Subtract(u.Scale(half_width)).Subtract(v.Scale(half_height)).Subtract(w);
horizontal = u.Scale(2*half_width);
vertical = v.Scale(2*half_height);
}
简单的调整视野试验,其它参数大家都可以尝试:
以及渲染我们的题图~
Chapter 11: Defocus Blur
这一章讲了散焦模糊,用以模拟我们拍摄过程中的景深效果,具体作用就是突出关注的目标而糊化周围的场景。
这里引入光圈的概念,相机中光圈是一个用来控制光线透过镜头,进入机身内感光面光量的装置。我们之前都是通过一个个针孔透过一条射线光来看画面,而光圈就相当于把针孔变大,一次能透出很多光线组成的圆形光束。
现在我们要模拟这个光圈的设定,如下图所示。
我们把射出的光线通过叠加一个二维的随机数,来模拟上图的棱镜,也就是说本来一条固定的光射出,现在变成了一个圆内随机一点射出。再加上我们之前的采样率是100,也就是说同一个位置的光是重复100次射出的,因为这样下来,就相当于光是从一个平面内发射出来的光束了。而这个圆的半径就是我们的光圈参数,是我们可以任意调整设定的。
/*** 生成一个单位球内的随机坐标,模拟光圈棱镜* @return 单位球内的随机坐标*/
public Vec3 randomInUnitSphere(){
Vec3 p;
do{
//随机坐标 区间[-1,+1] p =new Vec3((float)(Math.random()), (float)(Math.random()), 0).Scale(2.0f).Subtract(new Vec3(1.0f, 1.0f, 0.0f));
}while (p.dot(p) >= 1.0f); //如果坐标在球内则采用,否则再次生成 return p;
}
我们通过棱镜把光线变成了光束,当然还得把光束聚集来一起,这样才能形成清晰的图像。如上图。因此我们光线发射出去所增加的的偏移量,要在后面减掉,这样才能保证光线照到该有的位置。
public Ray GetRay(float u, float v)
{
Vec3 rd = randomInUnitSphere().Scale(lens_radius);
Vec3 offset = this.u.Scale(rd.x()).Add(this.v.Scale(rd.y()));
return new Ray(origin.Add(offset), lower_left.Add(horizontal.Scale(u)).Add(vertical.Scale(v)).Subtract(origin).Subtract(offset));
}
此外成像的位置也有所改变,这里引入了一个focus_dist参数,把原来的成像位置-w现在改变为-focus_dist*w,这里的focus_dist可以设置比如设置成观察点和相机的距离:
float dist_to_focus = (lookfrom.Subtract(lookat)).length();
那么就相当于相机固定聚焦于这个观察点所在的平面上成像。
值得注意的是,我们改了这个成像位置,对应的宽和高也要作修改。回顾一下前几章,因为我们的宽是由高算出来的,而高是由视野角和成像位置距离算出来的,所以都要乘上这个focus_dist。
综上Camera类修改成这样:
/**** @param lookfrom 相机位置* @param lookat 观察点* @param vup 相机的倾斜方向 view up* @param vfov 角度 field of view* @param aspect 宽高比*/
public Camera(Vec3 lookfrom, Vec3 lookat, Vec3 vup, float vfov, float aspect, float aperture, float focus_dist){
lens_radius = aperture / 2;
float theta = (float)(vfov * Math.PI / 180);
float half_height = (float)( Math.tan(theta/2) );
float half_width = aspect * half_height;
origin = lookfrom;
w = lookfrom.Subtract(lookat).normalize(); //相当于新的z u = vup.cross(w).normalize(); //相当于新的x v = w.cross(u).normalize(); //相当于新的y lower_left = origin.Subtract(u.Scale(half_width*focus_dist)).Subtract(v.Scale(half_height*focus_dist)).Subtract(w.Scale(focus_dist));
horizontal = u.Scale(2*half_width*focus_dist);
vertical = v.Scale(2*half_height*focus_dist);
}
Chapter 12: Where next?
这章没有新的知识,就是渲染一下封面图~我直接放一下我java版的场景设置
List random_scene() {
List objList = new ArrayList();
//超大漫反射球作为地板 objList.add(new Sphere(new Vec3(0.0f,-1000.0f,0.0f), 1000.0f, new Lambertian(new Vec3(0.5f, 0.5f, 0.5f))));
//定义三大球 objList.add(new Sphere(new Vec3(0, 1, 0), 1.0f, new Dielectric(1.5f)));
objList.add(new Sphere(new Vec3(-4, 1, 0), 1.0f, new Lambertian(new Vec3(0.4f, 0.2f, 0.1f))));
objList.add(new Sphere(new Vec3(4, 1, 0), 1.0f, new Metal(new Vec3(0.7f, 0.6f, 0.5f), 0.0f)));
//生成地面小球 int i = 1;
for (int a = -11; a < 11; a++) {
for (int b = -11; b < 11; b++) {
/*两个for循环中会产生(11+11)*(11+11)=484个随机小球*/
float choose_mat = (float)Math.random();
/*产生一个(0,1)的随机数,作为设置小球的材质的阀值*/
Vec3 center = new Vec3((float)( a+0.9*(Math.random()) ), 0.2f, (float) ( b+0.9*(Math.random() )));
/*球心的x,z坐标散落在是(-11,11)之间的随机数*/
if ((center.Subtract(new Vec3(4,0.2f,0))).length() > 0.9) {
/*避免小球的位置和最前面的大球的位置太靠近*/
if (choose_mat < 0.8) { //diffuse /*材料阀值小于0.8,则设置为漫反射球,漫反射球的衰减系数x,y,z都是(0,1)之间的随机数的平方*/
objList.add(
new Sphere(center, 0.2f, new Lambertian(
new Vec3((float)( (Math.random())*(Math.random()) ),
(float)( (Math.random())*(Math.random()) ),
(float)( (Math.random())*(Math.random()) ))
))
);
}
else if (choose_mat < 0.95) {
/*材料阀值大于等于0.8小于0.95,则设置为镜面反射球,镜面反射球的衰减系数x,y,z及模糊系数都是(0,1)之间的随机数加一再除以2*/
objList.add(
new Sphere(center, 0.2f, new Metal(
new Vec3((float)( 0.5f*(1+(Math.random())) ), (float)( 0.5f*(1+(Math.random())) ), (float)( 0.5f*(1+(Math.random()))) ),
(float)( 0.5*(1+(Math.random())))
))
);
}
else {
/*材料阀值大于等于0.95,则设置为介质球*/
objList.add(
new Sphere(center, 0.2f, new Dielectric(1.5f))
);
}
}
}
}
return objList;
}
以上。