1 - 一个周末搞定光追Part1

开始学习大名鼎鼎的光追三部曲系列中的:Ray Tracing in One Weekend!希望我能坚持下去吧。

简介

学完这三本书后,我们将得到一个用C++写的离线路径追踪渲染器,所以好好干吧!

输出PPM格式图片

PPM格式简介

这里使用.ppm格式的图片作为渲染器的输出内容,因为它的格式很简单,仅需声明P3标准(颜色用ASCII表示)、图片的宽高以及最大颜色的数值后,按行填入每个像素的RGB值即可。

使用C++输出

然后就能用C++输出PPM格式的图片了:

#include <iostream>
#include <format>

int main()
{
    // 图片设定
    int imgWidth = 256;
    int imgHeight = 256;

    // 渲染
    std::cout << std::format("P3\n{} {}\n255\n", imgWidth, imgHeight);
    for (int j = 0; j < imgHeight; ++j) {
        for (int i = 0; i < imgWidth; ++i) {
            // 生成[0, 1]间的RGB值
            auto r = static_cast<double>(i) / (imgWidth - 1);
            auto g = static_cast<double>(j) / (imgHeight - 1);
            auto b = 0.0;
			
            // 映射到[0, 255]
            int ir = static_cast<int>(255.999 * r);
            int ig = static_cast<int>(255.999 * g);
            int ib = static_cast<int>(255.999 * b);

            std::cout << std::format("{} {} {}\n", ir, ig, ib);
        }
    }

    return 0;
}

上述代码将输出一个长宽均为256像素的图片,其中从左到右越来越”红”,从上到下越来越“黄”。接下来使用管道运算符将输出写入到test.ppm文件中:

.\LearnRayTracing.exe > test.ppm

可以用VsCode中的Simple PPM Viewer插件查看该图片,或者去网上找其他途径查看,最终图片如下:

Vec3类

手写Vec3类

几乎所有图形程序都有存储颜色和集合向量的类,它们大多数是4D的(3D位置+齐次坐标;RGBA等)。这里用3D就够了,手写的Vec3类如下:

#pragma once

#include <cmath>
#include <iostream>
#include <format>

class Vec3 {
public:
	double e[3];

	Vec3() : e{0, 0, 0} {}
	Vec3(double e0, double e1, double e2) : e{e0, e1, e2} {}

	// 运算符
	Vec3 operator-() const { return Vec3(-e[0], -e[1], -e[2]); }
	double operator[] (int i) const { return e[i]; }
	double& operator[] (int i) { return e[i]; }
	Vec3& operator+= (const Vec3& v)
	{
		e[0] += v.e[0];
		e[1] += v.e[1];
		e[2] += v.e[2];
		return *this;
	}
	Vec3& operator*= (double t)
	{
		e[0] *= t;
		e[1] *= t;
		e[2] *= t;
		return *this;
	}
	Vec3& operator/= (double t)
	{
		return *this *= 1 / t;
	}

	// 获取相关属性
	double x() const { return e[0]; }
	double y() const { return e[1]; }
	double z() const { return e[2]; }
	double lengthSquared() const { return e[0] * e[0] + e[1] * e[1] + e[2] * e[2]; }
	double length() const { return std::sqrt(lengthSquared()); }
};

// 使用Point3作为Vec3别名, 用于表示几何信息
using Point3 = Vec3;

// 一些工具函数
inline std::ostream& operator<<(std::ostream& out, const Vec3& v) {
	return out << v.e[0] << ' ' << v.e[1] << ' ' << v.e[2];
}

inline Vec3 operator+(const Vec3& u, const Vec3& v) 
{
	return Vec3(u.e[0] + v.e[0], u.e[1] + v.e[1], u.e[2] + v.e[2]);
}

inline Vec3 operator-(const Vec3& u, const Vec3& v) 
{
	return Vec3(u.e[0] - v.e[0], u.e[1] - v.e[1], u.e[2] - v.e[2]);
}

inline Vec3 operator*(const Vec3& u, const Vec3& v) 
{
	return Vec3(u.e[0] * v.e[0], u.e[1] * v.e[1], u.e[2] * v.e[2]);
}

inline Vec3 operator*(double t, const Vec3& v) 
{
	return Vec3(t * v.e[0], t * v.e[1], t * v.e[2]);
}

inline Vec3 operator*(const Vec3& v, double t) 
{
	return t * v;
}

inline Vec3 operator/(const Vec3& v, double t) 
{
	return (1 / t) * v;
}

inline double dot(const Vec3& u, const Vec3& v) 
{
	return u.e[0] * v.e[0]
		+ u.e[1] * v.e[1]
		+ u.e[2] * v.e[2];
}

inline Vec3 cross(const Vec3& u, const Vec3& v) 
{
	return Vec3(u.e[1] * v.e[2] - u.e[2] * v.e[1],
				u.e[2] * v.e[0] - u.e[0] * v.e[2],
				u.e[0] * v.e[1] - u.e[1] * v.e[0]);
}

inline Vec3 unitVector(const Vec3& v) 
{
	return v / v.length();
}

颜色相关工具函数

接下来创建一个新文件color.hpp,用于定义和存储颜色相关的工具函数:

#pragma once

#include "vec3.hpp"
#include <iostream>

using Color = Vec3;

inline void writeColor(std::ostream& out, const Color& pixel_color) {
    auto r = pixel_color.x();
    auto g = pixel_color.y();
    auto b = pixel_color.z();

    // 将属于[0, 1]的RGB分量变换到[0, 255]上
    int rByte = static_cast<int>(255.999 * r);
    int gByte = static_cast<int>(255.999 * g);
    int bByte = static_cast<int>(255.999 * b);

    // 写到输出流out中
    out << rByte << ' ' << gByte << ' ' << bByte << '\n';
}

然后就能修改之前写的main.cpp了:

#include "./util/vec3.hpp"
#include "./util/color.hpp"

#include <iostream>
#include <format>

int main()
{
	// 图片设定
	int imgWidth = 256;
	int imgHeight = 256;

	// 渲染
	std::cout << std::format("P3\n{} {}\n255\n", imgWidth, imgHeight);
	for (int j = 0; j < imgHeight; ++j) {
		for (int i = 0; i < imgWidth; ++i) {
			Color pixelColor = Color(static_cast<double>(i) / (imgWidth - 1),
									 static_cast<double>(j) / (imgHeight - 1),
									 0);
			writeColor(std::cout, pixelColor);
		}
	}

	return 0;
}

光线, 摄像机和背景

光线类

光线追踪器都有一个光线类,并且可以计算光打到着色点的颜色值。将光线看作一个方程: 其中,是3D射线上的一个点,是光线原点,是光线的方向。参数是一个实数,可通过获得光线上的任意一点。

光线类Ray.hpp的代码如下:

#pragma once

#include "../util/vec3.hpp"

class Ray 
{
public:
	Ray() {}
	Ray(const Point3& origin, const Vec3& direction) : orig(origin), dir(direction) {}

	const Point3& origin() const { return orig; }
	const Vec3& direction() const { return dir; }

	// P(t) = Orig + t*dir
	Point3 at(double t) const {
		return orig + t * dir;
	}

private:
	Point3 orig;
	Vec3 dir;
};

在场景中发射光线

有了光线类,我们就能开始写光线追踪器了。光线追踪器发射光线,将其打到像素上,然后计算这些光线方向上的颜色。包括如下步骤:

  1. 计算从“眼睛”(摄像机)发出打到像素点的光线;
  2. 确认哪些物体与光线相交;
  3. 计算距离最近相交点的颜色;

定义视口和摄像机

输出正方形图不好debug,因为会混淆x和y,这里使用宽高比(aspect)为16:9的图片作为输出。此外还需要准备一个虚拟视口(viewport),视口是在3D空间里的一个矩形,它按网格包含输出图片的各个像素,每个像素均为正方形。

接下来我们使用高度为2的视口,宽高比为16:9,重新写一下图片设定:

// 图片设定
double aspectRadio = 16.0 / 9.0;
int imgWidth = 400;
int imgHeight = static_cast<int>(imgWidth / aspectRadio);
imgHeight = (imgHeight < 1) ? 1 : imgHeight;				// 确保高度至少为1

// 视口设定
double viewportHeight = 2.0;
double viewportWidth = viewportHeight * static_cast<double>(imgWidth) / imgHeight;

注意到这里没有直接用宽高比去计算视口宽度,这是因为aspectRadio是理想值,可能和真正的宽高比有误差。

有了视口以后,就能定义摄像机了。首先是摄像机的中心点,也被称为“眼睛”的位置,光线将在这里发出。从该点至视口中心点的向量和视口表面垂直,且长度最初为单位1,这段距离也被称为 焦距(focal length)

还需要注意的是,世界空间的坐标系和图像空间的不一样,图像空间的坐标系原点在左上角,也就是说两坐标系的y轴正方向是相反的。

如图,视口所在图像空间的原点在左上角,每个像素的原点则为图上的绿点。要想定位到绿点,还需要向量来帮忙。

接下来将摄像机和返回光线颜色的函数ray_color()实现一下:

#include "util/vec3.hpp"
#include "util/color.hpp"
#include "ray/ray.hpp"

#include <iostream>
#include <format>

Color ray_color(const Ray& r)
{
	// TODO: 返回真正的颜色
	return Color(0, 0, 0);
}

int main()
{
	// vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv 数据设置 vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
	// 图片设定
	double aspectRadio = 16.0 / 9.0;
	int imgWidth = 400;
	int imgHeight = static_cast<int>(imgWidth / aspectRadio);
	imgHeight = (imgHeight < 1) ? 1 : imgHeight;				// 确保高度至少为1

	// 相机设定
	double focalLength = 1.0;				// 焦距
	Point3 cameraCenter = Point3(0, 0, 0);	// 相机位置

	// 视口设定
	double viewportHeight = 2.0;
	double viewportWidth = viewportHeight * static_cast<double>(imgWidth) / imgHeight;

	// 4个辅助向量
	Vec3 viewport_u = Vec3(viewportWidth, 0, 0);
	Vec3 viewport_v = Vec3(0, -viewportHeight, 0);
	Vec3 pixel_delta_u = viewport_u / imgWidth;
	Vec3 pixel_delta_v = viewport_v / imgHeight;

	// 计算第一个像素位置
	Point3 viewport_upper_left = cameraCenter - Point3(0, 0, focalLength) - viewport_u / 2 - viewport_v / 2;
	Point3 pixel00_pos = viewport_upper_left + 0.5 * (pixel_delta_u + pixel_delta_v);
	// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 数据设置 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^


	// vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv 渲染 vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
	std::cout << std::format("P3\n{} {}\n255\n", imgWidth, imgHeight);
	for (int j = 0; j < imgHeight; ++j) {
		for (int i = 0; i < imgWidth; ++i) {
			Point3 cur_pixel_center = pixel00_pos + (i * pixel_delta_u) + (j * pixel_delta_v);
			Vec3 ray_direction = unitVector((cur_pixel_center - cameraCenter));
			Ray ray(cameraCenter, ray_direction);
			Color pixel_color = ray_color(ray);

			writeColor(std::cout, pixel_color);
		}
	}
	// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 渲染 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

	return 0;
}

接下来实现ray_color,让其返回随Y轴从蓝到白渐变的颜色。首先需要将属于的Y映射到,然后将其进行线性混合: 这里的初始值是白色,结束值是蓝色,代码如下:

Color ray_color(const Ray& r)
{
	Vec3 dir = r.direction();
	// [-1, 1] -> [0, 1]
	double a = 0.5 * (dir.y() + 1.0);
	// 线性混合
	return (1.0 - a) * Color(1.0, 1.0, 1.0) + a * Color(0.5, 0.7, 1.0);
}

最终结果如下:

添加球体

有了场景后,就能添加物体了。这里添加球体,因为球体和光线求交相对简单点。

光线和球的相交

对于处于原点,半径为的球体定义如下: 如果点在球外,有;如果在球内,有

如果想让球心位置自定义为,那么它的方程就得改写为: 在图形学中,公式各项常常以向量的形式出现,尽量隐藏形如x/y/z之类的东西。因此让点,球心,那么有: 因此就能将方程重写为: 为了求解光线是否与球相交,需要代入光线方程,就得到一个关于的一元二次方程:

对于形如的一元二次方程,有求根公式 对于,如果,说明方程有两个不相等实数根;说明方程有两个相等实数根;说明方程没有实数解。

可以用求根公式解这个方程,其中: 数形结合一下方便理解:

代码部分

接下来通过hit_sphere()函数判定光线是否与球相交,如果相交,返回红色方便调试:

// 光线r是否和以center为球心, radius为半径的球相交
bool hit_sphere(const Point3& center, double radius, const Ray& r)
{
	// oc = 球心C - 光线原点Q
	Vec3 oc = center - r.origin();
	// 求根公式
	double a = dot(r.direction(), r.direction());
	double b = -2.0 * dot(r.direction(), oc);
	double c = dot(oc, oc) - radius * radius;
	// 返回有实数解的结果
	return (b * b - 4 * a * c >= 0);
}

Color ray_color(const Ray& r)
{
	// vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv 球 vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
	if (hit_sphere(Point3(0, 0, -1), 0.5, r))
	{
		return Color(1, 0, 0);
	}
	// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 球 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

	// vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv 背景 vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
	Vec3 dir = r.direction();
	// [-1, 1] -> [0, 1]
	double a = 0.5 * (dir.y() + 1.0);
	// 线性混合
	return (1.0 - a) * Color(1.0, 1.0, 1.0) + a * Color(0.5, 0.7, 1.0);
	// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 背景 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
}

可以发现球显示出来了:

可以发现这就是个圆形,没有立体感。因为还缺着色、反射光、其他球体等内容。除此之外还有一个bug,就是无法分辨球在相机面前还是在相机背后,把z设置为1也会显示出球来,后续将修复这个bug。

表面法线与多个物体

表面法线可视化

首先让我们实现球表面法线的着色,表面法线就是从球心指向相交点的向量:

通常来说,法线都是单位向量,但是否需要为了节省计算,而不对法线向量进行标准化操作,看看以下三个观点:

  1. 能做一次标准化就不要重复做多次;
  2. 在某几个场合下需要用到标准化后的法线;
  3. 可根据法向量的定义简化运算,例如球面法向量只需除以球的半径即可标准化,从而避免平方根运算。

因此还是将所有法线标准化吧。

可视化法线向量不需要任何光线来帮忙,通常将各分量范围在的法向量映射到,然后再映射到RGB上就行了,代码修改如下:

// 返回光线r与以center为球心, radius为半径的球相交的最近点
double hit_sphere(const Point3& center, double radius, const Ray& r)
{
    // oc = 球心C - 光线原点Q
    Vec3 oc = center - r.origin();
    // 求根公式
    double a = dot(r.direction(), r.direction());
    double b = -2.0 * dot(r.direction(), oc);
    double c = dot(oc, oc) - radius * radius;
    double delta = b * b - 4 * a * c;
    // 返回有实数解的结果
    if (delta < 0)
    {
        return -1.0;
    }
    else
    {
        return (-b - std::sqrt(delta)) / (2.0 * a);
    }
}

Color ray_color(const Ray& r)
{
    // vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv 球 vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
    double t = hit_sphere(Point3(0, 0, -1), 0.5, r);
    if (t > 0.0)
    {
        Vec3 normal = unitVector(r.at(t) - Vec3(0, 0, -1));
        return 0.5 * Color(normal.x() + 1, normal.y() + 1, normal.z() + 1);
    }
    // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 球 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

    // vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv 背景 vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
    Vec3 dir = r.direction();
    // [-1, 1] -> [0, 1]
    double a = 0.5 * (dir.y() + 1.0);
    // 线性混合
    return (1.0 - a) * Color(1.0, 1.0, 1.0) + a * Color(0.5, 0.7, 1.0);
    // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 背景 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
}

上面的红球表面法线可视化如下:

简化光线和球相交的代码

让我们看看之前写的hit_sphere函数:

// 返回光线r与以center为球心, radius为半径的球相交的最近点
double hit_sphere(const Point3& center, double radius, const Ray& r)
{
	// oc = 球心C - 光线原点Q
	Vec3 oc = center - r.origin();
	// 求根公式
	double a = dot(r.direction(), r.direction());
	double b = -2.0 * dot(r.direction(), oc);
	double c = dot(oc, oc) - radius * radius;
	double delta = b * b - 4 * a * c;
	// 返回有实数解的结果
	if (delta < 0)
	{
		return -1.0;
	}
	else
	{
		return (-b - std::sqrt(delta)) / (2.0 * a);
	}
}
  1. 回想一下,向量点乘自己相当于自己长度的平方。
  2. 注意到b里有个-2,将带入求根公式,有

再和联立解得 因此就能简化代码如下:

// 返回光线r与以center为球心, radius为半径的球相交的最近点
double hit_sphere(const Point3& center, double radius, const Ray& r)
{
	// oc = 球心C - 光线原点Q
	Vec3 oc = center - r.origin();
	// 求根公式
	double a = r.direction().lengthSquared();
	double h = dot(r.direction(), oc);
	double c = oc.lengthSquared() - radius * radius;
	double delta = h * h - a * c;
	// 返回有实数解的结果
	if (delta < 0)
	{
		return -1.0;
	}
	return (h - std::sqrt(delta)) / a;
}

抽象可被光线打到的物体

接下来尝试给场景加入多个球体。这里先定义一个抽象类,用于描述任何被光线打到的物体,然后定义继承于它的球体类和列表。

抽象类Hittable会有一个hit()虚函数,参数是打到物体的光线,以及合法的t值范围:

#pragma once

#include "ray.hpp"

class HitRecord 
{
public:
	Point3 position;
	Vec3 normal;
	double t;
};

class Hittable 
{
public:
	virtual ~Hittable() = default;

	virtual bool hit(const Ray& r, double ray_tMin, double ray_tMax, HitRecord& rec) const = 0;
};

然后就能定义球体类Sphere了:

#pragma once

#include "../ray/hittable.hpp"
#include "../util/vec3.hpp"

class Sphere : public Hittable
{
public:
	Sphere(const Point3& center, double radius)
		: center(center), radius(std::fmax(0, radius)) {}

	bool hit(const Ray& r, double ray_tMin, double ray_tMax, HitRecord& rec) const override
	{
		// oc = 球心C - 光线原点Q
		Vec3 oc = center - r.origin();
		
		// 求根公式
		double a = r.direction().lengthSquared();
		double h = dot(r.direction(), oc);
		double c = oc.lengthSquared() - radius * radius;
		double delta = h * h - a * c;
		if (delta < 0)
		{
			return false;
		}
		double sqrtDelta = std::sqrt(delta);

		// 找到位于[tMin, tMax]最近的相交点
		double root = (h - sqrtDelta) / a;
		if (root <= ray_tMin || root >= ray_tMax) {
			root = (h + sqrtDelta) / a;
			if (root <= ray_tMin || root >= ray_tMax) {
				return false;
			}
		}

		// 记录相交信息
		rec.t = root;
		rec.position = r.at(rec.t);
		rec.normal = (rec.position - center) / radius;	// 利用定义简化法线计算

		return true;
	}

private:
	Point3 center;
	double radius;
};

正向面和背向面

法线的第二条设计原则就是 它们是否总指向外边。当光线和物体表面相交时,法线的所有可能指向如下:

我们需要选择其中一种可能,因为我们想要决定光线最终打到哪个表面。这对于渲染不同表面的物体(例如渲染双面纸上的文字)和有内外表面的物体(例如玻璃球)很重要。

如果决定法线总是指向外面,接下来我们需要在着色时决定光线打到物体的那个面。可以通过比较光线和法线得到结果。假设光线和法线表面同向,光线就在物体里,否则光线在物体外。代码可以这样写:

if (dot(ray_direction, outward_normal) > 0.0) {
    // 光线在物体内部
} else {
    // 光线在物体外部
}

如果决定法线总是指向光线的反方向,需要通过额外变量存储光线在哪个面上:

bool front_face;
if (dot(ray_direction, outward_normal) > 0.0) {
    // 光线在物体内部
    normal = -outward_normal;
    front_face = false;
} else {
    // 光线在物体外部
    normal = outward_normal;
    front_face = true;
}

这里我们使用后者,让法线总是指向表面“外面”。在HitRecord类中,添加front_face的布尔型变量,并新增set_face_normal()方法解决相关计算:

class HitRecord 
{
public:
	Point3 position;
	Vec3 normal;
	double t;
	bool front_face;

	
	void set_face_normal(const Ray& r, const Vec3& outward_normal) 
	{
		// 注意: 总认为outward_normal是单位向量
		front_face = dot(r.direction(), outward_normal) < 0.0;
		normal = front_face ? outward_normal : -outward_normal;
	}
};

然后在Sphere类中调用它:

class Sphere : public Hittable
{
public:
	...

	bool hit(const Ray& r, double ray_tMin, double ray_tMax, HitRecord& rec) const override
	{
		...

		// 记录相交信息
		rec.t = root;
		rec.position = r.at(rec.t);
		Vec3 outward_normal = (rec.position - center) / radius;	// 利用定义简化法线计算
		rec.set_face_normal(r, outward_normal);

		return true;
	}

...
};

可被光线打到物体的列表

接下来就能创建多个物体的列表类HittableList了:

#pragma once

#include "hittable.hpp"
#include <memory>
#include <vector>

using std::make_shared;
using std::shared_ptr;

class HittableList : public Hittable {
public:
	std::vector<shared_ptr<Hittable>> objects;

	HittableList() {}
	HittableList(shared_ptr<Hittable> object) { add(object); }

	void clear() { objects.clear(); }
	void add(shared_ptr<Hittable> object) 
	{
		objects.push_back(object);
	}

	bool hit(const Ray& r, double ray_tMin, double ray_tMax, HitRecord& rec) const override
	{
		HitRecord tmpRec;
		bool isHit = false;
		double ray_tClosest = ray_tMax;

		for (const auto& object : objects)
		{
			if (object->hit(r, ray_tMin, ray_tClosest, tmpRec))
			{
				isHit = true;
				ray_tClosest = tmpRec.t;
				rec = tmpRec;
			}
		}

		return isHit;
	}
};

通用头文件

可以创建一个通用头文件rtweekend.h简化引入头文件的操作,顺便提供一些工具函数:

#pragma once

#include <cmath>
#include <iostream>
#include <limits>
#include <memory>
#include <numbers>

// C++ std usings
using std::make_shared;
using std::shared_ptr;

// 常量
constexpr double infinity = std::numeric_limits<double>::infinity();
constexpr double pi = std::numbers::pi;

// 工具函数
inline double degrees_to_radians(double degrees)
{
	return degrees * pi / 180.0;
}

// 自定义头文件
#include "util/color.hpp"
#include "ray/ray.hpp"
#include "util/vec3.hpp"

然后就能从之前写的库中移除重复引入的头文件了,这是更新后的main.cpp

#include "rtweekend.h"
#include "ray/hittable.hpp"
#include "ray/hittableList.hpp"
#include "object/sphere.hpp"

#include <format>

Color ray_color(const Ray& r, const Hittable& world)
{
	// vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv 和光线相交物体 vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
	HitRecord rec;
	if (world.hit(r, 0, infinity, rec))
	{
		return 0.5 * (rec.normal + Color(1, 1, 1));
	}
	// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 和光线相交物体 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

	// vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv 背景 vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
	Vec3 dir = r.direction();
	// [-1, 1] -> [0, 1]
	double a = 0.5 * (dir.y() + 1.0);
	// 线性混合
	return (1.0 - a) * Color(1.0, 1.0, 1.0) + a * Color(0.5, 0.7, 1.0);
	// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 背景 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
}

int main()
{
	// vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv 数据设置 vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
	// 图片设定
	double aspectRadio = 16.0 / 9.0;
	int imgWidth = 400;
	int imgHeight = static_cast<int>(imgWidth / aspectRadio);
	imgHeight = (imgHeight < 1) ? 1 : imgHeight;				// 确保高度至少为1

	// 场景设定
	HittableList world;
	world.add(make_shared<Sphere>(Point3(0, 0, -1), 0.5));
	world.add(make_shared<Sphere>(Point3(0, -100.5, -1), 100));

	// 相机设定
	double focalLength = 1.0;				// 焦距
	Point3 cameraCenter = Point3(0, 0, 0);	// 相机位置

	// 视口设定
	double viewportHeight = 2.0;
	double viewportWidth = viewportHeight * static_cast<double>(imgWidth) / imgHeight;

	// 4个辅助向量
	Vec3 viewport_u = Vec3(viewportWidth, 0, 0);
	Vec3 viewport_v = Vec3(0, -viewportHeight, 0);
	Vec3 pixel_delta_u = viewport_u / imgWidth;
	Vec3 pixel_delta_v = viewport_v / imgHeight;

	// 计算第一个像素位置
	Point3 viewport_upper_left = cameraCenter - Point3(0, 0, focalLength) - viewport_u / 2 - viewport_v / 2;
	Point3 pixel00_pos = viewport_upper_left + 0.5 * (pixel_delta_u + pixel_delta_v);
	// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 数据设置 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^


	// vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv 渲染 vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
	std::cout << std::format("P3\n{} {}\n255\n", imgWidth, imgHeight);
	for (int j = 0; j < imgHeight; ++j) 
	{
		for (int i = 0; i < imgWidth; ++i)
		{
			Point3 cur_pixel_center = pixel00_pos + (i * pixel_delta_u) + (j * pixel_delta_v);
			Vec3 ray_direction = unitVector((cur_pixel_center - cameraCenter));
			Ray ray(cameraCenter, ray_direction);
			Color pixel_color = ray_color(ray, world);

			writeColor(std::cout, pixel_color);
		}
	}
	// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 渲染 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

	return 0;
}

在更新后的main.cpp中,我们可以自由地向world中添加各种可碰撞物体,虽然目前只有球。代码运行后的结果如下:

区间类

接下来新建一个区间类Interval用于管理实数区间,这个类将在后续经常用到:

#pragma once

class Interval
{
public:
	double min, max;

	// 初始的区间是空的
	Interval() : min(+infinity), max(-infinity) {}
	Interval(double min, double max) : min(min), max(max) {}

	double size() const { return max - min; }
	bool contains(double x) const { return x >= min && x <= max; }
	bool surrounds(double x) const { return x > min && x < max; }

	// 常用区间
	static const Interval empty, universe;
};

// 常用区间定义
const Interval Interval::empty = Interval(+infinity, -infinity);
const Interval Interval::universe = Interval(-infinity, +infinity);

然后将用到诸如ray_tMaxray_tMin区间的类全都替换为Interval即可,详情见这里

封装摄像机类

接下来将相机和场景渲染相关的代码封装到Camera类中,它将完成如下工作:

  1. 构造并发射光线到场景中。
  2. 使用这些光线的结果渲染图片。

需要将ray_color()函数和图片、场景等设定一起封装,新的Camera类中将包含initialize()render()两个主要方法,还有两个私有帮手函数get_ray()ray_color()。只需先将ray_colormain.cpp中移植过来;然后将main.cpp中有关渲染的代码,一些设置也全部移植过来就好了,最终代码如下:

#pragma once

#include "../rtweekend.h"
#include "../ray/hittable.hpp"

class Camera
{
public:
	double aspectRadio = 1.0;			// 图像的宽高比
	int imgWidth = 100;					// 图像宽度

	void render(const Hittable& world)
	{
		initialize();

		std::cout << std::format("P3\n{} {}\n255\n", imgWidth, imgHeight);
		for (int j = 0; j < imgHeight; ++j)
		{
			for (int i = 0; i < imgWidth; ++i)
			{
				Point3 cur_pixel_center = pixel00_pos + (i * pixel_delta_u) + (j * pixel_delta_v);
				Vec3 ray_direction = unitVector((cur_pixel_center - center));
				Ray ray(center, ray_direction);
				Color pixel_color = ray_color(ray, world);

				writeColor(std::cout, pixel_color);
			}
		}
	}

private:
	int imgHeight;						// 图像高度
	Point3 center;						// 相机位置
	Point3 pixel00_pos;					// 像素(0, 0)的位置
	Vec3 pixel_delta_u;					// 定位像素用的辅助向量
	Vec3 pixel_delta_v;

	void initialize()
	{
		imgHeight = static_cast<int>(imgWidth / aspectRadio);
		imgHeight = (imgHeight < 1) ? 1 : imgHeight;				// 确保高度至少为1

		center = Point3(0, 0, 0);

		// 相机设定
		double focalLength = 1.0;									// 焦距

		// 视口设定
		double viewportHeight = 2.0;
		double viewportWidth = viewportHeight * static_cast<double>(imgWidth) / imgHeight;

		// 4个辅助向量
		Vec3 viewport_u = Vec3(viewportWidth, 0, 0);
		Vec3 viewport_v = Vec3(0, -viewportHeight, 0);
		pixel_delta_u = viewport_u / imgWidth;
		pixel_delta_v = viewport_v / imgHeight;

		// 计算第一个像素位置
		Point3 viewport_upper_left = center - Point3(0, 0, focalLength) - viewport_u / 2 - viewport_v / 2;
		pixel00_pos = viewport_upper_left + 0.5 * (pixel_delta_u + pixel_delta_v);
	}

	Color ray_color(const Ray& r, const Hittable& world) const
	{
		// vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv 和光线相交物体 vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
		HitRecord rec;
		if (world.hit(r, Interval(0, infinity), rec))
		{
			return 0.5 * (rec.normal + Color(1, 1, 1));
		}
		// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 和光线相交物体 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

		// vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv 背景 vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
		Vec3 dir = r.direction();
		// a in [-1, 1] -> a in [0, 1]
		double a = 0.5 * (dir.y() + 1.0);
		// 线性混合
		return (1.0 - a) * Color(1.0, 1.0, 1.0) + a * Color(0.5, 0.7, 1.0);
		// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 背景 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
	}
};

然后将其添加到main.cpp中:

#include "rtweekend.h"

#include "camera/camera.hpp"
#include "ray/hittable.hpp"
#include "ray/hittableList.hpp"
#include "object/sphere.hpp"

#include <format>

int main()
{
	// 场景设定
	HittableList world;
	world.add(make_shared<Sphere>(Point3(0, 0, -1), 0.5));
	world.add(make_shared<Sphere>(Point3(0, -100.5, -1), 100));

	// 初始化摄像机, 然后渲染
	Camera cam;
	cam.aspectRadio = 16.0 / 9.0;
	cam.imgWidth = 400;
	cam.render(world);

	return 0;
}

如果一切正常,跑下来的结果应该和封装前的一样。

参考资料

  • Ray Tracing in One Weekend