2019 / 06 / 22

SmallPT —— 99 行代码光线追踪解析

光线追踪(Ray tracing)是三维计算机图形学中的特殊渲染算法:根据光路可逆原理,对每一个像素,沿着入射光线逆向追踪若干次反射、折射,进而计算此光线的颜色,把场景渲染出来。

理论上,光线追踪算法可以完整地模拟物理世界中的光照,分毫不差地计算出每个像素的颜色,但是这样做的算力消耗趋近于正无穷。所以实际上所有光线追踪算法都包含了一些近似优化的逻辑,以将运算开销控制在可接受的数量级内。

Rhythm & Hues Studios 公司的程序员 Kevin Beason 曾于 2010 年编写过一个名为 SmallPT 的 C++ 程序,仅包含 99 行代码,即实现了最简单的光线追踪效果。此程序可视为光追算法的可运行最小集,是初学者学习和理解光追原理的极佳材料,其运行结果如下图所示。作为一个门外汉,我花了好几个晚上研究这 99 行代码,并在这个极好的 PPT 的帮助下,总算基本弄明白了其运行的原理。不妨记录下来:

Small PT 程序运行结果Small PT 程序运行结果

渲染方程

还是从最基础的渲染方程开始:

解释:

  1. 此方程描述的问题是:从物体表面上的一点 处,射入到观察者眼中的某条光线的强度,是如何确定的。
  2. 为射入观察者眼中的光线的颜色,即需要求取的值; 为出射方向。
  3. 为物体表面在点 向观察者方向自发射的光线的颜色(灯)。
  4. 表示环境入射到点 的光的颜色; 代表入射方向(为计算方便,取真实入射方向的反方向,不影响代表关系,后面也简称为入射方向)。
  5. 表示在给定 时,由 方向的入射单位光强产生的 方向的出射光的强度,此函数与表面的性质有关,又称表面的 BRDF 函数。
  6. 表示 与表面法线的夹角。
  7. 整个积分项表示:对半球(不透明材质)或全球(透明介质)内的所有入射方向 进行积分,得到的 方向的出射光强度。

依照光线追踪的原理,我们沿着 方向追踪到一处交点 ,就需要进行一次积分操作。在计算机程序中,积分是用求和模拟的,求和的次数越多(自变量的间隔越小),结果就越准确。假设每次积分都要进行 次求和操作,那么当追踪的光线遇到第一个交点时,会发散成 条光线;同时追踪这 条光线,到下一个交点,每条光线又会发散成 条光线……随着追踪深度的增加,计算开销的量级将按照指数级上升。

蒙特卡洛方法

蒙特卡洛方法(Monte Carlo method)是一种使用概率理论(通过大量随机数采样)进行数值计算以求取积分的方法。一个常用的有助理解的例子是:对「如何计算圆的面积」这个问题(圆的面积公式求取其实也是一个积分问题),蒙特卡洛的解法是「撒豆」:在包含圆的已知面积为 的矩形内随机采样(撒豆) 次,统计豆在圆内的次数为 ,则圆的面积为

撒豆问题不仅可以解圆的面积,还可以解任意形状,甚至不规则形状的面积求取。

更具体地,蒙特卡洛方法可以表述为:

  1. 左侧表示函数 在区间 的积分,即下图中的部分阴影部分面积。
  2. 此积分的值,可以这样求取:随机在 区间取值,采样计算 ,然后计算所有样本的均值并乘以区间的长度。当取样数量 越大,最后的值就越接近真实的积分值。

有同学可能会问(我也曾有此困惑),为什么不直接均等分采样,而要随机采样呢?其实,对于计算一维的函数 ,区别确实不大,但对二维甚至更高维度的函数(此时需要求取重积分)如 ,情况就不一样了。

此时,如果采取均等分采样,就需要选择在 上均等分为若干份,选择在 上均等分为若干份(其实撒豆问题已经是二维积分了,只不过积分函数是最简单的二值函数)。随着维度的增加,我们采样的数量也更难以控制,甚至还会出现维度不确定的情况。相比之下,蒙特卡洛方法的随机采样,可以轻易地控制或调整采样的次数:取两次(或更多次数的)随机数,可以视为单次随机行为,其背后包含的某种「随机性」是一致的。

对于光线追踪而言,我们会追踪若干次反射或折射(这个次数又称深度),每一次反射或折射都需要进行一次采样,相当于增加了一个维度。如果每次采样的次数过多,随着深度的增加,总采样次数很快就会不可接受,而如果每次的采样次数过少,(直觉告诉我)那么第一次采样对后续的采样将造成比较大的偏差(这背后应该有更完整的数学解释)。而且,在光线追踪算法中,有时候是否继续进行采样取决于具体的采样值,这就让等分采样变得更加困难。实际上,这份光追算法的实现,在每次反射或折射时只取一个随机样本,而通过增加总总采样次数 来保证最后的结果满足期望。

光线追踪算法

使用蒙特卡洛方法,对每一次反射或折射不再进行积分,而是随机选取一条可能的反射或折射光线进行追踪。然后在开始追踪的源头处,重复多次追踪操作以求取期望,这就是 SmallPT 光追的算法。简述一下具体步骤:

  1. 从每个像素 处发出一条射线 ,其方向与入射到相机并产生该像素的光线相反。
  2. 求取此射线照射到的物体表面的点 ,即与场景中物体的交点;如有多个交点,取距离相机最近的那个。如果未求到交点,则返回背景色。
  3. 射向 的光线强度,为 本身发射光强度 ,加上反射或折射环境光的强度。
    1. :如果 处是光源,则 为光源的强度;否则, 为 0。
    2. :根据 处根据表面性质,按概率随机取一次反射的射线 ,然后重复 2 的步骤,分别递归地求取 ……等等,直到满足一些特定条件停止递归。

以上便是单个像素的光追的算法。对单个像素,重复大量的次数求取平均值,作为此像素的颜色。

对每个像素完成以上步骤,就渲染出了整幅图像。

代码摘录

完整的代码摘抄如下:

#include <math.h>   // smallpt, a Path Tracer by Kevin Beason, 2008
#include <stdlib.h> // Make : g++ -O3 -fopenmp smallpt.cpp -o smallpt
#include <stdio.h>  //        Remove "-fopenmp" for g++ version < 4.2
struct Vec {        // Usage: time ./smallpt 5000 && xv image.ppm
  double x, y, z;                  // position, also color (r,g,b)
  Vec(double x_=0, double y_=0, double z_=0){ x=x_; y=y_; z=z_; }
  Vec operator+(const Vec &b) const { return Vec(x+b.x,y+b.y,z+b.z); }
  Vec operator-(const Vec &b) const { return Vec(x-b.x,y-b.y,z-b.z); }
  Vec operator*(double b) const { return Vec(x*b,y*b,z*b); }
  Vec mult(const Vec &b) const { return Vec(x*b.x,y*b.y,z*b.z); }
  Vec& norm(){ return *this = *this * (1/sqrt(x*x+y*y+z*z)); }
  double dot(const Vec &b) const { return x*b.x+y*b.y+z*b.z; } // cross:
  Vec operator%(Vec&b){return Vec(y*b.z-z*b.y,z*b.x-x*b.z,x*b.y-y*b.x);}
};
struct Ray { Vec o, d; Ray(Vec o_, Vec d_) : o(o_), d(d_) {} };
enum Refl_t { DIFF, SPEC, REFR };  // material types, used in radiance()
struct Sphere {
  double rad;       // radius
  Vec p, e, c;      // position, emission, color
  Refl_t refl;      // reflection type (DIFFuse, SPECular, REFRactive)
  Sphere(double rad_, Vec p_, Vec e_, Vec c_, Refl_t refl_):
  rad(rad_), p(p_), e(e_), c(c_), refl(refl_) {}
  double intersect(const Ray &r) const { // returns distance, 0 if nohit
    Vec op = p-r.o; // Solve t^2*d.d + 2*t*(o-p).d + (o-p).(o-p)-R^2 = 0
    double t, eps=1e-4, b=op.dot(r.d), det=b*b-op.dot(op)+rad*rad;
    if (det<0) return 0; else det=sqrt(det);
      return (t=b-det)>eps ? t : ((t=b+det)>eps ? t : 0);
  }
};
Sphere spheres[] = {//Scene: radius, position, emission, color, material
  Sphere(1e5, Vec( 1e5+1,40.8,81.6), Vec(),Vec(.75,.25,.25),DIFF),//Left
  Sphere(1e5, Vec(-1e5+99,40.8,81.6),Vec(),Vec(.25,.25,.75),DIFF),//Rght
  Sphere(1e5, Vec(50,40.8, 1e5),     Vec(),Vec(.75,.75,.75),DIFF),//Back
  Sphere(1e5, Vec(50,40.8,-1e5+170), Vec(),Vec(),           DIFF),//Frnt
  Sphere(1e5, Vec(50, 1e5, 81.6),    Vec(),Vec(.75,.75,.75),DIFF),//Botm
  Sphere(1e5, Vec(50,-1e5+81.6,81.6),Vec(),Vec(.75,.75,.75),DIFF),//Top
  Sphere(16.5,Vec(27,16.5,47),       Vec(),Vec(1,1,1)*.999, SPEC),//Mirr
  Sphere(16.5,Vec(73,16.5,78),       Vec(),Vec(1,1,1)*.999, REFR),//Glas
  Sphere(600, Vec(50,681.6-.27,81.6),Vec(12,12,12),  Vec(), DIFF) //Lite
};
inline double clamp(double x){ return x<0 ? 0 : x>1 ? 1 : x; }
inline int toInt(double x){ return int(pow(clamp(x),1/2.2)*255+.5); }
inline bool intersect(const Ray &r, double &t, int &id){
  double n=sizeof(spheres)/sizeof(Sphere), d, inf=t=1e20;
  for(int i=int(n);i--;) if((d=spheres[i].intersect(r))&&d<t){t=d;id=i;}
  return t<inf;
}
Vec radiance(const Ray &r, int depth, unsigned short *Xi){
  double t;                               // distance to intersection
  int id=0;                               // id of intersected object
  if (!intersect(r, t, id)) return Vec(); // if miss, return black
  const Sphere &obj = spheres[id];        // the hit object
  Vec x=r.o+r.d*t, n=(x-obj.p).norm(), nl=n.dot(r.d)<0?n:n*-1, f=obj.c;
  double p = f.x>f.y && f.x>f.z ? f.x : f.y>f.z ? f.y : f.z; // max refl
  if (++depth>5) if (erand48(Xi)<p) f=f*(1/p); else return obj.e; //R.R.
  if (obj.refl == DIFF){                  // Ideal DIFFUSE reflection
    double r1=2*M_PI*erand48(Xi), r2=erand48(Xi), r2s=sqrt(r2);
    Vec w=nl, u=((fabs(w.x)>.1?Vec(0,1):Vec(1))%w).norm(), v=w%u;
    Vec d = (u*cos(r1)*r2s + v*sin(r1)*r2s + w*sqrt(1-r2)).norm();
    return obj.e + f.mult(radiance(Ray(x,d),depth,Xi));
  } else if (obj.refl == SPEC)            // Ideal SPECULAR reflection
    return obj.e + f.mult(radiance(Ray(x,r.d-n*2*n.dot(r.d)),depth,Xi));
  Ray reflRay(x, r.d-n*2*n.dot(r.d));     // Ideal dielectric REFRACTION
  bool into = n.dot(nl)>0;                // Ray from outside going in?
  double nc=1, nt=1.5, nnt=into?nc/nt:nt/nc, ddn=r.d.dot(nl), cos2t;
  if ((cos2t=1-nnt*nnt*(1-ddn*ddn))<0)    // Total internal reflection
    return obj.e + f.mult(radiance(reflRay,depth,Xi));
  Vec tdir = (r.d*nnt - n*((into?1:-1)*(ddn*nnt+sqrt(cos2t)))).norm();
  double a=nt-nc, b=nt+nc, R0=a*a/(b*b), c = 1-(into?-ddn:tdir.dot(n));
  double Re=R0+(1-R0)*c*c*c*c*c,Tr=1-Re,P=.25+.5*Re,RP=Re/P,TP=Tr/(1-P);
  return obj.e + f.mult(depth>2 ? (erand48(Xi)<P ? // Russian roulette
    radiance(reflRay,depth,Xi)*RP:radiance(Ray(x,tdir),depth,Xi)*TP) :
    radiance(reflRay,depth,Xi)*Re+radiance(Ray(x,tdir),depth,Xi)*Tr);
}
int main(int argc, char *argv[]){
  int w=1024/8, h=768/8, samps = argc==2 ? atoi(argv[1])/4 : 30;
  Ray cam(Vec(50,52,295.6), Vec(0,-0.042612,-1).norm()); // cam pos, dir
  Vec cx=Vec(w*.5135/h), cy=(cx%cam.d).norm()*.5135, r, *c=new Vec[w*h];
  for (int y=0; y<h; y++){                       // Loop over image rows
    fprintf(stderr,"\rRendering (%d spp) %5.2f%%",samps*4,100.*y/(h-1));
    for (unsigned short x=0, Xi[3]={0,0,(unsigned short)(y*y*y)}; x<w; x++)
      for (int sy=0, i=(h-y-1)*w+x; sy<2; sy++)     // 2x2 subpixel rows
        for (int sx=0; sx<2; sx++, r=Vec()){        // 2x2 subpixel cols
          for (int s=0; s<samps; s++){
            double r1=2*erand48(Xi), dx=r1<1 ? sqrt(r1)-1: 1-sqrt(2-r1);
            double r2=2*erand48(Xi), dy=r2<1 ? sqrt(r2)-1: 1-sqrt(2-r2);
            Vec d = cx*( ( (sx+.5 + dx)/2 + x)/w - .5) +
            cy*( ( (sy+.5 + dy)/2 + y)/h - .5) + cam.d;
            r = r + radiance(Ray(cam.o+d*140,d.norm()),0,Xi)*(1./samps);
          } // Camera rays are pushed ^^^^^ forward to start in interior
          c[i] = c[i] + Vec(clamp(r.x),clamp(r.y),clamp(r.z))*.25;
        }
  }
  FILE *f = fopen("image.ppm", "w");         // Write image to PPM file.
  fprintf(f, "P3\n%d %d\n%d\n", w, h, 255);
  for (int i=0; i<w*h; i++)
    fprintf(f,"%d %d %d ", toInt(c[i].x), toInt(c[i].y), toInt(c[i].z));
}

本文的主要部分,就是对这段代码的解析:

矢量运算

定义 Vec 结构体描述三维矢量,重载运算符来实现矢量运算。

struct Vec {
  double x, y, z;
  Vec(double x_=0, double y_=0, double z_=0){ x=x_; y=y_; z=z_; }
  Vec operator+(const Vec &b) const { return Vec(x+b.x,y+b.y,z+b.z); }
  Vec operator-(const Vec &b) const { return Vec(x-b.x,y-b.y,z-b.z); }
  Vec operator*(double b) const { return Vec(x*b,y*b,z*b); }
  Vec mult(const Vec &b) const { return Vec(x*b.x,y*b.y,z*b.z); }
  Vec& norm(){ return *this = *this * (1/sqrt(x*x+y*y+z*z)); }
  double dot(const Vec &b) const { return x*b.x+y*b.y+z*b.z; }
  Vec operator%(Vec&b){return Vec(y*b.z-z*b.y,z*b.x-x*b.z,x*b.y-y*b.x);}
};

矢量运算包括(假设 ):

  1. 加法 operator+
  2. 减法 operator-
  3. 乘法 mult() 。这种矢量分量直接相乘的运算通常用于颜色的计算,在三维空间中并无特殊的物理意义。
  4. 点乘 dot() ,其中 的夹角。点乘的结果是一个标量,物理含义是 方向上的投影。
  5. 叉乘 operator% ,叉乘的结果是一个矢量,物理意义是垂直于 的矢量(按 顺序右手螺旋),其长度为 构成的平行四边形面积。
  6. 矢量乘以标量 operator*
  7. 归一化 norm() ,其物理意义是保持矢量方向不变,将其长度缩放为单位长度 1。

射线

通过定义射线的出射点 和出射方向 来定义射线:

struct Ray { Vec o, d; Ray(Vec o_, Vec d_) : o(o_), d(d_) {} };

定义场景

如前文 SmallPT 的渲染效果图所示,整个场景由一个房间,一个光源和两个球体组成。为了简单,SmallPT 将场景全部用球体来表示,房间的墙壁就用直径极大(使观者感受不到墙壁是弯曲的)的球体来表示。

球体对象的表示

  1. 首先枚举出三种表面特性:散射面——用于墙壁,镜面——用于左侧的镜面小球,折射面——用于右侧的玻璃小球。
  2. 定义球体,内容包括:
    1. 数值类型成员:半径
    2. 矢量类型成员:圆心位置 ,表面发光强度(颜色) ,表面散射颜色
    3. 表面特性
enum Refl_t { DIFF, SPEC, REFR };  // material types, used in radiance()
struct Sphere {
  double rad;       // radius
  Vec p, e, c;      // position, emission, color
  Refl_t refl;      // reflection type (DIFFuse, SPECular, REFRactive)
  Sphere(double rad_, Vec p_, Vec e_, Vec c_, Refl_t refl_):
  rad(rad_), p(p_), e(e_), c(c_), refl(refl_) {}
  // ...
};

射线与球体相交

Sphere 上定义了 intersect 方法,以求取给定的射线与球体是不是相交。如果相交则返回射线原点与交点的距离。

double intersect(const Ray &r) const { // returns distance, 0 if nohit
  Vec op = p-r.o; // Solve t^2*d.d + 2*t*(o-p).d + (o-p).(o-p)-R^2 = 0
  double t, eps=1e-4, b=op.dot(r.d), det=b*b-op.dot(op)+rad*rad;
  if (det<0) return 0; else det=sqrt(det);
    return (t=b-det)>eps ? t : ((t=b+det)>eps ? t : 0);
}

注意,下面为了清楚,我们使用大写字母为矢量,小写字母为标量来进行推导。我们需要求解的是射线的原点 与交点 的距离 ,而条件是:

  1. 点在射线上,即
  2. 点在球面上,即 与球心 的距离等于球的半径 。即 ,也就是

将 (1) 中关于 的表达式代入 (2),得到:

展开得到:

这已经是一个一元二次方程,自变量 是未知的。所有矢量经过平方或点积后都成为了标量,可以直接根据一元二次方程 的求根公式:

计算出根

  1. 一元二次方程可能没有实根的,此时射线与球体不相交。这对应着代码中 det<0 的情况,直接返回 0。
  2. 一元二次方程可能有两个实根,此时射线(所在的直线)与球体有两个交点。我们需要取的是最小的正根。如果另一个根 (对应交点为 )大于 ,表明是左图的情况;如果 小于等于 0,表明是右图的情况,这条光线是在介质内的折射光线。

使用球体表示场景

代码定义了一个 spheres 数组来表达整个场景,每一个球体分别是:

  1. 左侧墙体,红色;墙体的半径都很大,观者感知不到曲率;墙体的反射类型都是散射,但是颜色有所不同。
  2. 右侧墙体,蓝色。
  3. 后面墙体,灰色。
  4. 前面墙体,黑色。
  5. 底部墙体,灰色。
  6. 顶部墙体,灰色。
  7. 左侧球体,镜面材质。
  8. 右侧球体,透明玻璃材质,光线可能折射进入玻璃中继续传播。
  9. 顶部的光源,发射出强度为 12 的白光。
Sphere spheres[] = { //Scene: radius, position, emission, color, material
  Sphere(1e5, Vec( 1e5+1,40.8,81.6), Vec(),Vec(.75,.25,.25),DIFF),//Left
  Sphere(1e5, Vec(-1e5+99,40.8,81.6),Vec(),Vec(.25,.25,.75),DIFF),//Rght
  Sphere(1e5, Vec(50,40.8, 1e5),     Vec(),Vec(.75,.75,.75),DIFF),//Back
  Sphere(1e5, Vec(50,40.8,-1e5+170), Vec(),Vec(),           DIFF),//Frnt
  Sphere(1e5, Vec(50, 1e5, 81.6),    Vec(),Vec(.75,.75,.75),DIFF),//Botm
  Sphere(1e5, Vec(50,-1e5+81.6,81.6),Vec(),Vec(.75,.75,.75),DIFF),//Top
  Sphere(16.5,Vec(27,16.5,47),       Vec(),Vec(1,1,1)*.999, SPEC),//Mirr
  Sphere(16.5,Vec(73,16.5,78),       Vec(),Vec(1,1,1)*.999, REFR),//Glas
  Sphere(600, Vec(50,681.6-.27,81.6),Vec(12,12,12),  Vec(), DIFF) //Lite
};

可以看出,坐标轴的原点位于盒子的左面、后面和底面的交点附近。宽度 100 左右,高度 80 左右,深度 170 左右。

准备渲染

在真正开始渲染之前,还需要做一些准备工作。

确定相机和输出图像

先跳过 clamptoIntradiance 这几个函数,直接来看 main 函数。

int main(int argc, char *argv[]){
  int w=1024, h=768, samps = argc==2 ? atoi(argv[1])/4 : 30;
  Ray cam(Vec(50,52,295.6), Vec(0,-0.042612,-1).norm()); // cam pos, dir
  Vec cx=Vec(w*.5135/h), cy=(cx%cam.d).norm()*.5135, r, *c=new Vec[w*h];
  for (int y=0; y<h; y++){                       // Loop over image rows
    fprintf(stderr,"\rRendering (%d spp) %5.2f%%",samps*4,100.*y/(h-1));
    for (unsigned short x=0, Xi[3]={0,0,(unsigned short)(y*y*y)}; x<w; x++)
      // ... 暂时先省略
  }
  FILE *f = fopen("image.ppm", "w");         // Write image to PPM file.
  fprintf(f, "P3\n%d %d\n%d\n", w, h, 255);
  for (int i=0; i<w*h; i++)
    fprintf(f,"%d %d %d ", toInt(c[i].x), toInt(c[i].y), toInt(c[i].z));
}

首先,定义需要渲染图像的宽度(1024),高度(768),对每个像素的采样次数(命令行参数传入,或 120 次)。注意,变量 samps 的值是采样次数的 1/4,在这个例子中,最终渲染在图像上的每一个像素,又会拆分成 4 个子像素进行追踪和采样,而 samps 的值是子像素的采样次数。

然后,使用射线类的变量 cam 来表示相机,射线的原点 cam.o 即是相机的位置,而射线的方向 cam.d 是相机朝向的方向。由于相机本身的位置只是一个点,所以需要把渲染出的图像视为在屏幕前方(亦或是后方,参考一下眼球和相机的构造)不远处的一块矩形幕布,抵达相机的光线穿透幕布时会在与幕布的交点 处留下颜色,这样就在幕布上投影了。只要幕布的分辨率和宽高比不变,其距离相机的远近与最终结果是无关的,所以程序直接设定幕布与相机的距离是 1,设定幕布本身的高度为 0.5135(配合像素尺寸的宽高比,其实这里已经暗含了相机的水平和垂直视场角的信息)。然后,求取长度等于幕布真实宽度 0.5135*w/h 的水平矢量 cx(平行于 X 轴),求取长度等于幕布真实高度 0.5135 的垂直矢量 cy(通过 cxcam.d 叉乘而来)。

再接着,生成长度为 w*hVec 类型数组 c 备用。这个数组的作用是存储渲染的结果。

最后,遍历宽度和高度,对每一个像素进行渲染,并将渲染得到值写入到数组 c 中,并将其输出为 PPM 格式的图片,以便打开查看渲染效果。

PPM 是一种基于文本的图片格式,能够帮助你避免考虑图片文件编码问题,轻易地按像素输出图片。比如以下文本内容就是一张 4x4 的图片。

P3
# feep.ppm
4 4
15
 0  0  0    0  0  0    0  0  0   15  0 15
 0  0  0    0 15  7    0  0  0    0  0  0
 0  0  0    0  0  0    0 15  7    0  0  0
15  0 15    0  0  0    0  0  0    0  0  0

总结一下各个变量的含义:

  1. wh:宽度和高度像素数。
  2. samps:每个像素采样次数的 1/4。
  3. cam:相机描述。
  4. cxcy:长度与幕布真实尺寸相同的水平、垂直矢量。
  5. c:按像素存储渲染结果的数组。

子像素采样

接下来,我们看一下如何计算每个像素的值的(即之前省略的部分)。对每一个像素,都将其拆分为 2x2 一共 4 个子像素。一个当然的想法是,以子像素的中心点 作为射线的出发点开始追踪,但是由于每个子像素都会进行 samps 次光追取均值,所以这里就引入了一次随机过程:以子像素的中心点,在一个像素大小的范围内进行一次随机采样,并以采样到的点 为射线的出发点,进行一次光追。对每个子像素完成 samps 次光追,依次以 ……为出发点,最后求均值。

而且,为了使渲染的质量更高一些(达到相同渲染质量所需的采样次数更小一些),这里对随机过程进行了优化:对随机数进行了一次 tent 滤波。此滤波函数将 区间映射到了 ,当 为随机的值时, 的分布更向中点值 集中(从图中可以看出)。

然后,拿到经过滤波后的随机数 ,计算 在整个画布中的归一化坐标(假设成为 ,如 nx=(sx+0.5+dx)/2+x)/w-0.5),再乘以之前求得的画布真实尺寸矢量 ,加上相机本身的方向矢量 ,就得到了最终从相机原点射向 的矢量 ,即代码中的 d

for (int sy=0, i=(h-y-1)*w+x; sy<2; sy++)     // 2x2 subpixel rows
    for (int sx=0; sx<2; sx++, r=Vec()){        // 2x2 subpixel cols
        for (int s=0; s<samps; s++){
            double r1=2*erand48(Xi), dx=r1<1 ? sqrt(r1)-1: 1-sqrt(2-r1);
            double r2=2*erand48(Xi), dy=r2<1 ? sqrt(r2)-1: 1-sqrt(2-r2);
            Vec d = cx*( ( (sx+.5 + dx)/2 + x)/w - .5) +
            cy*( ( (sy+.5 + dy)/2 + y)/h - .5) + cam.d;
            r = r + radiance(Ray(cam.o+d*140,d.norm()),0,Xi)*(1./samps);
        } // Camera rays are pushed ^^^^^ forward to start in interior
        c[i] = c[i] + Vec(clamp(r.x),clamp(r.y),clamp(r.z))*.25;
    }

最后,调用 radiance,沿着穿过此子像素的射线,在 140 倍远(让光追的开始点进到盒子里面,而不会直接被朝前的表面挡掉)的地方开始进行光线追踪算法。进行 samps 次,取均值,再基于 4 个子像素再取一次均值,就得到了这个像素的颜色。

erand48() 函数是 C 标准库提供的随机函数。传入长度为 3 的数组作为随机种子,返回 [0, 1] 区间的双精度浮点数,同时改写传入的随机种子,便于下一次调用 erand() 时传入(以获取一个不同的结果)。

总结一下各个变量的含义:

  • i:为当前像素在 c 中的索引。
  • r:通过光追计算出的子像素的颜色。
  • sxsy:子像素索引,为 0 或 1。
  • r1r2:滤波用自变量,在 [0, 2] 区间随机取得。
  • dxdy:滤波后的随机自变量,分布在 [-1, 1] 区间内。
  • d :从相机原点指向子像素位置的矢量。

光线追踪

讲了这么多,终于到正餐了。函数 radiance() 即进行了一次光线追踪。

Vec radiance(const Ray &r, int depth, unsigned short *Xi){
    double t;                               // distance to intersection
    int id=0;                               // id of intersected object
    if (!intersect(r, t, id)) return Vec(); // if miss, return black
    const Sphere &obj = spheres[id];        // the hit object
    Vec x=r.o+r.d*t, n=(x-obj.p).norm(), nl=n.dot(r.d)<0?n:n*-1, f=obj.c;
    double p = f.x>f.y && f.x>f.z ? f.x : f.y>f.z ? f.y : f.z; // max refl
    if (++depth>5) if (erand48(Xi)<p) f=f*(1/p); else return obj.e; //R.R.
    if (obj.refl == DIFF){                  // Ideal DIFFUSE reflection
      // ...
    } else if (obj.refl == SPEC)            // Ideal SPECULAR reflection
      // ...
    // ...                                   // Ideal dielectric REFRACTION
}

函数 radiance() 接收三个参数,射线 r,深度 depth 和随机种子 Xi。此函数是一个递归函数,在计算射线颜色时候,遇到反射或折射,会计算出下一次反射或折射的射线,然后递归调用自己求算下一次射线的颜色。深度 depth 标记了反射/折射的次数。

下面看 radiance() 函数的逻辑:

求取交点和递归结束条件

首先,对场景中的所有球体对象,尝试求取射线与之交点。函数 intersect() 负责做这件事:遍历 spheres 数组,依次调用 sphere 对象上的 intersect() 方法,并保留最小的正值 t 和对应球体在 spheres 数组中的索引 i

inline bool intersect(const Ray &r, double &t, int &id){
    double n=sizeof(spheres) /sizeof(Sphere), d, inf=t=1e20;
    for(int i=int(n);i--;) if((d=spheres[i].intersect(r))&&d<t){t=d;id=i;}
    return t<inf;
}

如果没有任何对象与之相交,直接返回黑色(递归的结束条件 1)。

如果检测到有球体与射线相交,首先检查当前深度是否大于了 5(之前已经经过了 5 次反射或折射),如果是,那么遵循一个概率来随机地决定采取以下哪一种方案:

  1. 放弃反射光的贡献,直接返回物体的出射光——虽然大部分时候是 0(递归的结束条件 2)。
  2. 继续追踪光线。

这个概率就是当前物体表面的颜色 RGB 分量中最大的那个分量,归一化后的值。换言之,物体越暗(的颜色值越低),越容易被筛选到第 1 种方案(因为物体暗,环境光反射贡献的值也会小),而物体越亮,越容易被筛选到第二种方案。值得注意的是,当选择第二种方案时,需要把物体的颜色按照概率调高一些 f=f*(1/p),这是因为那些运气不够好,被筛选到 1 方案光线,其反射分量完全地消失了,这会导致最终计算出的颜色的期望(也就是说,即使采样无穷次)与真实值产生偏差。在运气好的 2 方案中调整颜色,可以消除这个偏差。

按照概率进行随机选择是无处不在的,只要采样的次数足够多,那么遵照概率选择计算得到的均值就能够逼近真实值。

接着,如果深度小于等于 5,那么不管物体的颜色值如何,都会正常地递归地进行光线追踪。在此之前,还需要预先求取一些变量的值,比如法线 nl 等,便于后续使用。

总结一下各变量的意义:

  • t:交点距离相机原点的距离。
  • id:相交的球体在 spheres 数组中的索引。
  • obj:相交的球体 Sphere 对象。
  • x:交点的位置。
  • n:球体在交点处的归一化的法线(从球心射向表面)。
  • nl:与反射/折射上下文契合的归一化的法线:如果射线在球体外部,nln 相同;如果射线在球体内部(折射),nln 相反。

散射(漫反射)

追踪光线,当代表光线的射线与球体相交时,根据球体表面的特性,进行不同的操作(代码中省略的部分)。

如果物体表面是漫反射,将随机选取一个角度取归一化的反射光线

  1. 确定互相正交的三个归一化矢量 ,其中 为反射面的法线。
  2. 在区间 中随机确定一个值 ,并使 作为反射光线 在法线 方向上投影的长度,使 也就是 成为反射光线在表面上投影的长度。
  3. 在区间 中随机确定一个角度值 ,使之成为反射光点 在反射表面(即 平面)的投影与 轴的交角。
  4. 极坐标公式转笛卡尔坐标, 计算出在半球面的随机矢量,再归一化得到反射矢量

最后,递归地调用 radiance() 函数,将反射光线作为参数传入,传入已自增过一次的深度和随机种子。其结果乘以表面的颜色,再加上球体表面的发射光颜色,作为结果返回。这就是此光追算法对散射表面的处理。

if (obj.refl == DIFF){                  // Ideal DIFFUSE reflection
    double r1=2*M_PI*erand48(Xi), r2=erand48(Xi), r2s=sqrt(r2);
    Vec w=nl, u=((fabs(w.x)>.1?Vec(0,1):Vec(1))%w).norm(), v=w%u;
    Vec d = (u*cos(r1)*r2s + v*sin(r1)*r2s + w*sqrt(1-r2)).norm();
    return obj.e + f.mult(radiance(Ray(x,d),depth,Xi));

总结一下各变量的含义:

  • uvw:互相正交的三个归一化矢量,w 即为法向量。
  • r1:反射光线在反射表面与 u 的交角。
  • r2r2sr2s 为反射光线在反射表面投影的长度,r2 为此值的平方。
  • d:随机求取的反射方向。

镜面反射

镜面反射比较简单,因为对于确定的入射光线,反射的方向也是确定的,因此没有随机过程,直接取镜面反射的方向继续进行光追。

镜面反射的求法比较简单,不过也记录一下吧:如下图对称的两条射线 ,而法线为 (法线是归一化的)。此时有 ,求解 。在代码中, 就是 -r.d,带入得到 r.d-n*2*n.dot(r.d),即反射光线的。由于 r.d 也已经是归一化的了,所以求取得到的结果也一定是归一化的。

代码如下:

} else if (obj.refl == SPEC)            // Ideal SPECULAR reflection
    return obj.e + f.mult(radiance(Ray(x,r.d-n*2*n.dot(r.d)),depth,Xi));

折射

折射是比较复杂的。不同的物体有不同的折射率,空气的折射率为 1,玻璃的折射率为 1.5。根据折射定理:光线从折射率为 的介质折射到折射率为 的介质,折射角满足(此定理可以从更一般的麦克斯韦电磁方程组推导出来,厉害厉害):

全反射

首先需要考虑的是全反射现象,即光线从光密介质(玻璃)向光疏介质(空气)传播时(上图右半部分), 大于临界角(使得 为 90° 的 ),以至于不存在 满足上述折射定理时,所有光都被反射的现象,即:

两边平方并作简单变换,得到:

由于 nnt ,而 ddn ,代入上式,得到全反射的条件即是 1-nnt*nnt*(1-ddn*ddn))<0。满足全反射条件时,镜面反射的方式进行处理。

Ray reflRay(x, r.d-n*2*n.dot(r.d));     // Ideal dielectric REFRACTION
bool into = n.dot(nl)>0;                // Ray from outside going in?
double nc=1, nt=1.5, nnt=into?nc/nt:nt/nc, ddn=r.d.dot(nl), cos2t;
if ((cos2t=1-nnt*nnt*(1-ddn*ddn))<0)    // Total internal reflection
    return obj.e + f.mult(radiance(reflRay,depth,Xi));

总结一下各变量的含义:

  • reflRay:镜面反射方向,与上一节中的求法完全一致。
  • into:由空气(试图)进入玻璃则为 true,由玻璃(试图)进入空气则为 false
  • ncnt:空气和玻璃的折射率。
  • nnt 为折射率比值,当追踪的射线是从空气进入玻璃时,nnt1.0/1.5,当射线是从玻璃进入空气时,nnt1.5
  • ddn:即 的值,由 点乘而来。

正常折射

所谓正常折射,即是光线一部分镜面反射,一部分折射进入另一种介质。此时,我们需要:

  1. 计算折射光线矢量
  2. 计算有多少比例的光线被反射,有多少比例的光线被折射。
  3. 根据条件,选择以随机或非随机的形式,递归地进行光线追踪。

首先计算折射光线矢量

首先,假设入射光线在表面平面的投影矢量(并归一化)为 已知,折射角 也已知,我们可以得到 为:

其中 就是入射光线平面上的投影矢量(因为入射和折射在同一个平面内)归一化得到,而此投影矢量可以使用 减去 在法线上的投影 (注意这里点积是个负数)来得出,因此:

同时 的正弦和余弦值也可以通过折射定律求得:

全部代入,可得:

按照上述公式,将变量代入,即可计算出折射光线归一化矢量 tdir

Vec tdir = (r.d*nnt - n*((into?1:-1)*(ddn*nnt+sqrt(cos2t)))).norm();

接下来,计算有多少比例的光线被反射,有多少比例的光线被折射。根据电动力学的一些理论,入射光线(普通的非偏振光)被反射/折射的比例,与入射角 ,两种介质的折射率都有关系。当入射角等于 0(即垂直入射到表面)时,被反射的比例 为:

而当入射角为 时的反射比例 为:

最后,基于当前光追的深度:

  1. 如果深度小于等于 2,那么分别递归地追踪反射光和折射光,将两者对颜色的贡献再加上物体本身的发光,作为结果返回。
  2. 如果深度大于 2,那么依据折射和反射光线的占比来进行概率随机,选择追踪反射光或折射光中的一支。换言之,如果反射光占比高,那么就有较大的可能性随机到反射光线,反之亦然。注意,这时由于我们放弃了另一种可能性,会导致结果的期望产生偏差,所以需要进行一次修正。

对玻璃材质,前几次反射、折射对最终的结果的贡献比较大,所以选择都进行追踪。但是这样做一下子使得某些追踪的采样次数发散为了原来的 4 倍,如果深度改为 4,可能就是 16 倍。所以代码把发散深度限制在 2 这样一个比较小的数值,也算是在效果和性能间达到一个平衡。

代码如下:

double a=nt-nc, b=nt+nc, R0=a*a/(b*b), c = 1-(into?-ddn:tdir.dot(n));
double Re=R0+(1-R0)*c*c*c*c*c,Tr=1-Re,P=.25+.5*Re,RP=Re/P,TP=Tr/(1-P);
return obj.e + f.mult(depth>2 ? (erand48(Xi)<P ?   // Russian roulette
    radiance(reflRay,depth,Xi)*RP : radiance(Ray(x,tdir),depth,Xi)*TP) :
    radiance(reflRay,depth,Xi)*Re+radiance(Ray(x,tdir),depth,Xi)*Tr);

最后解释一下各个变量的含义:

  • ab:用以计算 的临时变量。
  • R0:入射角为 0 时反射光的占比。
  • c:即
  • Re:当前入射角下反射光的占比。
  • Tr:折射光的占比。
  • RP:随机到反射光时,对结果的修正的因子。
  • TP:随机到折射光时,对结果的修正的因子。

这样,光追算法就全部完成了。

感想

一直觉得光线追踪是非常高端的存在,因为没有基础,想了解也不知从何看起。一个偶然的机会发现到这个 demo,觉得挺有意思,就拿过来研究了。结果发现看起来还是比较吃力,好在另外一个老外的解读 PPT 帮了很大的忙。

把学习过的东西消化了,再重新写出来是件很折磨的事情,但收获也不小。每一个公式一步一步推导过来,对一些地方「为什么这么做」也有了不一样的体会。数学还是挺重要的,而且能够给人带来很强的成就感。