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;
}
光线, 摄像机和背景
光线类
光线追踪器都有一个光线类,并且可以计算光打到着色点的颜色值。将光线看作一个方程:
光线类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;
};
在场景中发射光线
有了光线类,我们就能开始写光线追踪器了。光线追踪器发射光线,将其打到像素上,然后计算这些光线方向上的颜色。包括如下步骤:
- 计算从“眼睛”(摄像机)发出打到像素点的光线;
- 确认哪些物体与光线相交;
- 计算距离最近相交点的颜色;
定义视口和摄像机
输出正方形图不好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轴从蓝到白渐变的颜色。首先需要将属于
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);
}
最终结果如下:
添加球体
有了场景后,就能添加物体了。这里添加球体,因为球体和光线求交相对简单点。
光线和球的相交
对于处于原点,半径为
如果想让球心位置自定义为
对于形如
的一元二次方程,有求根公式 对于 ,如果 ,说明方程有两个不相等实数根; 说明方程有两个相等实数根; 说明方程没有实数解。
可以用求根公式解这个方程,其中:
代码部分
接下来通过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。
表面法线与多个物体
表面法线可视化
首先让我们实现球表面法线的着色,表面法线就是从球心指向相交点的向量:
通常来说,法线都是单位向量,但是否需要为了节省计算,而不对法线向量进行标准化操作,看看以下三个观点:
- 能做一次标准化就不要重复做多次;
- 在某几个场合下需要用到标准化后的法线;
- 可根据法向量的定义简化运算,例如球面法向量只需除以球的半径即可标准化,从而避免平方根运算。
因此还是将所有法线标准化吧。
可视化法线向量不需要任何光线来帮忙,通常将各分量范围在
// 返回光线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);
}
}
- 回想一下,向量点乘自己相当于自己长度的平方。
- 注意到
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_tMax
,ray_tMin
区间的类全都替换为Interval
即可,详情见这里。
封装摄像机类
接下来将相机和场景渲染相关的代码封装到Camera
类中,它将完成如下工作:
- 构造并发射光线到场景中。
- 使用这些光线的结果渲染图片。
需要将ray_color()
函数和图片、场景等设定一起封装,新的Camera
类中将包含initialize()
和render()
两个主要方法,还有两个私有帮手函数get_ray()
和ray_color()
。只需先将ray_color
从main.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