从零开始编写光线追踪渲染器 I

前言

In computer graphics, ray tracing is a rendering technique for generating an image by tracing the path of light as pixels in an image plane and simulating the effects of its encounters with virtual objects. The technique is capable of producing a very high degree of visual realism, usually higher than that of typical scanline rendering methods, but at a greater computational cost. ——wikipedia

光线追踪概述

光线追踪 Ray Tracing 是一种渲染算法,准确地说它应该叫路径追踪 Path Tracing 。它通过追踪入射到人眼或者摄像机的光线来决定这道光线通过的像素点是什么颜色。与光栅化 rasterization 不同,光栅化将3维空间内的物体投影到屏幕上,逐行或者逐列扫描像素,决定每个像素的颜色,通过既有现象的模拟,来实现光照、反射、散射等现象的表现情况。光线追踪算法的渲染方式从一开始就是契合物理规律的,因此生成的图像更加真实。但是缺点在于计算量过于巨大,主要在计算光线的相交和反射、折射。

下节开始将会介绍如何从零开始实现一个光线追踪渲染器。看过 'Ray Tracing In The Weekend' 的同学可能会更加喜欢他那种渐进式的面向代码的叙述方式。本系列只是对本人某个阶段的渲染器完成总结,因此我会

1. 生成图像

我们选择一种比较简单的图像格式——PNM格式,作为输出,类似于位图,它使用正整数保存每个像素点的RGB信息,如下展示了一个PNM图像的ascii编码:

a.ppm文件内容:

P3
400 300
255
149    192    255
149    192    255
149    191    255
149    191    255
149    191    255
148    191    255
148    191    255
148    191    255
148    191    255
148    191    255
......

第一行P3是所有PNM图像的头,第二行400 300描述了这个图像的横向和纵向的尺寸。255描述了每个像素的颜色分量的最大值,这里我们选择255作为最大值。下面的每一行则是从左上角开始的每个像素点的RGB分量,a.ppm中应该有400*300即120000行信息,很显然这是非常低效的储存方式。

下面的代码能够写出一个ppm图像,其中 Color 类可以简单理解成一个包含RGB三个颜色分量的struct,后续会有详细介绍。

#include "ppm.h"

using namespace std;

int WriteRGBImg(const char* path, int nx, int ny, Color *pix)
{
    std::ofstream fout(path);
    fout << "P3\n" << nx << " " << ny << "\n255\n";
    for (int i = 0; i < nx * ny; ++i)
    {
        fout << (int) pix[i][0] << "\t" << (int) pix[i][1] << "\t" << (int) pix[i][2] << "\n";
    }
    fout.flush();
    return 0;
}

<center>图1.1 写入ppm文件代码图</center>

2. 三维世界的数学模型

现阶段我们只打算在世界中引入球体。

2.1 三维向量

可以首先考虑一下实现一个三维世界中的光线追踪渲染器需要表示哪些概念,例如点、线、面、颜色。他们无一例外都可以用三维的向量或者三维向量的组合来表示。因此一个良好完备的三维向量定义是整个项目的重要基础设施,下图给出了我的Vector3定义。

/**
 * Common 3-d vector definition
 */
class Vector3 
{
public:
    Vector3() = default;
    Vector3(double a, double b, double c);
    double e[3];
    inline double& operator[](int i) { return e[i]; }
    inline Vector3& operator=(const Vector3& vec);
    inline Vector3 operator-() const;
    friend inline Vector3 operator+(const Vector3& vec1, const Vector3& vec2);
    friend inline Vector3 operator-(const Vector3& vec1, const Vector3& vec2);
    inline Vector3 operator*(double k) const;
    inline Vector3 operator*(const Vector3& vec);
    inline Vector3 operator/(double k) const;
    inline Vector3 operator/(const Vector3& vec);
    inline Vector3& operator+=(const Vector3& vec);
    inline Vector3& operator-=(const Vector3& vec);
    inline Vector3& operator*=(double k);
    inline Vector3& operator/=(double k);
    inline double Dot(const Vector3 &vec) const;
    inline Vector3 Cross(const Vector3 &vec) const;
    inline Vector3 UnitVector();
    inline double Length() const;
    inline bool operator!=(const Vector3& v);
    inline bool Parallel(const Vector3 &v) const;
    friend std::ostream& operator<<(std::ostream& os, const Vector3& v);
};

<center>图2.1 vector3定义代码</center>

需要注意的是,尽量引导编译器将向量的运算inline,因为渲染器几乎所有的计算都涉及向量的运算,inline可以提升渲染速度。Vector3 的具体实现这里不在话下。

2.2 点和线段

一个三维向量就可以表示一个点,(下文的所有向量用大写字母表示,实数用小写字母表示,点乘用·表示,叉乘用×表示)。

Vector3 p{0, 0, 0};

上面的代码表示一个在(0, 0, 0)的点。

空间中的一条直线可以表示为如下。k是一个参数,这是一个直线上的点p关于k的参数方程。

l = A + k·B

我的光纤定义如下:

/*
 * described by P = A + k·B
 * P is any point on the ray.
 * A is the origin point. B is the direction.
 */
class Ray
{
public:
    Ray() = default;
    Ray(const Vector3& A, const Vector3& B) : A(A), B(B) { }
    Ray(const Vector3& A, const Vector3& B, const Ray& previous): Ray(A, B)
    {
        refracted = previous.refracted;
    }
    Vector3 Origin() const { return A; }
    Vector3 Direction() const { return B; }
    Vector3 P(double k) const { return A + B * k; }
    Vector3 operator[](double k) const { return P(k); }
    bool refracted = false;
protected:
    Vector3 A, B;
};

<center>图2.3 光线定义代码</center>
注意这里的光线定义不是真实世界的光线,而是从摄像机中发出的“追踪光线”。

2.3 球体

对于球体的描述非常简单,只需要圆心和半径就知道了这个圆的所有信息。我的圆的定义如下:

class Sphere : public Object
{
public:
    Sphere(Vector3 center, double radius, const Material& m) : center(center), radius(radius), Object(m) { }
    bool IsHit(const Ray& r, double minT, double maxT, HitRecord& hitRec) override;
    Vector3 Center() { return center; }
    double Radius() { return radius; }
protected:
    Vector3 center;
    double radius;
};

3. 光线追踪的编程模型

首先描述光线追踪的编程模型,以便后续物理模型叙述的展开。

3.1 物体

世界中的所有物体都需要计算与光线的相交,并且需要给出光线的交点和交点的切面信息,这是现阶段所有光线算法的所需的所有信息。计算交点的工作似乎放在物体的定义中是更加合适的,因为光线的信息简单的多,而计算交点信息非常依赖物体的信息。我的物体定义如下:


/**
 * stores information about at which point a ray hits
 * an object and what the t-param is in the ray.
 */
struct HitRecord
{
    HitRecord() = default;
    HitRecord(
            double t, const Vector3& p, const Vector3 normal, const Material& m
    ) : t(t), p(p), normal(normal), scatterInfos(scatterInfos) { }
    double t;
    Vector3 p, normal;
    std::vector<ScatterInfo> scatterInfos;
};

/**
 * common object definition.
 */
class Object
{
public:
    Object(const Material& m): material(m) {   }
    // decide whether the ray r hits this object.
    virtual bool IsHit(const Ray& r, double minT, double maxT, HitRecord& hitRec) = 0;
    const Material& material;
};

HitRecord 描述了光线与物体交点的位置、切面的法向量以及下一步可能会衍生的多条光线及其占比。多条光线可能比较难以理解,想象一个玻璃材质的物体,来自某个交点的光线可能是折射出来的光线,也可能是外界反射的光线,这两种光线叠加在一起。物体中包含了一个 Material 类成员,表示这个物体表面的材质。材质将会在 3.3 小节详细描述。

3.2 物体群

我也考虑将物体群继承自物体,因为一条光线与物体群只会有至多一个交点,这与单个物体的行为是一致的。并且我希望多个物体可以组合为一个物体,比如玻璃泡可以由一个相对折射率为n的玻璃球和一个相对折射路为1/n的玻璃球组合而成。但是这里我们更倾向于将物体群理解成物体的集合,它承担着相对于物体而言更多的职责,包括根据材质计算光线的下一步走向,如果希望将 Object 组合在一起,可以将。
我的物体群定义如下:

class Objects
{
public:
    virtual bool IsHit(const Ray& r, double minT, double maxT, HitRecord& hitRec);
    void Add(Object* hittable) { objects.push_back(hittable); }
    void Release() { for (auto* p: objects) delete p; }
protected:
    std::vector<Object*> objects;
};

3.3 渲染

渲染 render 的含义是根据模型生成图像的过程,更细地说就是选择每个像素点的颜色。光线追踪的算法核心是追踪某个视角发往某个像素点的光线的路径,当光线到达光源时决定这条光路上光线的颜色。

3.3.1 摄像机

提到渲染就要首先提到摄像机的概念。

3.4 材质

材质决定了光线在接触到物体之后的行为,材质定义如下:

class Material
{
public:
    virtual bool Scatter(
            const Ray &r, HitRecord &hr) const { hr.scatterInfos = {}; };
};

目前我们关注几个简单的材质种类。

1) 漫反射材质

漫反射材质的某个点反射进入摄像机的光线来自多个无规律的方向。因此我们以交点为起点的单位法向量终点为圆心,作一个半径为1的圆,并在园内随机取一点 P 作为入射光线方向。

clipboard.png

图3.1 漫反射材质示意图

漫反射材质还会按比例吸收颜色光,下面是完整的漫反射材质定义。

class Lambertian : public Material
{
public:
    Lambertian(const Vector3& attenuation) : attenuation(attenuation) { }
    bool Scatter(
            const Ray& r, HitRecord& hr) const override;

protected:
    Vector3 attenuation;
};

Vector3 RandomUnitVector()
{
    Vector3 p;
    do
    {
        p = 2.f * Vector3((double) drand48(), (double) drand48(), (double) drand48()) - Vector3(1, 1, 1);
    } while (p.Length() >= 1);
    return p;
}

bool Lambertian::Scatter(const Ray &r, HitRecord &hr) const
{
    Vector3 dir = hr.normal + RandomUnitVector();
    hr.scatterInfos.push_back({
                                      attenuation,
                                      Ray(hr.p, dir)
                              });
    return true;
}

我们使用 drand48() 作为随机数生成器,不同的随机数序列会有不同的效果, drand48() 是unix平台会提供的快速随机数实现,它能够生成0-1之间的双精度浮点数。 Windows 平台上可能没有定义,直接引用下面的头文件即可:

/**
 * drand48.h
 */

#include <stdlib.h>  
  
#define m 0x100000000LL  
#define c 0xB16  
#define a 0x5DEECE66DLL  
  
static unsigned long long seed = 1;  
  
double drand48(void)  
{  
    seed = (a * seed + c) & 0xFFFFFFFFFFFFLL;  
    unsigned int x = seed >> 16;  
    return  ((double)x / (double)m);  
      
}  
  
void srand48(unsigned int i)  
{  
    seed  = (((long long int)i) << 16) | rand();  
}  
  

根据这个算法,我们会得到类似下图的图像。

clipboard.png

显然这是由于漫反射的每个像素点只采样了一次导致的,提高采样数可以得到更加真实的图像。我们为每个像素点采样100次,可以得到如下的结果:

clipboard.png

……未完


mist14
45 声望8 粉丝

想养喵