Ray Tracing in One Weekend
作者:Peter Shirley, Trevor David Black, Steve Hollasch
1. 概述
这些年,我上过不少图形学的课程,常常把光线追踪当作教学内容。在不使用API的情况下,你常常需要写所有的代码才能获得很酷的图像。我决定把我的教案改写成本教程以让你能尽可能快的实现一个炫酷的光线追踪器(ray tracer)。这并不是一个功能完备的光线追踪器,但是它渲染的间接光照(indirect lighting)使光线追踪在电影行业能够成为主流技术。跟随本教程循序渐进,你的光线追踪器的代码构筑将会变得易于拓展。如果之后你对这方面燃起了兴趣,你可以将它拓展成一个更加完备的光线追踪器。
当大家提起" 光线追踪", 可能指的是很多不同的东西。我对这个词的描述是,光线追踪在技术上就是一个路径追踪器,事实上大部分情况下这个词都是这个意思。光线追踪器的代码也是十分的简单(让电脑帮我们算去吧!)。当你看到你渲染的图片时,你一定会感到高兴的。
接下来我会带你一步步的实现这个光线追踪,并加入一些我的 debug 建议。最后你会得到一个能渲染出漂亮图片的光线追踪器。你认为你应该能在一个周末的时间内搞定。如果你花的时间比这长,别担心,也没太大问题。我使用 C++ 作为本光线追踪器的实现语言。你其实不必,但我还是推荐你用 C++, 因为 C++ 快速,平台移植性好,并且大部分的工业级电影和游戏渲染器都是使用 C++ 编写的。注意这里我避免了大部分 C++ 的新特性。但是继承和重载运算符我们保留,对光线追踪器来说这个太有用了。网上的那些代码不是我提供的,但是这些代码是真的可以跑的。除了 vec3
类中的一些简单的操作,我将所有的代码都公开了。我是" 学习编程要亲自动手敲代码" 派。但是如果有一份代码摆在我面前,我可以直接用,我还是会用的。所以我只在没代码用的时候,我才奉行我刚刚说的话。好啦,别提这个了!
我没把上面一段删了,因为我的态度 180° 大转变太好玩了。读者们帮我修复了一些次要的编译错误,所以还是请你亲手来敲一下代码吧!但是你如果你想看看我的代码: Github 传送门
关于这系列书籍的实现代码的说明------我们所有代码优先考虑以下目标:
-
代码应该实现书中涵盖的概念。
-
我们使用C++,但尽可能简单。我们的编程风格非常像C,但我们利用了现代C++的特性,使代码更容易使用或理解。
-
我们的编码风格尽可能地延续了原著中建立的风格,以保持连贯性。
-
每行的行长保持为96个字符,以保持代码库和书籍中代码列表之间的行一致。
因此,该代码提供了一个基础实现,为读者提供了大量可改进的空间。有无数种方法可以优化代码并使其更现代化;在这里,我们仅仅优先考虑简单的解决方案。
我们假定你有一定的向量知识(比如点乘积和向量加法)。如果你忘记了,可以复习一下。如果你是第一次学习或者需要复习资料,请查看Morgan McGuire上传的在线教材:Graphics Codex,以及Steve Marschner和Peter Shirley的著作:《计算机图形学基础》,J.D.Foley和Andy Van Dam的著作:《计算机图形:原理与实践》。
请参阅项目README文件,以获取有关此项目、GitHub上的存储库、目录结构、构建和运行以及如何进行或引用更正和贡献的信息。
请参阅我们的进一步阅读维基页面获取更多与项目相关的资源。
这些书已经过格式化,可以直接从浏览器打印出来。在发布栏目中的"Assets"子目录下,还包括每本书的PDF文件。
如果你想要联系我们,可以给我们发邮件:
- Peter Shirley: ptrshrl@gmail.com
- Steve Hollasch: steve@hollasch.net
- Trevor David Black: trevordblack@trevord.black
最后,如果你在实现上遇到了麻烦,有一般性问题,或者想分享你自己的想法和成果,移步到该Github仓库下的讨论专区
感谢所有参与这个项目的人。你可以在本书末尾的部分的致谢中找到他们。
让我们开始吧。
译者注:在翻译这些内容时,我把PPM文件的输出封装成了一个类,后续的渲染器,几何描述都会如此。但文章中的代码还是保持原文章的样子,保证跟着做没有问题。
2. 输出一张图像
2.1 PPM图像格式
当你开始写渲染器的时候,你首先得能有办法看到你渲染的图像。最直接了当的方法就行把图像信息写入文件。问题是,有那么多图片格式,而且许多格式都挺复杂的。在开始着手做的时候,我习惯于使用最简单的PPM文件,这里引用Wikipedia上面的简明介绍:
PPM文件格式是一种纯文本格式,第一行P3表明颜色是用ASCII编码表示,第二行的3和2表示3列和2行,第三行的255表示最大颜色值是255,后续就是RGB序列,表示每一个像素的颜色值,从左上角开始一直到右下角。
我们来写一下输出这种格式文件的C++代码:
c++
#include <iostream>
int main() {
// Image
int image_width = 256;
int image_height = 256;
// Render
std::cout << "P3\n" << image_width << ' ' << image_height << "\n255\n";
for (int j = 0; j < image_height; j++) {
for (int i = 0; i < image_width; i++) {
auto r = double(i) / (image_width-1);
auto g = double(j) / (image_height-1);
auto b = 0.0;
int ir = int(255.999 * r);
int ig = int(255.999 * g);
int ib = int(255.999 * b);
std::cout << ir << ' ' << ig << ' ' << ib << '\n';
}
}
}
关于这段代码,有一些需要注意的点:
- 像素按行输出
- 每行像素从左往右输出
- 按惯例,像素的RGB通道的颜色值在内部使用0-1的实数来表示,仅在输出时缩放为0-255。
- 下方的红色从左到右由黑边红,左侧的绿色从上到下由黑到绿。红 + 绿变黄,所以我们的右下角应该是黄的。
2.2 创建一个图片文件
要把结果输出到文件中,而cout
是输出到标准输出流的,要能够实现文件的输出,就需要借助命令行提供的重定向符号(>
)。
在Windows系统中,你可能通过CMake执行下面的命令,来构建一个Debug版本的应用程序:
css
cmake -B build
cmake --build build
然后像这样执行它:
arduino
build\Debug\inOneWeekend.exe > image.ppm
再然后,为了优化运行速度。你将会执行下面的指令来进行构建:
arduino
cmake --build build --config release
并像这样执行应用程序:
arduino
build\Release\inOneWeekend.exe > image.ppm
假设你使用CMake进行构建上面的例子,在CMakeLists.txt
内包含相关源代码。就可以移植到任何其他你喜欢的环境中使用。
在Mac或者Linux上进行Release Build,启动程序的命令行如下:
arduino
build/inOneWeekend > image.ppm
完整的构建和运行的指令可以从项目的README中找到。
打开输出的文件并得到如下结果(我在Mac上使用的图片浏览器是ToyViewer,你可以使用自己喜欢的图片浏览器查看,如果你的系统上没有,可以谷歌一下 "PPM Viewer"):
Oh!!这就是图形学里的"Hello World"。如果你的图像不像上图所展示的样子,可以使用文本编辑器打开它,看看里面的内容。它应该是下面的内容:
erlang
P3
256 256
255
0 0 0
1 0 0
2 0 0
3 0 0
4 0 0
5 0 0
6 0 0
7 0 0
8 0 0
9 0 0
10 0 0
11 0 0
12 0 0
...
如果你的PPM文件并不如此,检查一下你的代码。如果它确实如此,但是却没办法显示,那么可能是行末尾的字符有点不同,或者是图片浏览器有问题。你可以从本书的Github Project的图库中找到一张test.ppm
并打开它来确认是不是图片浏览器的问题,并用来和你生成的PPM文件比较。
据有的读者反馈,在Windows上查看生成的文件有问题,原因可能是输出PPM文件时使用的时UTF-16字符集,该问题通常是由PowerShell导致的。如果你遇到了这个问题,可以看一下Discussion 1114,这有助于解决这个问题。
如果展示正确,那么就表明系统和IDE方面都没有问题------本系列其余部分中的所有内容都使用这种简单的机制来生成渲染图像。
如果你想要生成其他文件格式,可以从GitHub上获取stb_image.h
,只有一个头文件的图像库。(我还是它的粉丝)
2.3 添加进度指示器
在我们往下走之前,我们先来加个输出的进度提示。对于查看一次长时间渲染的进度来说,这不失为一种简便的做法。也可以通过这个进度来判断程序是否卡住或者进入一个死循环。
我们的程序将图片信息写入标准输出流(std::cout
), 所以我们不能用这个流输出进度。我们换用错误输出流(std::cerr
)来输出进度:
c++
for (int j = 0; j < image_height; ++j) {
std::clog << "\rScanlines remaining: " << (image_height - j) << ' ' << std::flush;
for (int i = 0; i < image_width; i++) {
auto r = double(i) / (image_width-1);
auto g = double(j) / (image_height-1);
auto b = 0.0;
int ir = int(255.999 * r);
int ig = int(255.999 * g);
int ib = int(255.999 * b);
std::cout << ir << ' ' << ig << ' ' << ib << '\n';
}
}
std::clog << "\rDone. \n";
运行它,您将看到剩余扫描线数量的计数。它跑得如此之快,以至于你甚至看不到它!不过别担心,随着我们后续编写光线追踪渲染器,您将来会有很多时间来观察缓慢更新的进度值。
向量类
几乎所有的图形程序都使用类似的类来储存几何向量和颜色。在许多程序中这些向量是四维的 (对于位置或者几何向量来说是三维的齐次拓展,对于颜色来说是 RGB 加透明通道A)。对我们现在这个程序来说,三维就足够了。我们用一个 vec3
类来储存所有的颜色,位置,方向,位置偏移,或者别的什么东西。一些人可能不太喜欢这样做,因为全都用一个类,没有限制,写代码的时候难免会犯二,比如你把颜色和位置加在一起。他们的想法挺好的,但是我们想在避免明显错误的同时让代码量尽量的精简。所以这里就先一个类吧。
下面是我的 vec3
的头文件:
c++
#ifndef VEC3_H
#define VEC3_H
#include <cmath>
#include <iostream>
class vec3 {
public:
double e[3];
vec3() : e{0,0,0} {}
vec3(double e0, double e1, double e2) : e{e0, e1, e2} {}
double x() const { return e[0]; }
double y() const { return e[1]; }
double z() const { return e[2]; }
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 length() const {
return std::sqrt(length_squared());
}
double length_squared() const {
return e[0]*e[0] + e[1]*e[1] + e[2]*e[2];
}
};
// point3 is just an alias for vec3, but useful for geometric clarity in the code.
using point3 = vec3;
// Vector Utility Functions
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 unit_vector(const vec3& v) {
return v / v.length();
}
#endif
我们使用双精度浮点 double
, 但是有些光线追踪器使用单精度浮点 float
。这里其实都行,你喜欢哪个就用那个。
3.1 颜色通用函数
基于我们创建的vec3
类,我们可以创建新的头文件:color.h
并定义一个将颜色输出到指定流的函数。
c++
#ifndef COLOR_H
#define COLOR_H
#include "vec3.h"
#include <iostream>
using color = vec3;
void write_color(std::ostream& out, const color& pixel_color) {
auto r = pixel_color.x();
auto g = pixel_color.y();
auto b = pixel_color.z();
// Translate the [0,1] component values to the byte range [0,255].
int rbyte = int(255.999 * r);
int gbyte = int(255.999 * g);
int bbyte = int(255.999 * b);
// Write out the pixel color components.
out << rbyte << ' ' << gbyte << ' ' << bbyte << '\n';
}
#endif
接下来,我们可以修改我们的main
函数,使用上面定义的内容:
c++
#include "color.h"
#include "vec3.h"
#include <iostream>
int main() {
// Image
int image_width = 256;
int image_height = 256;
// Render
std::cout << "P3\n" << image_width << ' ' << image_height << "\n255\n";
for (int j = 0; j < image_height; j++) {
std::clog << "\rScanlines remaining: " << (image_height - j) << ' ' << std::flush;
for (int i = 0; i < image_width; i++) {
auto pixel_color = color(double(i)/(image_width-1), double(j)/(image_height-1), 0);
write_color(std::cout, pixel_color);
}
}
std::clog << "\rDone. \n";
}
然后你会生成和上一章一样的图像。
4. 射线,相机和背景
4.1 射线类
所有的光线追踪渲染器都有个一个 ray 类,我们假定光线的公式为 <math xmlns="http://www.w3.org/1998/Math/MathML"> P ( 𝑡 ) = A + 𝑡 b P(𝑡)=A+𝑡b </math>P(t)=A+tb。这里的 P 是三维射线上的一个点。A 是射线的原点,b 是射线的方向。类中的变量𝑡 是一个实数 (代码中为 double
类型)。P (𝑡 ) 接受任意的𝑡做为变量,返回射线上的对应点。如果允许𝑡 取负值你可以得到整条直线。对于一个正数𝑡 , 你只能得到原点A 沿着方向b的前部分, 这常常被称为半条直线,或者说射线。
我在代码中使用复杂命名,将函数 <math xmlns="http://www.w3.org/1998/Math/MathML"> P ( 𝑡 ) P(𝑡) </math>P(t) 扩写为 ray::at(t)
:
c++
#ifndef RAY_H
#define RAY_H
#include "vec3.h"
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; }
point3 at(double t) const {
return orig + t*dir;
}
private:
point3 orig;
vec3 dir;
};
#endif
(对于那些不熟悉C++的人来说,函数ray::origin()
和ray::direction()
都返回对其成员的不可变引用。调用者可以直接使用引用,也可以根据需要制作可变副本。)
4.2 向场景中发射射线
现在我们再回头来做我们的光线追踪渲染器。光线追踪渲染器的核心是从像素发射射线,并计算这些射线得到的颜色。这包括如下的步骤:
- 将射线从视点转化为像素坐标
- 计算射线是否与场景中的物体相交
- 如果有,计算交点的颜色
在做光线追踪渲染器的初期,我会先弄个简单摄像机让代码能跑起来。我也会编写一个简单的 color(ray)
函数来返回背景颜色值 (一个简单的渐变色)。
在使用正方形的图像 Debug 时,我时常会遇到问题,因为我老是把x和y弄反,所以我们不会使用一个宽高比为1:1的正方形图像,因为它的宽和高一致。我坚持使用16:9这样长宽不等的图像,因为它很常见。 宽高比为16:9意味着图像的宽度和高度的比例是16:9。
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> w i d t h : h e i g h t = 16 : 9 = 1.7778 width:height=16:9=1.7778 </math>width:height=16:9=1.7778
举一个实际的例子来说明,一张800x400图像的宽高比是2:1。
图像的宽高比可以通过其宽度与高度的比值来确定。但是,由于我们考虑了给定的宽高比,因此更容易设置图像的宽度和宽高比,然后使用它来计算其高度。这样,我们可以通过改变图像宽度来放大或缩小图像,而不会偏离我们想要的比例。我们必须确保在求解图像高度时,得到的高度至少为1。
除了为渲染图像设置像素尺寸外,我们还需要设置一个虚拟视口,通过它传递场景光线。视口是3D世界中的一个虚拟矩形,包含图像像素的位置。如果像素在水平方向上与垂直方向上间隔相同的距离,则限制它们的视口具有与渲染图像相同的宽高比。两个相邻像素之间的距离称为像素间距,方形像素作为标准。(这一段翻译有点奇怪)
首先,我们将选择一个任意的视口高度2.0,并缩放视口宽度以获得所需的纵横比。以下是这段代码的一个片段:
c++
auto aspect_ratio = 16.0 / 9.0;
int image_width = 400;
// Calculate the image height, and ensure that it's at least 1.
int image_height = int(image_width / aspect_ratio);
image_height = (image_height < 1) ? 1 : image_height;
// Viewport widths less than one are ok since they are real valued.
auto viewport_height = 2.0;
auto viewport_width = viewport_height * (double(image_width)/image_height);
如果你疑惑,为什么我们不用aspect_ratio
来计算viewport_width
,那是因为设置到aspect_ratio
的值是要给理想值,它不是实际的宽高比。如果image_height
可以被设置为浮点数而不是整数,那么使用aspect_ratio
才是合理的。但是实际的宽高比可能会由于代码的两种情况引起差异。第一,是image_height
被四舍五入到最近的整数,这会导致宽高比增大。第二,我们不允许image_height
小于1,这也会改变实际的宽高比。
请注意,aspect_ratio
是一个理想的比例,我们尽可能地基于整数的宽度和高度的比例来近似。为了使我们的视口比例与图像比例完全匹配,我们使用计算出来的宽度和高度重新计算宽高比来确定最终视口的宽度。
接下来我们将定义相机的中心:一个三维空间的点,该点作为场景中所有射线的起点(通常也叫做视点)。从相机中心到视口中心的向量与视口正交。我们会设置相机中心与视口中心的距离为一个单位。该距离通常被称为"焦距"。
为了简化,我们首先将相机中心设置在(0,0,0)处,并且将Y轴设置为上方向,X轴为右方向,Z轴的负方向指向视口方向。(这就是通常所说的右手坐标系)
现在是不可避免的棘手部分。虽然我们的3D空间有上述约定,但这与我们的图像坐标相冲突,我们希望在左上角是第0个像素,然后向下移动到右下角的最后一个像素。这意味着我们的图像坐标Y轴是颠倒的:Y随着图像的向下而增加。
当我们扫描图像时,我们将从左上角的像素(像素(0,0))开始,从左向右扫描每一行,然后从上到下逐行扫描。为了帮助遍历像素网格,我们将使用从左边缘到右边缘的向量( <math xmlns="http://www.w3.org/1998/Math/MathML"> V u V_u </math>Vu)和从上边缘到下边缘的矢量( <math xmlns="http://www.w3.org/1998/Math/MathML"> V v V_v </math>Vv)。
我们的像素格子从视口边缘半个像素的距离开始插入。这样一来,我们的视口区域被均匀地划分为Width x Height 大小的区域。如下图所示:
在这张图中,我们定义了视口,是一张 7x5 分辨率的图片,视口的左上角点原点 <math xmlns="http://www.w3.org/1998/Math/MathML"> Q Q </math>Q,像素的原点位置 <math xmlns="http://www.w3.org/1998/Math/MathML"> P 0 , 0 P_{0,0} </math>P0,0,视口的横向向量 <math xmlns="http://www.w3.org/1998/Math/MathML"> V u V_u </math>Vu,视口的纵向向量 <math xmlns="http://www.w3.org/1998/Math/MathML"> V v V_v </math>Vv,以及像素间的间距向量 <math xmlns="http://www.w3.org/1998/Math/MathML"> Δ u \Delta u </math>Δu和 <math xmlns="http://www.w3.org/1998/Math/MathML"> Δ v \Delta v </math>Δv。
根据所有这些论述,我们将实现相机的代码。我们将实现一个桩函数ray_color(const ray& r)
,该函数给出往场景中发射一条射线得到的颜色,这里我们先简单地返回黑色。
c++
#include "color.h"
#include "ray.h"
#include "vec3.h"
#include <iostream>
color ray_color(const ray& r) {
return color(0,0,0);
}
int main() {
// Image
auto aspect_ratio = 16.0 / 9.0;
int image_width = 400;
// Calculate the image height, and ensure that it's at least 1.
int image_height = int(image_width / aspect_ratio);
image_height = (image_height < 1) ? 1 : image_height;
// Camera
auto focal_length = 1.0;
auto viewport_height = 2.0;
auto viewport_width = viewport_height * (double(image_width)/image_height);
auto camera_center = point3(0, 0, 0);
// Calculate the vectors across the horizontal and down the vertical viewport edges.
auto viewport_u = vec3(viewport_width, 0, 0);
auto viewport_v = vec3(0, -viewport_height, 0);
// Calculate the horizontal and vertical delta vectors from pixel to pixel.
auto pixel_delta_u = viewport_u / image_width;
auto pixel_delta_v = viewport_v / image_height;
// Calculate the location of the upper left pixel.
auto viewport_upper_left = camera_center
- vec3(0, 0, focal_length) - viewport_u/2 - viewport_v/2;
auto pixel00_loc = viewport_upper_left + 0.5 * (pixel_delta_u + pixel_delta_v);
// Render
std::cout << "P3\n" << image_width << " " << image_height << "\n255\n";
for (int j = 0; j < image_height; j++) {
std::clog << "\rScanlines remaining: " << (image_height - j) << ' ' << std::flush;
for (int i = 0; i < image_width; i++) {
auto pixel_center = pixel00_loc + (i * pixel_delta_u) + (j * pixel_delta_v);
auto ray_direction = pixel_center - camera_center;
ray r(camera_center, ray_direction);
color pixel_color = ray_color(r);
write_color(std::cout, pixel_color);
}
}
std::clog << "\rDone. \n";
}
注意,上面的代码中,我没有让ray_direction
为单位向量,因为我认为这样可以让代码更间接及高效。
现在,我们将要补充ray_color(ray)
函数的内容以实现一个简单的渐变。该函数将会基于单位化后的方向向量的Y轴高度,线性地混合蓝色和白色( <math xmlns="http://www.w3.org/1998/Math/MathML"> − 1 < y < 1 -1<y<1 </math>−1<y<1)。因为我们使用y轴做渐变,你将会看到这个渐变颜色也是竖直的。
我接下来使用了一个标准的图形小技巧来线性缩放数值a(0.0<a<1.0)。当a=1.0,我希望输出蓝色,当a=0.0时,我希望输出白色。在二者之间,我希望它们进行混合。这就得到了一个"线性混合",或者叫"线性插值"。这通常被称为两个值之间的"lerp"。Lerp的形式总是:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> B l e n d e d V a l u e = ( 1 − a ) ⋅ S t a r t V a l u e + a ⋅ E n d V a l u e ( 0.0 ≤ a ≤ 1.0 ) BlendedValue = (1-a)\cdot StartValue + a\cdot EndValue~~~~~~(0.0\leq a \leq 1.0) </math>BlendedValue=(1−a)⋅StartValue+a⋅EndValue (0.0≤a≤1.0)
把他们整理到一起,得到下面的代码:
c++
#include "color.h"
#include "ray.h"
#include "vec3.h"
#include <iostream>
color ray_color(const ray& r) {
vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0, 1.0, 1.0) + a*color(0.5, 0.7, 1.0);
}
...
执行这段代码,输出的图片如下图所示:
5. 添加一个球体
让我们为我们的光线追踪渲染器添加一个简单的物体。人们通常使用球体在光线追踪渲染器中,因为一条射线和球体的相交计算相对而言比较简单。
5.1 射线与球的相交计算
一个半径为 <math xmlns="http://www.w3.org/1998/Math/MathML"> r r </math>r,球心在原点的球体的方程式为:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> x 2 + y 2 + z 2 = r 2 x^2+y^2+z^2=r^2 </math>x2+y2+z2=r2
根据前面的公式,你可以知道,当给定点 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( x , y , z ) (x,y,z) </math>(x,y,z)在球体的表面上,则 <math xmlns="http://www.w3.org/1998/Math/MathML"> x 2 + y 2 + z 2 = r 2 x^2+y^2+z^2=r^2 </math>x2+y2+z2=r2。如果给定点 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( x , y , z ) (x,y,z) </math>(x,y,z)在球体内部,则 <math xmlns="http://www.w3.org/1998/Math/MathML"> x 2 + y 2 + z 2 < r 2 x^2+y^2+z^2<r^2 </math>x2+y2+z2<r2。当给定点 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( x , y , z ) (x,y,z) </math>(x,y,z)在球体外部,则 <math xmlns="http://www.w3.org/1998/Math/MathML"> x 2 + y 2 + z 2 > r 2 x^2+y^2+z^2>r^2 </math>x2+y2+z2>r2
如果我们希望球心可以是任意位置的点( <math xmlns="http://www.w3.org/1998/Math/MathML"> C x , C y , C z C_x,C_y,C_z </math>Cx,Cy,Cz),则方程写成下面的形式:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> ( C x − x ) 2 + ( C y − y ) 2 + ( C z − z ) 2 = r 2 (C_x-x)^2+(C_y-y)^2+(C_z-z)^2=r^2 </math>(Cx−x)2+(Cy−y)2+(Cz−z)2=r2
在图形学中,你经常会想让你的公式写成向量的形式,这样可以把x/y/z分量替换成向量的形式。你可能会注意到,从点 <math xmlns="http://www.w3.org/1998/Math/MathML"> P = ( x , y , z ) P=(x,y,z) </math>P=(x,y,z)到球心 <math xmlns="http://www.w3.org/1998/Math/MathML"> C = ( C x , C y , C z ) C=(C_x,C_y,C_z) </math>C=(Cx,Cy,Cz)的向量是 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( C − P ) (C-P) </math>(C−P)
如果我们应用点积的定义,则可以得到下面的等式:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> ( C − P ) ⋅ ( C − P ) = ( C x − x ) 2 + ( C y − y ) 2 + ( C z − z ) 2 (C-P)\cdot (C-P)=(C_x-x)^2+(C_y-y)^2+(C_z-z)^2 </math>(C−P)⋅(C−P)=(Cx−x)2+(Cy−y)2+(Cz−z)2
然后我们把球的方程改成向量的形式,如下:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> ( C − P ) ⋅ ( C − P ) = r 2 (C-P)\cdot(C-P)=r^2 </math>(C−P)⋅(C−P)=r2
我们可以把它读作"任意满足这个方程的点 <math xmlns="http://www.w3.org/1998/Math/MathML"> P P </math>P在球上"。我们想知道一条射线 <math xmlns="http://www.w3.org/1998/Math/MathML"> P ( t ) = Q + t d P(t)=Q+td </math>P(t)=Q+td是否与球相交在某些点。如果该射线确实与球相交,则存在 <math xmlns="http://www.w3.org/1998/Math/MathML"> t t </math>t满足下面的方程:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> ( C − P ( t ) ) ⋅ ( C − P ( t ) ) = r 2 (C-P(t))\cdot (C-P(t))=r^2 </math>(C−P(t))⋅(C−P(t))=r2
将 <math xmlns="http://www.w3.org/1998/Math/MathML"> P ( t ) P(t) </math>P(t)展开后,可得:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> ( C − ( Q + t d ) ) ⋅ ( C − ( Q + t d ) ) = r 2 (C-(Q+td))\cdot (C-(Q+td))=r^2 </math>(C−(Q+td))⋅(C−(Q+td))=r2
在等式的左边,是三个向量(C,Q,d)的点乘式。如果我们把这些向量的点乘完全展开,将得到9个向量。你当然可以把所有东西都写下来,但我们不需要那么努力。我们只是想求解 <math xmlns="http://www.w3.org/1998/Math/MathML"> t t </math>t,因此我们只需要判断 <math xmlns="http://www.w3.org/1998/Math/MathML"> t t </math>t是否存在:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> ( − t d + ( C − Q ) ) ⋅ ( − t d + ( C − Q ) ) = r 2 (-td+(C-Q))\cdot (-td+(C-Q)) = r^2 </math>(−td+(C−Q))⋅(−td+(C−Q))=r2
接下来我们遵循向量的乘法分配律进行计算,将公式进一步展开,得到:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> ( t 2 d ⋅ d − 2 t d ⋅ ( C − Q ) + ( C − Q ) ⋅ ( C − Q ) ) = r 2 (t^2d\cdot d- 2td\cdot (C-Q) + (C-Q)\cdot(C-Q))=r^2 </math>(t2d⋅d−2td⋅(C−Q)+(C−Q)⋅(C−Q))=r2
把等式右边的半径平方移到左边:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> ( t 2 d ⋅ d − 2 t d ⋅ ( C − Q ) + ( C − Q ) ⋅ ( C − Q ) ) − r 2 = 0 (t^2d\cdot d- 2td\cdot (C-Q) + (C-Q)\cdot(C-Q)) - r^2=0 </math>(t2d⋅d−2td⋅(C−Q)+(C−Q)⋅(C−Q))−r2=0
虽然仍很难弄清楚该方程是什么,不过公式中的向量和半径都是已知的常量。此外,仅有的向量被通过点积转化为标量。唯一的未知数是参数 <math xmlns="http://www.w3.org/1998/Math/MathML"> t t </math>t,并且在方程中有 <math xmlns="http://www.w3.org/1998/Math/MathML"> t 2 t^2 </math>t2,表明这是一个二次方程。所以可以通过二次方程( <math xmlns="http://www.w3.org/1998/Math/MathML"> a x 2 + b x + c = 0 ax^2+bx+c=0 </math>ax2+bx+c=0)的求解公式进行求解:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> − b ± b 2 − 4 a c 2 a \frac{-b\pm\sqrt{b^2 - 4ac}}{2a} </math>2a−b±b2−4ac
因此,从射线-球体的相交方程可以确定参数a,b和c,这样代入上面的公式即可求解 <math xmlns="http://www.w3.org/1998/Math/MathML"> t t </math>t:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> a = d ⋅ d b = − 2 d ⋅ ( C − Q ) c = ( C − Q ) ⋅ ( C − Q ) − r 2 \begin{align*} a &= d\cdot d\\ b &= -2d\cdot (C-Q)\\ c &= (C-Q)\cdot(C-Q)-r^2 \end{align*} </math>abc=d⋅d=−2d⋅(C−Q)=(C−Q)⋅(C−Q)−r2
使用上述所有方法,你可以求解 <math xmlns="http://www.w3.org/1998/Math/MathML"> t t </math>t,判别式中平方根的值为正,则意味着有两个解,负表明误解,零则意味着有一个解。在图形学中,代数几乎总是与几何相关,如下图所示:
5.2 创建第一张光线追踪生成的图像
如果我们应用上面的数学原理,并把它硬编码到我们的程序中,我们可以通过放置一个小形的球体在z轴-1的位置,当射线与它相交时返回红色。
c++
bool hit_sphere(const point3& center, double radius, const ray& r) {
vec3 oc = center - r.origin();
auto a = dot(r.direction(), r.direction());
auto b = -2.0 * dot(r.direction(), oc);
auto c = dot(oc, oc) - radius*radius;
auto discriminant = b*b - 4*a*c;
return (discriminant >= 0);
}
color ray_color(const ray& r) {
if (hit_sphere(point3(0,0,-1), 0.5, r))
return color(1, 0, 0);
vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0, 1.0, 1.0) + a*color(0.5, 0.7, 1.0);
}
于是我们得到:
现在它仍然缺少很多内容,例如着色,反射光线和多个物体,但我们比开始时更进一步了。我们通过二次方程是否有解来判断射线是否与球相交,但是目前该方案中, <math xmlns="http://www.w3.org/1998/Math/MathML"> t t </math>t为负值也仍然可以得到相同的结果。例如将球心的z坐标改为+1,则可以得到相同的输出结果。这是因为上面的代码没有区分物体是否在相机的后方。这是不对的,我们后续会修复它。
6. 表面法线和多个物体
6.1 使用表面法线进行着色
为了可以使用表面法线进行着色,首先,我们来给出法线的定义。表面法线是一个垂直于相交表面的三维向量。
在我们的代码中,有两个关键的设计抉择:法线是否可以是任意长度或者被归一化到单位长度。
在不需要的情况下,跳过归一化向量所涉及的昂贵的平方根运算是很有诱惑力的。然而,在实践中,有三个重要的观察结果。第一,如果一个单位向量是必要的,那么你最好提前做一次,以防在每个位置都一遍遍地重复这个计算。第二,我们确实在很多地方都会需要到单位向量。第三,如果你把法线定为单位向量,那么你可以基于特定物体的几何性质来高效地生成它,在它的构造函数或者在hit()
函数中。举个例子,球体的法线可以通过除以它的半径来进行归一化,避免开根号。
因此,我们将法向量都设置为单位长度。
对于球体,外向法线的计算是通过相交点减去球心实现的:
说到底,其实就是从球心到相交点的位置再往外延伸。让我们把它实现到代码中,并且对它进行着色。我们暂时还没有考虑灯光和其他事情,仅仅只是将法线作为颜色输出。对于法线的可视化,我们常常将它的值映射到0-1(由于法线 <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n是单位向量,所以每一个分量的取值范围是-1到1),并赋值给rgb。对于法线来说, 仅仅判断射线是否与球相交是不够的, 我们还需求出交点的坐标。在有两个交点的情况下, 我们选取最近的交点( <math xmlns="http://www.w3.org/1998/Math/MathML"> t t </math>t最小的点)。计算与可视化球的法向量的代码如下:
c++
//main.cc 球体表面法相
double hit_sphere(const vec3& center, double radius, const ray& r) {
vec3 oc = r.origin() - center;
auto a = dot(r.direction(), r.direction());
auto b = 2.0 * dot(oc, r.direction());
auto c = dot(oc, oc) - radius*radius;
auto discriminant = b*b - 4*a*c;
if (discriminant < 0) {
return -1.0;
} else {
return (-b - sqrt(discriminant) ) / (2.0*a);
}
}
vec3 ray_color(const ray& r) {
auto t = hit_sphere(vec3(0,0,-1), 0.5, r);
if (t > 0.0) {
vec3 N = unit_vector(r.at(t) - vec3(0,0,-1));
return 0.5*vec3(N.x()+1, N.y()+1, N.z()+1);
}
vec3 unit_direction = unit_vector(r.direction());
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);
}
这会得到下面的结果:
6.2 简化射线-球相交代码
让我们重温射线-球相交函数:
c++
double hit_sphere(const point3& center, double radius, const ray& r) {
vec3 oc = center - r.origin();
auto a = dot(r.direction(), r.direction());
auto b = -2.0 * dot(r.direction(), oc);
auto c = dot(oc, oc) - radius*radius;
auto discriminant = b*b - 4*a*c;
if (discriminant < 0) {
return -1.0;
} else {
return (-b - std::sqrt(discriminant) ) / (2.0*a);
}
}
首先,回顾一下,向量点乘自身等于该向量长度的平方。
然后,将给用-2h
代替b
,这样就有一个-2的乘积因子在里面。将 <math xmlns="http://www.w3.org/1998/Math/MathML"> b = − 2 h b=-2h </math>b=−2h代入判别式方程,可得:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> − b ± b 2 − 4 a c 2 a = − ( − 2 h ) ± ( − 2 h ) 2 − 4 a c 2 a = 2 h ± 2 h 2 − 2 a c 2 a = h ± h 2 − 2 a c a \begin{align*} &\frac{-b\pm\sqrt{b^2-4ac}}{2a}\\ &=\frac{-(-2h)\pm\sqrt{(-2h)^2-4ac}}{2a}\\ &=\frac{2h\pm 2\sqrt{h^2-2ac}}{2a}\\ &=\frac{h\pm \sqrt{h^2-2ac}}{a} \end{align*} </math>2a−b±b2−4ac =2a−(−2h)±(−2h)2−4ac =2a2h±2h2−2ac =ah±h2−2ac
这样简化会和优雅,因此让我们求解 <math xmlns="http://www.w3.org/1998/Math/MathML"> h h </math>h并使用它:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> b = − 2 d ⋅ ( C − Q ) b = − 2 h h = b − 2 = d ⋅ ( C − Q ) b = -2d\cdot(C-Q)\\ b = -2h\\ h = \frac{b}{-2}=d\cdot(C-Q) </math>b=−2d⋅(C−Q)b=−2hh=−2b=d⋅(C−Q)
我们可以基于这些推导来修改我们球的相交代码如下:
c++
double hit_sphere(const point3& center, double radius, const ray& r) {
vec3 oc = center - r.origin();
auto a = r.direction().length_squared();
auto h = dot(r.direction(), oc);
auto c = oc.length_squared() - radius*radius;
auto discriminant = h*h - a*c;
if (discriminant < 0) {
return -1.0;
} else {
return (h - std::sqrt(discriminant)) / a;
}
}
6.3 抽象的可碰撞物体
现在,如果有多个球体要怎么做呢?一个非常简洁的解决方案是定义一个"抽象类"来表示所有可以被射线碰撞的物体来表示一个或多个可以被射线碰撞的球体,这比使用一个球体数组来表示要好得多。对改类的命名会进退维谷,如果不是为了"面向对象"编程,称其为"object(物体)"会比较好。"surface(表面)"可能是经常被使用的名词,但是如果我们想引入体积时,它将不再适用(雾或云等)。"hittable"强调了它们统一的功能函数集。我都不喜欢它们,但还是会选择"hittable"。
该hittable
抽象类会有一个hit
方法,接受一个射线的输入。许多光线追踪渲染器为了便利, 加入了一个区间 <math xmlns="http://www.w3.org/1998/Math/MathML"> t m i n < t < t m a x t_{min}<t<t_{max} </math>tmin<t<tmax来判断相交是否有效。对于一开始的光线来说, 这个 <math xmlns="http://www.w3.org/1998/Math/MathML"> t t </math>t值总是正的, 但加入这部分对代码实现的一些细节有着不错的帮助。现在有个设计上的问题:我们是否在每次计算求交的时候都要去计算法线?但其实我们只需要计算离射线原点最近的那个交点的法线就行了, 后面的东西会被遮挡。接下来我会给出我的代码, 并将一些计算的结果存在一个结构体里, 下面的代码展示的就是那个抽象类:
c++
#ifndef HITTABLE_H
#define HITTABLE_H
#include "ray.h"
class hit_record {
public:
point3 p;
vec3 normal;
double t;
};
class hittable {
public:
virtual ~hittable() = default;
virtual bool hit(const ray& r, double ray_tmin, double ray_tmax, hit_record& rec) const = 0;
};
#endif
下面是球体的代码实现:
c++
#ifndef SPHERE_H
#define SPHERE_H
#include "hittable.h"
#include "vec3.h"
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, hit_record& rec) const override {
vec3 oc = center - r.origin();
auto a = r.direction().length_squared();
auto h = dot(r.direction(), oc);
auto c = oc.length_squared() - radius*radius;
auto discriminant = h*h - a*c;
if (discriminant < 0)
return false;
auto sqrtd = std::sqrt(discriminant);
// Find the nearest root that lies in the acceptable range.
auto root = (h - sqrtd) / a;
if (root <= ray_tmin || ray_tmax <= root) {
root = (h + sqrtd) / a;
if (root <= ray_tmin || ray_tmax <= root)
return false;
}
rec.t = root;
rec.p = r.at(rec.t);
rec.normal = (rec.p - center) / radius;
return true;
}
private:
point3 center;
double radius;
};
#endif
请注意,我们使用C++标准函数std::fmax()
,它返回两个浮点参数中的最大值。同样,我们稍后将使用std::fmin()
,它返回两个浮点参数中的最小值。
译者注:这里使用一个 <math xmlns="http://www.w3.org/1998/Math/MathML"> t m i n t_{min} </math>tmin和 <math xmlns="http://www.w3.org/1998/Math/MathML"> t m a x t_{max} </math>tmax区间,会有效地优化tracing效率,这里当射线每次与物体相交之后,更新 <math xmlns="http://www.w3.org/1998/Math/MathML"> t m a x t_{max} </math>tmax为最近点的 <math xmlns="http://www.w3.org/1998/Math/MathML"> t t </math>t值,可以有效地提高效率。
6.4 正面与背面
第二个关于法线的设计决策就是他们是否应该总是指向外面。目前,法线的方向总是由圆心指向表面相交点(即指向外侧)。如果射线从外部射向球体,法线的方向与射线相反。如果射线从内部射向球体,法线的方向仍然是指向外部,与射线方向相同。与之相对的,我们可以让法线方向总是与射线方向相反。如果射线是从外部射向球体,则法线指向外部,否则当射线是从内部射向球体,则法线向内。
在我们着色前, 我们需要仔细考虑一下采用上面哪种方式,这对于双面材质来说至关重要。例如一张双面打印的A4纸, 或者玻璃球这样的同时具有内表面和外表面的物体。
如果我们决定让法线总是指向外部, 那我们就得在着色的时候,判断射线是从表面的那一侧进入的,我们可以将射线与指向外部的法线进行比较。如果法线与射线方向相同, 那就是从内部击中内表面, 如果相反,则是从外部击中外表面。判断方向是否相同,可以通过两个向量的点乘进行,如果为点乘值为正则表明方向相同,表明射线从球体击中球体,否则方向相反,射线从球体外部击中球体。
c++
if (dot(ray_direction, outward_normal) > 0.0) {
// ray is inside the sphere
...
} else {
// ray is outside the sphere
...
}
如果我们决定让法线总是与射线的方向相反,那么我们也不必使用一个点乘来判断射线在表面的哪一侧,相对而言,就需要保存射入面的朝向信息。
c++
bool front_face;
if (dot(ray_direction, outward_normal) > 0.0) {
// ray is inside the sphere
normal = -outward_normal;
front_face = false;
} else {
// ray is outside the sphere
normal = outward_normal;
front_face = true;
}
我们可以设置一些东西,这样法线总是从表面指向"外部",或者说总是指向入射光线。这个决定取决于你是想在几何相交阶段还是在着色阶段确定曲面的边。在这本书中,我们的材质类型比几何类型多,所以我们将减少工作量,在几何阶段进行确定。这只是一个偏好问题,您将在文献中看到这两种实现。
我们在hit_record
结构中增加一个bool
变量:front_face
。我们还要增加一个函数set_face_normal()
来完成该计算。为了方便,我们还要假设输入新函数set_frace_normal()
的向量是单位长度的。我们可以显式地对参数进行归一化,但是如果在几何阶段的代码中这么做,效率会更高。因为当你对特定的几何体更了解时,通常会更容易。
c++
class hit_record {
public:
point3 p;
vec3 normal;
double t;
bool front_face;
void set_face_normal(const ray& r, const vec3& outward_normal) {
// Sets the hit record normal vector.
// NOTE: the parameter `outward_normal` is assumed to have unit length.
front_face = dot(r.direction(), outward_normal) < 0;
normal = front_face ? outward_normal : -outward_normal;
}
};
接下来,我们给球体类增加决定表面朝向的逻辑:
c++
class sphere : public hittable {
public:
...
bool hit(const ray& r, double ray_tmin, double ray_tmax, hit_record& rec) const {
...
rec.t = root;
rec.p = r.at(rec.t);
vec3 outward_normal = (rec.p - center) / radius;
rec.set_face_normal(r, outward_normal);
return true;
}
...
};
6.5 可碰撞物体列表
我们有一个用来表示可以与射线相交的通用物体表示,叫做hittable
。我们现在增加一个类来保存可碰撞物体(hittables
列表:
c++
#ifndef HITTABLE_LIST_H
#define HITTABLE_LIST_H
#include "hittable.h"
#include <memory>
#include <vector>
using std::make_shared;
using std::shared_ptr;
class hittable_list : public hittable {
public:
std::vector<shared_ptr<hittable>> objects;
hittable_list() {}
hittable_list(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, hit_record& rec) const override {
hit_record temp_rec;
bool hit_anything = false;
auto closest_so_far = ray_tmax;
for (const auto& object : objects) {
if (object->hit(r, ray_tmin, closest_so_far, temp_rec)) {
hit_anything = true;
closest_so_far = temp_rec.t;
rec = temp_rec;
}
}
return hit_anything;
}
};
#endif
6.6 C++新特性
hittable_list
类的实现使用了一些C++特性,如果你不是C++程序员的话,这些特性可能会让你感到迷惑:vector
,shared_ptr
和make_shared
.
shared_ptr<type>
是指向某个已分配类型的指针,使用引用计数管理引用。每次将其值分配给另一个shared_ptr
(通常使用简单赋值)时,引用计数都会增加。当shared_ptr
超出作用域范围时(例如在代码块或函数末尾),shared_ptr
实例会被释放掉,引用计数会减少。一旦计数变为零,对象就会被安全删除。
通常,shared_ptr
会先用新分配的对象初始化,如下所示:
c++
hared_ptr<double> double_ptr = make_shared<double>(0.37);
shared_ptr<vec3> vec3_ptr = make_shared<vec3>(1.414214, 2.718281, 1.618034);
shared_ptr<sphere> sphere_ptr = make_shared<sphere>(point3(0,0,0), 1.0);
make_shred<thing>(thing constructor params ...)
接收一个类型参数thing
,传入thing
的构造函数的参数,这会在堆内存中使用与输入参数匹配的构造函数创建一个thing
类型的实例。
由于类型可以被make_shared<type>(...)
的返回类型推断出来,所以上述代码可以使用auto
来简化:
c++
auto double_ptr = make_shared<double>(0.37);
auto vec3_ptr = make_shared<vec3>(1.414214, 2.718281, 1.618034);
auto sphere_ptr = make_shared<sphere>(point3(0,0,0), 1.0);
我们将会在代码中使用shared_ptr
,因为它允许多个几何体共享一个实例(例如一些使用相同颜色材质的球体)原因是它使内存管理自动化且更容易被理解。
std::shared_ptr
被包含在<memory>
头文件中。
上述代码中第二个C++特性是std::vector
,也许你不太熟悉。这是一个支持任意类型的通用数组集合。上面的hittable_list
类的实现中,我们使用该集合来作为hittable
指针的数组。当更多的实例被添加时,std::vector
会自动扩容:objects.push_back(object)
将object添加到集合末尾。
std::vector
被包含在<vector>
头文件中。
最后,using
语句告诉编译器,我们从std
库中使用shared_ptr
和make_shared
,因此我们在这之后的代码中可以省略std::
前缀。
6.7 常用常量和通用函数
我们需要在头文件中定义一些常用的数学常量。目前,我们只需要infinity
这个常量,但是我们先把 <math xmlns="http://www.w3.org/1998/Math/MathML"> π \pi </math>π定义好,之后要用到。我们还将在此处添加常用的有用常量和未来的实用函数。这个新的头文件就叫rtweekend.h
。
c++
#ifndef RTWEEKEND_H
#define RTWEEKEND_H
#include <cmath>
#include <iostream>
#include <limits>
#include <memory>
// C++ Std Usings
using std::make_shared;
using std::shared_ptr;
// Constants
const double infinity = std::numeric_limits<double>::infinity();
const double pi = 3.1415926535897932385;
// Utility Functions
inline double degrees_to_radians(double degrees) {
return degrees * pi / 180.0;
}
// Common Headers
#include "color.h"
#include "ray.h"
#include "vec3.h"
#endif
程序中的文件将首先包含rtweekend.h
,因此所有其他头文件()可以隐式假设rtweekend.h
已经被包含。头文件仍然需要显式包含其他必要的头文件。我们根据这些假设做一些更新:
在color.h
中:
#include
在hittable.h
中:
#include "ray.h"
在hittable_list.h
中:
#include#include
using std::make_shared;
using std::shared_ptr;
在sphere.h
中:
#include "vec3.h"
在vec3.h
中:
#include
#include
最后程序改成:
c++
#include "rtweekend.h"
#include "hittable.h"
#include "hittable_list.h"
#include "sphere.h"
color ray_color(const ray& r, const hittable& world) {
hit_record rec;
if (world.hit(r, 0, infinity, rec)) {
return 0.5 * (rec.normal + color(1,1,1));
}
vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0, 1.0, 1.0) + a*color(0.5, 0.7, 1.0);
}
int main() {
// Image
auto aspect_ratio = 16.0 / 9.0;
int image_width = 400;
// Calculate the image height, and ensure that it's at least 1.
int image_height = int(image_width / aspect_ratio);
image_height = (image_height < 1) ? 1 : image_height;
// World
hittable_list world;
world.add(make_shared<sphere>(point3(0,0,-1), 0.5));
world.add(make_shared<sphere>(point3(0,-100.5,-1), 100));
// Camera
auto focal_length = 1.0;
auto viewport_height = 2.0;
auto viewport_width = viewport_height * (double(image_width)/image_height);
auto camera_center = point3(0, 0, 0);
// Calculate the vectors across the horizontal and down the vertical viewport edges.
auto viewport_u = vec3(viewport_width, 0, 0);
auto viewport_v = vec3(0, -viewport_height, 0);
// Calculate the horizontal and vertical delta vectors from pixel to pixel.
auto pixel_delta_u = viewport_u / image_width;
auto pixel_delta_v = viewport_v / image_height;
// Calculate the location of the upper left pixel.
auto viewport_upper_left = camera_center
- vec3(0, 0, focal_length) - viewport_u/2 - viewport_v/2;
auto pixel00_loc = viewport_upper_left + 0.5 * (pixel_delta_u + pixel_delta_v);
// Render
std::cout << "P3\n" << image_width << ' ' << image_height << "\n255\n";
for (int j = 0; j < image_height; j++) {
std::clog << "\rScanlines remaining: " << (image_height - j) << ' ' << std::flush;
for (int i = 0; i < image_width; i++) {
auto pixel_center = pixel00_loc + (i * pixel_delta_u) + (j * pixel_delta_v);
auto ray_direction = pixel_center - camera_center;
ray r(camera_center, ray_direction);
color pixel_color = ray_color(r, world);
write_color(std::cout, pixel_color);
}
}
std::clog << "\rDone. \n";
}
这样得到的图实际上只是球体位置及其表面法线的可视化。这通常是查看几何模型缺陷或特定特征的好方法。
6.8 区间类
在继续之前,我们将添加一个区间类来管理具有最小值和最大值的实值区间,这个类之后将会被经常使用。
c++
#ifndef INTERVAL_H
#define INTERVAL_H
class interval {
public:
double min, max;
interval() : min(+infinity), max(-infinity) {} // Default interval is empty
interval(double min, double max) : min(min), max(max) {}
double size() const {
return max - min;
}
bool contains(double x) const {
return min <= x && x <= max;
}
bool surrounds(double x) const {
return min < x && x < max;
}
static const interval empty, universe;
};
const interval interval::empty = interval(+infinity, -infinity);
const interval interval::universe = interval(-infinity, +infinity);
#endif
在rtweekend.h
中包含区间类:
c++
// Common Headers
#include "color.h"
#include "interval.h"
#include "ray.h"
#include "vec3.h"
hittable::hit()
中使用区间:
c++
class hittable {
public:
...
virtual bool hit(const ray& r, interval ray_t, hit_record& rec) const = 0;
};
在hittable_list::hit()
中使用区间:
c++
class hittable_list : public hittable {
public:
...
bool hit(const ray& r, interval ray_t, hit_record& rec) const override {
hit_record temp_rec;
bool hit_anything = false;
auto closest_so_far = ray_t.max;
for (const auto& object : objects) {
if (object->hit(r, interval(ray_t.min, closest_so_far), temp_rec)) {
hit_anything = true;
closest_so_far = temp_rec.t;
rec = temp_rec;
}
}
return hit_anything;
}
...
};
在sphere.h
中使用区间:
c++
class sphere : public hittable {
public:
...
bool hit(const ray& r, interval ray_t, hit_record& rec) const override {
...
// Find the nearest root that lies in the acceptable range.
auto root = (h - sqrtd) / a;
if (!ray_t.surrounds(root)) {
root = (h + sqrtd) / a;
if (!ray_t.surrounds(root))
return false;
}
...
}
...
};
在主程序中,使用区间:
c++
color ray_color(const ray& r, const hittable& world) {
hit_record rec;
if (world.hit(r, interval(0, infinity), rec)) {
return 0.5 * (rec.normal + color(1,1,1));
}
vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0, 1.0, 1.0) + a*color(0.5, 0.7, 1.0);
}
7. 定义摄像机类
在继续之前,我们将相机和场景渲染代码合并到一个新类中,camera
类。 它将负责两项工作:
- 创建光线并将其发送到世界中
- 计算这些光线的结果并用它们来渲染图像
我们将从主程序中搜集ray_color
函数中关于图像,相机和渲染相关的代码来进行重构。新得相机类将包含两个公用方法initialize()
和render()
和两个私有得辅助方法get_ray()
和ray_color()
。
最终,相机将遵循我们能想到的最简单的使用模式:无参数的默认构造函数,使用camera
的代码将通过简单的赋值修改相机的公共变量,通过调用 initialize()
函数初始化,而不是调用带有大量参数的构造函数并在内部调用各种setter
方法。此外,该方法可以让使用者只需要设置透明关心的部分。最后,我们可以让使用camera
的代码调用initialize()
,或者让其在render()
开始时自动调用此函数,在此我们将使用后者。
在主函数创建相机并设置默认值之后,它将会调用reander()
方法。render()
方法将会执行渲染前的准备逻辑并执行渲染循环。
下面是camera
类的实现:
c++
#ifndef CAMERA_H
#define CAMERA_H
#include "hittable.h"
class camera {
public:
/* Public Camera Parameters Here */
void render(const hittable& world) {
...
}
private:
/* Private Camera Variables Here */
void initialize() {
...
}
color ray_color(const ray& r, const hittable& world) const {
...
}
};
#endif
首先,让我们把ray_color()
函数的内容从main.cc
中搬过来:
c++
class camera {
...
private:
...
color ray_color(const ray& r, const hittable& world) const {
hit_record rec;
if (world.hit(r, interval(0, infinity), rec)) {
return 0.5 * (rec.normal + color(1,1,1));
}
vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0, 1.0, 1.0) + a*color(0.5, 0.7, 1.0);
}
};
#endif
然后把所有相机相关的代码从main()
函数中搬运到camera
类中。而在主函数中只保留世界构造相关的代码。下面是整合代码之后,camera
类的实现:
c++
class camera {
public:
double aspect_ratio = 1.0; // Ratio of image width over height
int image_width = 100; // Rendered image width in pixel count
void render(const hittable& world) {
initialize();
std::cout << "P3\n" << image_width << ' ' << image_height << "\n255\n";
for (int j = 0; j < image_height; j++) {
std::clog << "\rScanlines remaining: " << (image_height - j) << ' ' << std::flush;
for (int i = 0; i < image_width; i++) {
auto pixel_center = pixel00_loc + (i * pixel_delta_u) + (j * pixel_delta_v);
auto ray_direction = pixel_center - center;
ray r(center, ray_direction);
color pixel_color = ray_color(r, world);
write_color(std::cout, pixel_color);
}
}
std::clog << "\rDone. \n";
}
private:
int image_height; // Rendered image height
point3 center; // Camera center
point3 pixel00_loc; // Location of pixel 0, 0
vec3 pixel_delta_u; // Offset to pixel to the right
vec3 pixel_delta_v; // Offset to pixel below
void initialize() {
image_height = int(image_width / aspect_ratio);
image_height = (image_height < 1) ? 1 : image_height;
center = point3(0, 0, 0);
// Determine viewport dimensions.
auto focal_length = 1.0;
auto viewport_height = 2.0;
auto viewport_width = viewport_height * (double(image_width)/image_height);
// Calculate the vectors across the horizontal and down the vertical viewport edges.
auto viewport_u = vec3(viewport_width, 0, 0);
auto viewport_v = vec3(0, -viewport_height, 0);
// Calculate the horizontal and vertical delta vectors from pixel to pixel.
pixel_delta_u = viewport_u / image_width;
pixel_delta_v = viewport_v / image_height;
// Calculate the location of the upper left pixel.
auto viewport_upper_left =
center - vec3(0, 0, focal_length) - viewport_u/2 - viewport_v/2;
pixel00_loc = viewport_upper_left + 0.5 * (pixel_delta_u + pixel_delta_v);
}
color ray_color(const ray& r, const hittable& world) const {
...
}
};
#endif
下面是精简之后的主函数:
c++
#include "rtweekend.h"
#include "camera.h"
#include "hittable.h"
#include "hittable_list.h"
#include "sphere.h"
color ray_color(const ray& r, const hittable& world) {
...
}
int main() {
hittable_list 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.aspect_ratio = 16.0 / 9.0;
cam.image_width = 400;
cam.render(world);
}
再一次跑这段代码,会得到与前面相同的图像。
译者注:这里把渲染相关的代码和相机耦合在一起,个人认为并不是一个好的选项,所以在我翻译过程中写的代码,将会定义一个renderer
来整合camera
,world
和ppm文件的。
8. 抗锯齿
如果放大渲染图像,发现物体边缘存在一些"阶梯",这种通常被称为"aliasing"或 "jaggies"。使用真正的相机拍摄照片时,物体边缘通常没有锯齿状,因为物体边缘的像素是一些前景和背景的混合,即世界的真实图像是连续的,或者说,真实世界具有近乎无限的分辨率。我们可以对每个像素的若干样本取平均来获得类似的效果。
当单条光线穿过像素的中心时,就是在执行我们通常所说的"点采样"。点采样的问题可以通过在远处渲染一个棋盘的例子来说明。如果这个棋盘由许多黑白格子组成,但只有四条光线击中它,那么所有四条光线可能只与白色格子相交,或只与黑色相交,因此在我们的渲染图像中会出现黑白的尖锐点,这不是一种好的结果。而在现实世界中,当我们用眼睛感知远处的棋盘时,我们会将其视为灰色,而不是黑白的尖锐点,因为眼睛自动地在做我们希望光线追踪渲染器做的事情:对落在渲染图像的特定区域(离散)上的(连续的)光线进行整合。
如果对穿过某一像素中心的同一光线多次重新采样,将不会获得任何不同的结果,因为返回的结果总是相同的。 因此,我们需要对落在这个像素中心周围的光也进行采样,然后整合这些样本以近似真实的连续结果。 那么如何整合落在像素周围的光线呢?
我们将采用最简单的模型:对以像素为中心的方形区域进行采样,该采样区域将延伸到四个相邻像素中的每个像素的一半。这不是最佳的方法,但它是最直接的方法。(参考 A Pixel is Not a Little Square 中对此问题的深度讨论)
8.1. 一些随机数实用程序
我们需要一个能够返回随机实数的随机数生成器。该函数返回一个随机数n( <math xmlns="http://www.w3.org/1998/Math/MathML"> 0 ≤ n < 1 0 \le n \lt 1 </math>0≤n<1)。小于1很关键,因为我们有时会利用这个性质。
一个简单的方实现方法是使用<cstdlib>
提供的std::rand()
函数来返回一个0到RAND_MAX
的随机数。由此,我们可以通过以下代码片段获得所需的实数随机数,把这部分代码加入到rtweekend.h
中。
c++
#include <cmath>
#include <cstdlib>
#include <iostream>
#include <limits>
#include <memory>
...
// Utility Functions
inline double degrees_to_radians(double degrees) {
return degrees * pi / 180.0;
}
inline double random_double() {
// Returns a random real in [0,1).
return std::rand() / (RAND_MAX + 1.0);
}
inline double random_double(double min, double max) {
// Returns a random real in [min,max).
return min + (max-min)*random_double();
}
在过去,C++并没有一个标准的随机数生成器,但是新版本的C++引入<random>
把随机数相关的代码写在里面,解决了这个问题(虽然一些专家认为不完美)。如果你想使用它,可以参考下面的代码:
c++
...
#include <random>
...
inline double random_double() {
static std::uniform_real_distribution<double> distribution(0.0, 1.0);
static std::mt19937 generator;
return distribution(generator);
}
...
8.2. 基于多重采样生成像素
对于由多个样本组成的单个像素,我们将从像素周围的区域中选择样本,并对得到的光线追踪的结果(颜色)进行平均。
首先,我们要更新write_color()
函数以确定我们将要使用的采样数:我们需要所有的采样结果来计算平均值。为此,我们需要把每一次采样的结果相加,最后除以样本总数,然后把平均结果写入。我们给interval
类增加一个interval::clamp(x)
方法以确保输出的颜色中每个分量的取值范围在0到1之间。
c++
class interval {
public:
...
bool surrounds(double x) const {
return min < x && x < max;
}
double clamp(double x) const {
if (x < min) return min;
if (x > max) return max;
return x;
}
...
};
下面的代码是更新后的write_color()
函数,增加了对interval::clamp
函数的调用:
c++
#include "interval.h"
#include "vec3.h"
using color = vec3;
void write_color(std::ostream& out, const color& pixel_color) {
auto r = pixel_color.x();
auto g = pixel_color.y();
auto b = pixel_color.z();
// Translate the [0,1] component values to the byte range [0,255].
static const interval intensity(0.000, 0.999);
int rbyte = int(256 * intensity.clamp(r));
int gbyte = int(256 * intensity.clamp(g));
int bbyte = int(256 * intensity.clamp(b));
// Write out the pixel color components.
out << rbyte << ' ' << gbyte << ' ' << bbyte << '\n';
}
现在更新camera
类,增加一个对每个像素生成不同采样的新函数 get_ray(int i, int j)
,在该函数内使用一个新的辅助函数 sample_square()
,这个辅助函数在以原点为中心的方格像素内生成一个随机采样点。我们随后将多重采样的平均颜色作为该像素的颜色:
Now let's update the camera class to define and use a new camera::get_ray(i,j)
function, which will generate different samples for each pixel. This function will use a new helper function sample_square()
that generates a random sample point within the unit square centered at the origin. We then transform the random sample from this ideal square back to the particular pixel we're currently sampling.
c++
class camera {
public:
double aspect_ratio = 1.0; // Ratio of image width over height
int image_width = 100; // Rendered image width in pixel count
int samples_per_pixel = 10; // Count of random samples for each pixel
void render(const hittable& world) {
initialize();
std::cout << "P3\n" << image_width << ' ' << image_height << "\n255\n";
for (int j = 0; j < image_height; j++) {
std::clog << "\rScanlines remaining: " << (image_height - j) << ' ' << std::flush;
for (int i = 0; i < image_width; i++) {
color pixel_color(0,0,0);
for (int sample = 0; sample < samples_per_pixel; sample++) {
ray r = get_ray(i, j);
pixel_color += ray_color(r, world);
}
write_color(std::cout, pixel_samples_scale * pixel_color);
}
}
std::clog << "\rDone. \n";
}
...
private:
int image_height; // Rendered image height
double pixel_samples_scale; // Color scale factor for a sum of pixel samples
point3 center; // Camera center
point3 pixel00_loc; // Location of pixel 0, 0
vec3 pixel_delta_u; // Offset to pixel to the right
vec3 pixel_delta_v; // Offset to pixel below
void initialize() {
image_height = int(image_width / aspect_ratio);
image_height = (image_height < 1) ? 1 : image_height;
pixel_samples_scale = 1.0 / samples_per_pixel;
center = point3(0, 0, 0);
...
}
ray get_ray(int i, int j) const {
// Construct a camera ray originating from the origin and directed at randomly sampled
// point around the pixel location i, j.
auto offset = sample_square();
auto pixel_sample = pixel00_loc
+ ((i + offset.x()) * pixel_delta_u)
+ ((j + offset.y()) * pixel_delta_v);
auto ray_origin = center;
auto ray_direction = pixel_sample - ray_origin;
return ray(ray_origin, ray_direction);
}
vec3 sample_square() const {
// Returns the vector to a random point in the [-.5,-.5]-[+.5,+.5] unit square.
return vec3(random_double() - 0.5, random_double() - 0.5, 0);
}
color ray_color(const ray& r, const hittable& world) const {
...
}
};
#endif
(除了上面的sample_square()
函数,你还可以从github仓库的源码中找到sample_disk()
的函数实现。因为考虑到你可能尝试非方格像素的采样方式,所以把它包含进来了,但是我们这本书不会使用它。sample_disk()
依赖我们后面定义的一个函数:random_in_unit_disk()
)
主函数中加入设置新相机参数的代码:
c++
int main() {
...
camera cam;
cam.aspect_ratio = 16.0 / 9.0;
cam.image_width = 400;
cam.samples_per_pixel = 100;
cam.render(world);
}
执行上面代码,并放大输出的图像。可以看到在集合体边缘处和之前的图像有区别。
9. 漫反射材质
到目前为止,我们在场景中放置了多个物体并实现了射线的多重采样逻辑,接下来我们可以做一些看起来具有真实感的材质。我们首先从漫反射材质开始。我们把几何物体和材质分开,这有利于我们可以把材质赋值给多个物体,还是说让他们紧密结合,这对于几何体和材质紧密链接的程序化生成的对象可能很有用。我们会采用材质和几何物体分开,因为该方案在大多数rennderer中比较流行,但请注意,还有其他方法。
9.1. 简单的漫反射材质
自身不发射光线的漫反射物体仅呈现周围的颜色,他们确实通过调节自身颜色来逼近这个颜色。从漫反射表面反射的光的方向是随机的,因此,如果我们将三条光线射入两个漫反射表面之间的裂缝,它们将各自具有不同的随机行为:
除了反射光线之外,还有一部分被吸收,表面越暗,就表明光线被吸收得越多(这也是为什么表面是黑色的原因)。实际上,任何随机化方向的算法都会产生看起来无光泽的表面。让我们从最直观的开始:一个随机向所有方向均匀反射光线的表面。对于这种材质,照射到表面的入射光线在往所有出射方向反弹的概率相等。
这种非常直观的材质是最简单的漫反射类型,事实上,许多最早的光线追踪的论文都使用了这种漫反射方法(在采用更准确的方法之前,我们稍后将实现这种方法)。我们目前没有办法随机反射光线,因此我们需要向vec3.h
中添加一些函数。我们首先需要的是生成任意随机向量的能力:
c++
class vec3 {
public:
...
double length_squared() const {
return e[0]*e[0] + e[1]*e[1] + e[2]*e[2];
}
static vec3 random() {
return vec3(random_double(), random_double(), random_double());
}
static vec3 random(double min, double max) {
return vec3(random_double(min,max), random_double(min,max),
random_double(min,max));
}
};
然后,我们需要解决如何让随机射线在我们定义的半球面上。有一些分析方法可以做到这一点,但是它们大多都难以理解,而且实现起来有一些复杂。相反,我们将使用通常最简单的算法:拒绝方法。拒绝方法的工作原理是重复生成随机样本,直到我们产生满足输入条件的样本。换句话说,不断拒绝不良样本,直到找到好的样本。
使用拒绝方法在半球上生成随机向量有许多同样有效的方法,但为了我们的目的,我们将使用最简单的方法,即:
- 在单位球上生成随机向量
- 归一化该向量使它的长度达到单位球表面
- 如果该向量在错误的半球面上,翻转它
首先,我们将会使用拒绝方法来生成随机向量在单位球内(即半径为1的球)。在单位球的包围立方体内随机选取一个点(让x,y和z坐标落在范围[-1,1]之间即可实现)。如果该点落在单位球外,就生成一个新的点,直到我们获得一个落在单位球内的点。
下面是我们实现这个函数的初稿:
c++
...
inline vec3 unit_vector(const vec3& v) {
return v / v.length();
}
inline vec3 random_unit_vector() {
while (true) {
auto p = vec3::random(-1,1);
auto lensq = p.length_squared();
if (lensq <= 1)
return p / sqrt(lensq);
}
}
遗憾的是,我们需要解决浮点数溢出的问题。因为浮点数只有有限的精度,在一个很小的值在平方之后,可能会下溢到0。因此,如果三个分量都非常小的时候(即非常靠近球心),对该向量的归一化可能会得到0,这样归一化会得到一个伪向量,即 <math xmlns="http://www.w3.org/1998/Math/MathML"> [ ± ∞ , ± ∞ , ± ∞ ] [\pm\infty, \pm\infty, \pm\infty] </math>[±∞,±∞,±∞]。为了修复这个错误,我们需要拒绝那些在球心周围"黑洞"区域的点。由于doubled的精度很高(64-bit floats),我们让数值与 <math xmlns="http://www.w3.org/1998/Math/MathML"> 1 0 − 160 10^{-160} </math>10−160比较是安全的。
下面是比较鲁棒的实现:
c++
inline vec3 random_unit_vector() {
while (true) {
auto p = vec3::random(-1,1);
auto lensq = p.length_squared();
if (1e-160 < lensq && lensq <= 1)
return p / sqrt(lensq);
}
}
至此,我们有了一个在单位球表面上的随机向量,我们可以判断通过将该向量与表面法线进行比较,它是否在正确的半球内:
我们可以计算随机向量和表面法线的点积来判断它是否在正确的半球内。如果点积的结果是正值,则随机向量在正确的半球内,否则我们就要将它反转。
c++
...
inline vec3 random_unit_vector() {
while (true) {
auto p = vec3::random(-1,1);
auto lensq = p.length_squared();
if (1e-160 < lensq && lensq <= 1)
return p / sqrt(lensq);
}
}
inline vec3 random_on_hemisphere(const vec3& normal) {
vec3 on_unit_sphere = random_unit_vector();
if (dot(on_unit_sphere, normal) > 0.0) // In the same hemisphere as the normal
return on_unit_sphere;
else
return -on_unit_sphere;
}
如果光线从材质上反弹并保持100%的颜色,那么我们说该材质是白色的。如果光线从材质上反弹并保持其颜色的0%,则我们说该材质是黑色的。作为漫反射材质的首次演示,我们将ray_color
函数设置为从反弹中返回50%的颜色。我们应该期望得到一个漂亮的灰色。
c++
class camera {
...
private:
...
color ray_color(const ray& r, const hittable& world) const {
hit_record rec;
if (world.hit(r, interval(0, infinity), rec)) {
vec3 direction = random_on_hemisphere(rec.normal);
return 0.5 * ray_color(ray(rec.p, direction), world);
}
vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0, 1.0, 1.0) + a*color(0.5, 0.7, 1.0);
}
};
...事实上,我们确实得到了相当漂亮的灰色球体:
9.2. 有限数量的子射线
还有一个潜在的问题。注意到ray_color
函数是递归的。那么何时递归会结束呢?目前代码中的终止条件是:当射线没有打到任何物体时。然而,这样消耗的时间可能会非常久------长到可能会炸穿栈。为了防止这种情况发生,我们应该设置一个最大递归深度,当达到最大递归深度时返回一个颜色,表明没有光照贡献(通常是黑色):
c++
class camera {
public:
double aspect_ratio = 1.0; // Ratio of image width over height
int image_width = 100; // Rendered image width in pixel count
int samples_per_pixel = 10; // Count of random samples for each pixel
int max_depth = 10; // Maximum number of ray bounces into scene
void render(const hittable& world) {
initialize();
std::cout << "P3\n" << image_width << ' ' << image_height << "\n255\n";
for (int j = 0; j < image_height; j++) {
std::clog << "\rScanlines remaining: " << (image_height - j) << ' ' << std::flush;
for (int i = 0; i < image_width; i++) {
color pixel_color(0,0,0);
for (int sample = 0; sample < samples_per_pixel; sample++) {
ray r = get_ray(i, j);
pixel_color += ray_color(r, max_depth, world);
}
write_color(std::cout, pixel_samples_scale * pixel_color);
}
}
std::clog << "\rDone. \n";
}
...
private:
...
color ray_color(const ray& r, int depth, const hittable& world) const {
// If we've exceeded the ray bounce limit, no more light is gathered.
if (depth <= 0)
return color(0,0,0);
hit_record rec;
if (world.hit(r, interval(0, infinity), rec)) {
vec3 direction = random_on_hemisphere(rec.normal);
return 0.5 * ray_color(ray(rec.p, direction), depth-1, world);
}
vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0, 1.0, 1.0) + a*color(0.5, 0.7, 1.0);
}
};
更新main()
函数,给相机添加递归深度限制:
c++
int main() {
...
camera cam;
cam.aspect_ratio = 16.0 / 9.0;
cam.image_width = 400;
cam.samples_per_pixel = 100;
cam.max_depth = 50;
cam.render(world);
}
对于这个非常简单的场景,我们得到了几乎一致的结果:
9.3. 修复Shadow Acne
还有一个微妙的错误需要我们解决。当射线与表面相交时,它将尝试准确计算交点。不幸的是,这种计算容易受到浮点舍入误差的影响,这可能导致交点略微偏离。这意味着下一条光线的原点,即从表面随机散射的光线,不太可能与表面完全齐平。它可能就在表面之上。它可能就在表面之下。如果光线的原点刚好在表面下方,则它可能会再次与该表面相交。这类点可能是 <math xmlns="http://www.w3.org/1998/Math/MathML"> h i t hit </math>hit函数返回给我们的经过近似值处理的表面交点。解决此问题的最简单方法就是忽略掉与射线原点非常接近的射线相交的结果:
c++
class camera {
...
private:
...
color ray_color(const ray& r, int depth, const hittable& world) const {
// If we've exceeded the ray bounce limit, no more light is gathered.
if (depth <= 0)
return color(0,0,0);
hit_record rec;
if (world.hit(r, interval(0.001, infinity), rec)) {
vec3 direction = random_on_hemisphere(rec.normal);
return 0.5 * ray_color(ray(rec.p, direction), depth-1, world);
}
vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0, 1.0, 1.0) + a*color(0.5, 0.7, 1.0);
}
};
这就解决了Shadow Acne的问题。下图展示的是修正后的渲染的结果:
9.4. 真正的Lambertian反射
在半球上均匀散射反射光线会产生一个很好的软漫反射模型,但我们绝对可以做得更好。真实漫反射对象的更准确表示是Lambertian 分布。此分布以与 <math xmlns="http://www.w3.org/1998/Math/MathML"> c o s ( ϕ ) cos(\phi) </math>cos(ϕ) 成比例的方式散射反射光线,其中 <math xmlns="http://www.w3.org/1998/Math/MathML"> ϕ \phi </math>ϕ是反射射线与表面法线之间的角度。这意味着反射射线更倾向于散射在法线附近。与我们之前的均匀散射相比,这种非均匀Lambertian分布在现实世界中更好地模拟了材质反射。
我们可以通过在法向量中添加一个随机单位向量来创建这个分布。在相交表面上,有一个交点 <math xmlns="http://www.w3.org/1998/Math/MathML"> p p </math>p,表面的法线 <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n。在相交点上,表面有两面,因此,与任何交点相切的只能有两个唯一的单位球体(曲面的每一侧有一个唯一的球体)。这两个单位球体的球心将从相交表面位移其半径长度,对于单位球体来说,半径长度正好为 1。
其中一个球体位于表面法线的正面( <math xmlns="http://www.w3.org/1998/Math/MathML"> + n +n </math>+n),另一个位于反面( <math xmlns="http://www.w3.org/1998/Math/MathML"> − n -n </math>−n)。这给我们留下了两个单位球,它们在射线与几何表面的交点处相切。由此,其中一个球的球心在 <math xmlns="http://www.w3.org/1998/Math/MathML"> P + n P+n </math>P+n的位置,拎一个在 <math xmlns="http://www.w3.org/1998/Math/MathML"> P − n P-n </math>P−n的位置。球心在 <math xmlns="http://www.w3.org/1998/Math/MathML"> P + n P+n </math>P+n位置的球体被认为是在相交表面的外侧,而 <math xmlns="http://www.w3.org/1998/Math/MathML"> P − n P-n </math>P−n位置的球体被认为是在相交表面的内侧。
我们想要选择与射线原点相对于相交表面同一侧的相切单位球。然后在该单位球面上随机选取一点 <math xmlns="http://www.w3.org/1998/Math/MathML"> S S </math>S并从表面相交点 <math xmlns="http://www.w3.org/1998/Math/MathML"> P P </math>P上向该随机点 <math xmlns="http://www.w3.org/1998/Math/MathML"> S S </math>S发射射线(新的射线方向是 <math xmlns="http://www.w3.org/1998/Math/MathML"> S − P S-P </math>S−P):
实际上,需要更新的代码不多:
c++
class camera {
...
color ray_color(const ray& r, int depth, const hittable& world) const {
// If we've exceeded the ray bounce limit, no more light is gathered.
if (depth <= 0)
return color(0,0,0);
hit_record rec;
if (world.hit(r, interval(0.001, infinity), rec)) {
vec3 direction = rec.normal + random_unit_vector(); // 只更新一行
return 0.5 * ray_color(ray(rec.p, direction), depth-1, world);
}
vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0, 1.0, 1.0) + a*color(0.5, 0.7, 1.0);
}
};
运行更新后的代码,得到类似的渲染图像:
鉴于我们的两个球体场景非常简单,因此很难分辨这两种漫反射方法之间的区别,但您应该能够注意到两个重要的视觉差异:
- 修改后阴影更加明显
- 更改后,两个球体在天空中都呈蓝色
这两个变化都是由于光线的散射不太均匀------更多的光线向法线散射。这意味着对于漫反射对象,它们将显得更暗,因为反射到摄像机的光线较少。对于阴影,更多的光线会直接向上反射,因此球体下方的区域会更暗。
日常常见的物体并不是完全漫射的,因此我们对这些物体在光线照射的视觉直觉可能很糟糕。在本书中,场景会变得越来越复杂,建议您可以在本书介绍的不同漫反射模型之间切换。大多数有趣的场景将包含大量的漫反射材质。通过了解不同漫反射模型对场景照明的影响,您可以获得更多有价值的见解。
(译者注:在看这一段的时候,觉得很有问题,图片明明是更亮了。我的理解是,Lambertian反射模型,更多的反射光线集中在法线部分,因此反射到天空的光线更多了,所以会更亮,而在球的底部区域,相交法线是指向上方的,与上方球体相交更多,因此会变暗,形成阴影。而之前的方法是往各个方向均匀反射,也就是说交到物体的光线会更多,射向天空的光线更少,所以之前的方法更暗了。如果我的英语理解没有问题的话,应该是这样的)
9.5. 使用Gamma校正实现准确的颜色强度
请注意球体下方的阴影。图片很暗,但我们的球体只吸收了每次反弹的一半能量,因此它们是 50% 的反射率。球体应该看起来非常亮(在现实生活中为浅灰色),但它们看起来相当暗。如果我们遍历漫反射材质的完整亮度色域,我们可以更清楚地看到这一点。我们首先将ray_color
函数的反射率从0.5'
(50%) 设置为0.1
(10%):
c++
class camera {
...
color ray_color(const ray& r, int depth, const hittable& world) const {
// If we've exceeded the ray bounce limit, no more light is gathered.
if (depth <= 0)
return color(0,0,0);
hit_record rec;
if (world.hit(r, interval(0.001, infinity), rec)) {
vec3 direction = rec.normal + random_unit_vector();
return 0.1 * ray_color(ray(rec.p, direction), depth-1, world);
}
vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0, 1.0, 1.0) + a*color(0.5, 0.7, 1.0);
}
};
我们以这个新的10%反射率进行渲染。然后,我们将Reflectance设置为30%并再次渲染。我们重复50%、70%,最后是90%。您可以在您选择的照片编辑器中从左到右叠加这些图像,您应该可以直观地看到所选色域随着亮度的增加,视觉表现更好。这是我们迄今为止一直在做的:
如果您仔细观察,或者使用颜色选择器,您应该注意到50%反射率渲染(中间的渲染结果)太暗了,无法介于白色和黑色(中间灰色)之间。事实上,70%的反射率更接近中间灰度。原因是几乎所有计算机程序都假定图像在写入图像文件之前已经过"伽玛校正"。这意味着0到1的值在存储为字节之前会做一些转换。如果数据写入时未进行转换,则称其位于线性空间中。您使用的图片浏览器可能期望Gamma空间中的图像,但我们为其提供的是线性空间中的图像。这就是我们的图像看起来不准确,偏暗的原因。
为什么应该将图像存储在Gamma空间中有很多充分的理由,但为了达到我们的目的,我们只需要意识到这一点。我们将数据转换为Gamma空间,以便我们的图片浏览器可以更准确地显示我们的图像。作为一个简单的近似值,我们可以使用 "gamma 2" 作为我们的转换,这是从gamma空间到线性空间时使用的幂。我们需要从线性空间转到gamma空间,这意味着取 "gamma 2" 的倒数,即 <math xmlns="http://www.w3.org/1998/Math/MathML"> 1 g a m m a \frac{1}{gamma} </math>gamma1作为指数,它只是平方根。我们还希望确保我们能够鲁棒地处理负输入。
c++
inline double linear_to_gamma(double linear_component)
{
if (linear_component > 0)
return std::sqrt(linear_component);
return 0;
}
void write_color(std::ostream& out, const color& pixel_color) {
auto r = pixel_color.x();
auto g = pixel_color.y();
auto b = pixel_color.z();
// Apply a linear to gamma transform for gamma 2
r = linear_to_gamma(r);
g = linear_to_gamma(g);
b = linear_to_gamma(b);
// Translate the [0,1] component values to the byte range [0,255].
static const interval intensity(0.000, 0.999);
int rbyte = int(256 * intensity.clamp(r));
int gbyte = int(256 * intensity.clamp(g));
int bbyte = int(256 * intensity.clamp(b));
// Write out the pixel color components.
out << rbyte << ' ' << gbyte << ' ' << bbyte << '\n';
}
应用Gamma校正之后,我们现在可以得到从暗到亮的更一致的过渡:
10. 金属
10.1. 材质的抽象类
我们可以做一个设计,让不同的物体有不同的材质。我们可以设计一个有着很多参数的通用的材质类,对于那些单独的材质可以忽略那些不影响它们效果的参数。这并不是一个特别糟糕的方法。此外,我们也可以设计一个抽象的材质类来封装独特的行为。我比较倾向于后者。对于我们的程序,材质需要完成下面两件事情:
- 生成散射光线(或者表明它对光线的吸收率)。
- 如果发生散射,则表明光线应该衰减多少。
这里给出抽象类的实现示例:
c++
#ifndef MATERIAL_H
#define MATERIAL_H
#include "hittable.h"
class material {
public:
virtual ~material() = default;
virtual bool scatter(
const ray& r_in, const hit_record& rec, color& attenuation, ray& scattered
) const {
return false;
}
};
#endif
10.2. 描述物体-射线相交的数据结构
使用hit_record
可以避免一堆参数,因此我们可以把任何我们想要的信息放进去。你可以用参数代替封装类型,这只是个人喜好的问题。Hittable
的物体和材质需要能够在代码中引用对方的类型,所以存在一些引用的循环性。在 C++ 中,我们添加了一行class material
; 来告诉编译器material
是一个稍后会定义的类。因为我们只是指定了一个指向类的指针,编译器不需要知道类的细节,这解决了循环引用的问题。
c++
class material;
class hit_record {
public:
point3 p;
vec3 normal;
shared_ptr<material> mat;
double t;
bool front_face;
void set_face_normal(const ray& r, const vec3& outward_normal) {
front_face = dot(r.direction(), outward_normal) < 0;
normal = front_face ? outward_normal : -outward_normal;
}
};
hit_record
只是一种将一堆参数塞入一个类的方法,这样我们就可以把它们作为一个组发送。当一条光线撞击一个表面(例如一个特定的球体)时,hit_record
中的材质指针将被设置为指向在main()
中设置球体时给予的材质指针。当 ray_color()
程序获取hit_record
时,它可以调用材料指针的成员函数来找出是否有光线被散射。
为了实现这一点,需要把分配给球体的材质分配给hit_record
中的mat
成员。
c++
class sphere : public hittable {
public:
sphere(const point3& center, double radius) : center(center), radius(std::fmax(0,radius)) {
// TODO: Initialize the material pointer `mat`.
}
bool hit(const ray& r, interval ray_t, hit_record& rec) const override {
...
rec.t = root;
rec.p = r.at(rec.t);
vec3 outward_normal = (rec.p - center) / radius;
rec.set_face_normal(r, outward_normal);
rec.mat = mat;
return true;
}
private:
point3 center;
double radius;
shared_ptr<material> mat;
};
10.3. 光线散射和反射模型
在本书中,我们将会使用词汇:反照率(Albedo)(在拉丁语中叫"whiteness")。反照率在某些学科中是一个精确的技术术语,但在所有情况下,它都用于定义某种形式的反射比例。反照率会随材质的颜色而变化(例如我们等会儿将要实现的玻璃材质),也会随入射观察方向 (入射光线的方向) 而变化。
Lambertian反射(漫反射)可以是完全散射并通过其反射率R进行衰减,或者它可以有部分散射(概率为1−R)不进行衰减(而没有散射的光线就被物体完全吸收)。它也可以是二者的结合。 我们选择前者,所以Lambertian材质变成了这样一个简单类:
译者注:第二段的翻译看原文作者给出了两种散射方案,第一种是完全散射,没有被吸收,但是每根散射的光线会有一定程度的能力衰减。而第二种是散射的部分没有能量衰减,没有散射的部分就被完全吸收了,第二种会比较符合能量守恒定律,不过这里用了第一种,实现起来比较简单。
c++
class material {
...
};
class lambertian : public material {
public:
lambertian(const color& albedo) : albedo(albedo) {}
bool scatter(const ray& r_in, const hit_record& rec, color& attenuation, ray& scattered)
const override {
auto scatter_direction = rec.normal + random_unit_vector();
scattered = ray(rec.p, scatter_direction);
attenuation = albedo;
return true;
}
private:
color albedo;
};
注意到scatter
函数的第三行:我们可以基于固定的概率 <math xmlns="http://www.w3.org/1998/Math/MathML"> p p </math>p把衰减系数设置成 <math xmlns="http://www.w3.org/1998/Math/MathML"> a l b e d o p \frac{albedo}{p} </math>palbedo。由你选择。
如果你仔细阅读上面的代码,你会注意到一个小问题。如果我们生成的随机单位向量与法线相反,二者加起来刚好是0,这就导致散射光线的方向刚好是零向量。这会导致最坏的情况发生------无穷大和NAN
的错误。因此我们需要增加一些条件避免这种情况。
为了解决这个问题,我们给vector定义了一个新方法------vec3::near_zero()
------如果向量的所有分量非常接近0,该方法返回true。
下面的代码将会使用C++的标准库函数std::fabs
,该函数返回一个输入数的绝对值。
c++
class vec3 {
...
double length_squared() const {
return e[0]*e[0] + e[1]*e[1] + e[2]*e[2];
}
bool near_zero() const {
// Return true if the vector is close to zero in all dimensions.
auto s = 1e-8;
return (std::fabs(e[0]) < s) && (std::fabs(e[1]) < s) && (std::fabs(e[2]) < s);
}
...
};
c++
class lambertian : public material {
public:
lambertian(const color& albedo) : albedo(albedo) {}
bool scatter(const ray& r_in, const hit_record& rec, color& attenuation, ray& scattered)
const override {
auto scatter_direction = rec.normal + random_unit_vector();
// Catch degenerate scatter direction
if (scatter_direction.near_zero())
scatter_direction = rec.normal;
scattered = ray(rec.p, scatter_direction);
attenuation = albedo;
return true;
}
private:
color albedo;
};
10.4. 光线的镜面反射
对于抛光金属,光线不会随机散射。关键问题是:光线如何从金属镜面反射?这时候就要运用到数学中的向量知识了:
图中红色的向量方向是 <math xmlns="http://www.w3.org/1998/Math/MathML"> v + 2 b v v+2bv </math>v+2bv。在我们的程序中, <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n是单位向量(长度为1),但 <math xmlns="http://www.w3.org/1998/Math/MathML"> v v </math>v不一定是。为了获得向量 <math xmlns="http://www.w3.org/1998/Math/MathML"> b b </math>b,我们将向量 <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n乘以向量 <math xmlns="http://www.w3.org/1998/Math/MathML"> v v </math>v在向量 <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n方向上的投影长度,这可以通过向量点乘实现 <math xmlns="http://www.w3.org/1998/Math/MathML"> v ⋅ n v\cdot n </math>v⋅n。(如果 <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n不是单位向量,我们就需要将点乘结果除以向量 <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n的长度)。最后,因为 <math xmlns="http://www.w3.org/1998/Math/MathML"> v v </math>v指向相交表面的内侧,并且我们希望 <math xmlns="http://www.w3.org/1998/Math/MathML"> b b </math>b指向表面外侧,因此需要乘以-1。
把这些实现如下,我们就可以计算反射向量:
译者注:根据图中所示, <math xmlns="http://www.w3.org/1998/Math/MathML"> d o t ( v , n ) dot(v,n) </math>dot(v,n)就是向量 <math xmlns="http://www.w3.org/1998/Math/MathML"> b b </math>b的长度,由于 <math xmlns="http://www.w3.org/1998/Math/MathML"> v v </math>v指向相交面内侧,所以点乘结果必然是负数,进而可以推导出向量 <math xmlns="http://www.w3.org/1998/Math/MathML"> b b </math>b是与 <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n的方向相反的,但是反射光线在外侧,所以乘一个负一是为了得到指向表面外侧的偏移向量(图中展示的是最终的 <math xmlns="http://www.w3.org/1998/Math/MathML"> b b </math>b向量),乘以2是镜面反射定律决定的------入射角等于反射角。
c++
...
inline vec3 random_on_hemisphere(const vec3& normal) {
...
}
inline vec3 reflect(const vec3& v, const vec3& n) {
return v - 2*dot(v,n)*n;
}
金属材质可以使用这个公式反射光线:
c++
...
class lambertian : public material {
...
};
class metal : public material {
public:
metal(const color& albedo) : albedo(albedo) {}
bool scatter(const ray& r_in, const hit_record& rec, color& attenuation, ray& scattered)
const override {
vec3 reflected = reflect(r_in.direction(), rec.normal);
scattered = ray(rec.p, reflected);
attenuation = albedo;
return true;
}
private:
color albedo;
};
我们需要修改ray_color()
函数,应用前面的修改:
c++
#include "hittable.h"
#include "material.h"
...
class camera {
...
private:
...
color ray_color(const ray& r, int depth, const hittable& world) const {
// If we've exceeded the ray bounce limit, no more light is gathered.
if (depth <= 0)
return color(0,0,0);
hit_record rec;
if (world.hit(r, interval(0.001, infinity), rec)) {
ray scattered;
color attenuation;
if (rec.mat->scatter(r, rec, attenuation, scattered))
return attenuation * ray_color(scattered, depth-1, world);
return color(0,0,0);
}
vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0, 1.0, 1.0) + a*color(0.5, 0.7, 1.0);
}
};
我们还要更新sphere
的构造方法初始化材质指针mat
:
c++
class sphere : public hittable {
public:
sphere(const point3& center, double radius, shared_ptr<material> mat)
: center(center), radius(std::fmax(0,radius)), mat(mat) {}
...
};
10.5. 金属球场景
现在让我们往场景中添加一些金属球:
c++
#include "rtweekend.h"
#include "camera.h"
#include "hittable.h"
#include "hittable_list.h"
#include "material.h"
#include "sphere.h"
int main() {
hittable_list world;
auto material_ground = make_shared<lambertian>(color(0.8, 0.8, 0.0));
auto material_center = make_shared<lambertian>(color(0.1, 0.2, 0.5));
auto material_left = make_shared<metal>(color(0.8, 0.8, 0.8));
auto material_right = make_shared<metal>(color(0.8, 0.6, 0.2));
world.add(make_shared<sphere>(point3( 0.0, -100.5, -1.0), 100.0, material_ground));
world.add(make_shared<sphere>(point3( 0.0, 0.0, -1.2), 0.5, material_center));
world.add(make_shared<sphere>(point3(-1.0, 0.0, -1.0), 0.5, material_left));
world.add(make_shared<sphere>(point3( 1.0, 0.0, -1.0), 0.5, material_right));
camera cam;
cam.aspect_ratio = 16.0 / 9.0;
cam.image_width = 400;
cam.samples_per_pixel = 100;
cam.max_depth = 50;
cam.render(world);
}
这会得到:
10.6. 模糊反射
我们可以给反射方向增加一个随机的球形范围的随机偏移,并选择一个新的点来计算反射光线。我们会在以初始反射点为球心的球体表面上随机选择一个新的点作为新的反射点,球的半径增加一个fuzz
参数控制,如下图所示:(译者注:新的反射点指的射线与几何体表面相交的点,顺着是reflect得到的反射方向单位长度的位置)
fuzz
的值越大,反射效果越模糊。这意味着可以增加一个模糊度参数来作为模糊半径(0意味着不模糊)。有一个问题是,散射光线可能在几何体以下。我们可以让几何表面吸收这些光线。
另请注意,为了使模糊球体有意义,它需要与反射向量相比保持一致的缩放比例,反射向量的长度可以任意变化。为了解决这个问题,我们需要对反射光线进行归一化。
c++
class metal : public material {
public:
metal(const color& albedo, double fuzz) : albedo(albedo), fuzz(fuzz < 1 ? fuzz : 1) {}
bool scatter(const ray& r_in, const hit_record& rec, color& attenuation, ray& scattered)
const override {
vec3 reflected = reflect(r_in.direction(), rec.normal);
reflected = unit_vector(reflected) + (fuzz * random_unit_vector());
scattered = ray(rec.p, reflected);
attenuation = albedo;
return (dot(scattered.direction(), rec.normal) > 0);
}
private:
color albedo;
double fuzz;
};
我们尝试一下0.3和1.0的金属模糊度:
c++
int main() {
...
auto material_ground = make_shared<lambertian>(color(0.8, 0.8, 0.0));
auto material_center = make_shared<lambertian>(color(0.1, 0.2, 0.5));
auto material_left = make_shared<metal>(color(0.8, 0.8, 0.8), 0.3);
auto material_right = make_shared<metal>(color(0.8, 0.6, 0.2), 1.0);
...
}