我将发布我的
计算机图形学课程的下一章(
在这里,您可以阅读俄语原文,尽管英语版本较新)。 这次,对话的主题是
使用光线跟踪绘制场景 。 像往常一样,我会尽量避免使用第三方库,因为这会使学生看起来很隐秘。
互联网上已经有很多类似的项目,但是几乎所有这些项目都显示出非常难以理解的完成程序。 例如,这里有一个非常著名的
渲染程序,可以放在名片上 。 令人印象深刻的结果,但是理解此代码非常困难。 我的目标不是展示自己的能力,而是详细说明如何重现这一点。 而且,在我看来,这堂课对计算机图形学的培训甚至没有什么用,而是作为一种编程工具。 我将始终如一地展示如何从头开始获得最终结果:如何将复杂的问题分解为基本可解决的阶段。
注意:仅查看我的代码,以及仅用手捧着一杯茶阅读这篇文章是没有意义的。 本文旨在帮助您抓住键盘并编写自己的引擎。 他肯定会比我的好。 好吧,或者只是更改编程语言!因此,今天我将展示如何绘制此类图片:

第一阶段:将图片保存到磁盘
我不想打扰窗口管理器,鼠标/键盘处理等。 我们程序的结果将是一张保存到磁盘的简单图片。 因此,我们需要做的第一件事就是将图片保存到磁盘。
这是允许您执行此操作的代码。 让我给你它的主要文件:
#include <limits> #include <cmath> #include <iostream> #include <fstream> #include <vector> #include "geometry.h" void render() { const int width = 1024; const int height = 768; std::vector<Vec3f> framebuffer(width*height); for (size_t j = 0; j<height; j++) { for (size_t i = 0; i<width; i++) { framebuffer[i+j*width] = Vec3f(j/float(height),i/float(width), 0); } } std::ofstream ofs; // save the framebuffer to file ofs.open("./out.ppm"); ofs << "P6\n" << width << " " << height << "\n255\n"; for (size_t i = 0; i < height*width; ++i) { for (size_t j = 0; j<3; j++) { ofs << (char)(255 * std::max(0.f, std::min(1.f, framebuffer[i][j]))); } } ofs.close(); } int main() { render(); return 0; }
在main函数中,仅调用render()函数,仅此而已。 render()函数内部是什么? 首先,我将图片定义为Vec3f类型的帧缓冲区值的一维数组,这些是简单的三维向量,为我们提供了每个像素的颜色(r,g,b)。
向量的类别存在于geometry.h文件中,这里不再赘述:首先,在那里一切都很琐碎,对二维和三维向量进行简单的操作(加,减,赋值,乘以标量,标量积),其次,
gbg作为计算机图形学课程的一部分已经对其进行了
详细描述 。
我将图片保存为
ppm格式 ; 尽管并非总是最方便的进一步查看方式,但这是保存图像的最简单方法。 如果要保存为其他格式,我仍然建议连接第三方库,例如
stb 。 这是一个很棒的库:在项目中包含一个头文件stb_image_write.h就足够了,这甚至可以保存为png和jpg。
总而言之,此阶段的目标是确保我们可以a)在内存中创建图片并在其中写入不同的颜色值b)将结果保存到磁盘,以便可以在第三方程序中查看。 结果如下:

第二阶段,最困难的是:直接光线跟踪
这是整个链中最重要和最困难的阶段。 我想在代码中定义一个球体,并在屏幕上显示它,而不会打扰材质或照明。 这是我们的结果应如下所示:

为了方便起见,在我的存储库中,每个阶段都有一个提交; Github使得查看更改非常方便。
例如 ,
在这里,与第一次提交相比,第二次提交中发生了什么变化。
首先:我们需要在计算机内存中代表一个球形吗? 对于我们来说,四个数字就足够了:一个具有球体中心的三维矢量和一个描述半径的标量:
struct Sphere { Vec3f center; float radius; Sphere(const Vec3f &c, const float &r) : center(c), radius(r) {} bool ray_intersect(const Vec3f &orig, const Vec3f &dir, float &t0) const { Vec3f L = center - orig; float tca = L*dir; float d2 = L*L - tca*tca; if (d2 > radius*radius) return false; float thc = sqrtf(radius*radius - d2); t0 = tca - thc; float t1 = tca + thc; if (t0 < 0) t0 = t1; if (t0 < 0) return false; return true; } };
这段代码中唯一不平凡的事情是一个函数,该函数使您可以检查给定的射线(从orig起源于dir方向)是否与我们的球体相交。 可以在
此处阅读有关检查光束与球面相交的算法的详细说明,我强烈建议您这样做并检查我的代码。
光线追踪如何工作? 很简单 在第一阶段,我们仅用渐变覆盖图像:
for (size_t j = 0; j<height; j++) { for (size_t i = 0; i<width; i++) { framebuffer[i+j*width] = Vec3f(j/float(height),i/float(width), 0); } }
现在,对于每个像素,我们将形成一条来自坐标中心并穿过像素的射线,并检查该射线是否与我们的球面相交。

如果没有与球体相交,则将放置color1,否则放置color2:
Vec3f cast_ray(const Vec3f &orig, const Vec3f &dir, const Sphere &sphere) { float sphere_dist = std::numeric_limits<float>::max(); if (!sphere.ray_intersect(orig, dir, sphere_dist)) { return Vec3f(0.2, 0.7, 0.8);
在这一点上,我建议用铅笔在纸上检查所有计算,包括光线与球体的交点以及光线对图片的扫描。 以防万一,我们的相机由以下因素决定:
- 影像宽度
- 图片高度
- 视角,前视
- 摄像头位置Vec3f(0,0,0)
- 沿z轴的注视方向为负无穷大方向
第三阶段:添加更多球体
最艰难的时刻已经过去,现在我们的道路万里无云。 如果我们能画一个球体。 那么显然再增加一些工作并不困难。
在这里您可以看到代码中的更改,结果如下:

第四阶段:照明
每个人都擅长于我们的照片,但这仅仅是光线不足。 在本文的其余部分中,我们只会谈论这一点。 添加一些点光源:
struct Light { Light(const Vec3f &p, const float &i) : position(p), intensity(i) {} Vec3f position; float intensity; };
考虑到真实的照明是一项非常非常艰巨的任务,因此,像其他所有人一样,我们将通过绘制完全不真实,但最可能,最合理的结果来欺骗眼睛。 第一句话:为什么冬天冷,夏天热? 因为加热地球表面取决于阳光的入射角。 地平线上方的太阳越高,则照亮的表面越亮。 反之亦然,视野越低,强度就越弱。 好吧,太阳落山之后,光子根本无法到达我们。 关于我们的球体:这是我们从相机发出的光束(与光子无关,请注意!)与球体相交。 我们如何了解交点是如何照亮的? 您可以简单地查看此点的法向矢量和描述光的方向的矢量之间的角度。 角度越小,表面照明越好。 为了使其更加方便,您可以简单地将法线向量和光照向量之间的标量乘积。 我记得两个向量a和b之间的标量乘积等于向量范数乘以向量之间角度的余弦值的乘积:a * b = | a | | b | cos(alpha(a,b))。 如果我们采用单位长度的矢量,那么最简单的标量积将为我们提供表面照明的强度。
因此,在cast_ray函数中,我们将考虑光源的情况下返回颜色,而不是恒定的颜色:
Vec3f cast_ray(const Vec3f &orig, const Vec3f &dir, const Sphere &sphere) { [...] float diffuse_light_intensity = 0; for (size_t i=0; i<lights.size(); i++) { Vec3f light_dir = (lights[i].position - point).normalize(); diffuse_light_intensity += lights[i].intensity * std::max(0.f, light_dir*N); } return material.diffuse_color * diffuse_light_intensity; }
请在此处查看更改,但
查看程序的结果:

第五阶段:有光泽的表面
在法向矢量和光矢量之间具有标量积的技巧可以很好地逼近粗糙表面的照明,在文献中将其称为漫射照明。 如果我们想要光滑有光泽怎么办? 我想得到这张照片:

看看需要进行的更改
很少 。 简而言之,发亮表面上的反射越亮,视角方向与
反射光方向之间的角度越小。 好吧,当然,我们将像以前一样遍历标量产品。
这种带有哑光和发光表面的体操被称为
Phong模型 。 Wiki对这种光照模型有相当详细的描述;与我的代码并行比较时,它读起来很好。 这是要了解的关键图片:

第六阶段:阴影
为什么我们有光却没有阴影? 乱! 我想要这张照片:
只有六行代码允许我们实现这一点:绘制每个点时,我们仅要确保点光源不与场景对象相交,如果确实相交,则当前光源将跳过。 只有一点点微妙之处:我将点向法线方向移动一点:
Vec3f shadow_orig = light_dir*N < 0 ? point - N*1e-3 : point + N*1e-3;
怎么了 是的,只是我们的点位于对象的表面上,并且(不包括数字误差)该点的任何光线都将穿过我们的场景。
第七步:思考
这令人难以置信,但是要将反射添加到场景中,我们只需要添加三行代码:
Vec3f reflect_dir = reflect(dir, N).normalize(); Vec3f reflect_orig = reflect_dir*N < 0 ? point - N*1e-3 : point + N*1e-3;
亲眼
看看:在与对象的交点处,我们只需对反射光线进行计数(从计算凹凸起的函数就派上用场了!),然后在反射射线的方向上递归调用cast_ray函数。 确保使用
递归深度 ,将其设置为4,从头开始,图片会发生什么变化? 这是我的工作反射和四个深度的结果:

第八阶段:折射
通过学习计算反射,
可以计算出完全相同的折射 。 一个函数可让您计算折射射线的方向(
根据斯涅尔定律 ),递归函数cast_ray中包含三行代码。 结果是,最近的球变成“玻璃”,它折射并稍微反射:

阶段九:添加更多对象
为什么我们所有人都没有牛奶,但是没有牛奶。 在此之前,我们仅渲染球体,因为这是最简单的非平凡数学对象之一。 让我们添加一个平面。 这种类型的经典作品是棋盘。 为此,考虑到光束与场景相交的函数中的
十几条线对我们来说已经足够了。
好吧,这是结果:

正如我所承诺的那样,准确地计算256行代码
就可以为自己计算 !
第十阶段:作业
我们已经走了很长一段路:我们学习了如何在场景中添加对象,如何考虑相当复杂的照明。 让我把两项任务留作功课。 绝对所有的准备工作都已经在
homework_assignment分支中完成了。 每个作业最多需要十行代码。
任务一:环境图
目前,如果光束没有穿过场景,那么我们只需将其设置为恒定的颜色即可。 为何实际上是永久性的? 让我们拍一张球形照片(
envmap.jpg文件)并将其用作背景! 为了使生活更轻松,我将我们的项目与stb库链接在一起,以方便使用jpeg。 这应该是这样的渲染:

第二项任务:嘎嘎!
我们可以同时渲染球体和平面(请参见棋盘)。 因此,让我们添加一个三角模型图! 我编写了代码以读取三角形的网格,并在其中添加了射线-三角形相交函数。 现在,将鸭子添加到我们的场景中应该是微不足道的!

结论
我的主要任务是显示有趣(容易)的项目进行编程,我真的希望我能做到。 这一点非常重要,因为我坚信程序员应该写很多又有品位的东西。 我不了解您,但是个人会计和精打细算的代码复杂程度相当,根本无法吸引我。
实际上,可以在几个小时内写出250行光线追踪。 几天内可以掌握
五百行软件光栅化器。 下次,我们将对
广播进行
分类 ,同时,我还将展示我的一年级学生在教授C ++编程时编写的最简单的游戏。 敬请期待!