256行裸机C ++中的可理解的RayTracing

256行裸机C ++中的可理解的RayTracing


这是我简短的计算机图形学课程的另一章。 这次我们谈论的是射线追踪。 像往常一样,我尽量避免使用第三方库,因为我相信这会使学生检查引擎盖下的情况。 还要检查tinykaboom项目


网上有很多关于射线追踪的文章。 但是,问题在于几乎所有的软件都显示了很难理解的完整软件。 以非常著名的商务卡射线追踪器挑战为例 。 它产生了令人印象深刻的程序,但是很难理解它是如何工作的。 除了要显示我可以进行渲染之外,我还想详细告诉您如何自己进行渲染。


注意:仅查看我的代码,或仅手捧一杯茶阅读本文,这都是没有意义的。 本文旨在帮助您拿起键盘并实现自己的渲染引擎。 肯定会比我的好。 至少要更改编程语言!


因此,今天的目标是学习如何渲染此类图像:



步骤1:将映像写入磁盘


我不想打扰窗口管理器,鼠标/键盘处理之类的东西。 我们程序的结果将是一张保存在磁盘上的简单图片。 因此,我们需要做的第一件事就是将图片保存到磁盘。 在这里,您可以找到允许我们执行此操作的代码。 让我列出主文件:


#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; } 

在主函数中仅调用render(),仅此而已。 render()函数内部是什么? 首先,我将帧缓冲区定义为Vec3f值的一维数组,它们是简单的三维向量,可为每个像素提供(r,g,b)值。 向量的类别存在于文件geometry.h中,在此不再赘述:它实际上是对二维和三维向量的琐碎操作(加法,减法,赋值,标量乘积,标量积)。


我将图像保存为ppm格式 。 这是保存图像的最简单方法,尽管并非总是最方便地进一步查看图像的方法。 如果要以其他格式保存,建议您链接第三方库,例如stb 。 这是一个很棒的库:您只需在项目中包含一个头文件stb_image_write.h,它将使您能够以最流行的格式保存图像。


警告:我的代码充满了错误,我在上游修复了这些错误,但较早的提交受到影响。 检查此问题


因此,此步骤的目标是确保我们可以a)在内存中创建映像+分配不同的颜色,以及b)将结果保存到磁盘。 然后,您可以在第三方软件中查看它。 结果如下:


图片


步骤2,关键一步:光线追踪


这是整个链中最重要,最困难的一步。 我想在代码中定义一个球体并绘制它,而不会沉迷于材质或照明。 这就是我们的结果应如下所示:


图片


为了方便起见,我在存储库中每步只有一个提交; 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); // background color } return Vec3f(0.4, 0.4, 0.3); } void render(const Sphere &sphere) {  [...] for (size_t j = 0; j<height; j++) { for (size_t i = 0; i<width; i++) { float x = (2*(i + 0.5)/(float)width - 1)*tan(fov/2.)*width/(float)height; float y = -(2*(j + 0.5)/(float)height - 1)*tan(fov/2.); Vec3f dir = Vec3f(x, y, -1).normalize(); framebuffer[i+j*width] = cast_ray(Vec3f(0,0,0), dir, sphere); } }  [...] } 

在这一点上,我建议您拿一支铅笔在纸上检查所有的计算结果(射线-球面相交以及射线对图像的扫描)。 以防万一,我们的相机由以下因素决定:


  • 图片宽度
  • 图片高度
  • 视场角
  • 摄像头位置,Vec3f(0.0.0)
  • 沿z轴的负无限远方向上的视图方向

让我说明一下我们如何计算要跟踪的射线的初始方向。 在主循环中,我们有以下公式:


  float x = (2*(i + 0.5)/(float)width - 1)*tan(fov/2.)*width/(float)height; float y = -(2*(j + 0.5)/(float)height - 1)*tan(fov/2.); 

它来自哪里? 很简单 我们的相机位于原点,并且面向-z方向。 让我来说明一下,此图从顶部显示了相机,y轴指向屏幕之外:


图片


如我所说,相机位于原点,场景投影在位于平面z = -1的屏幕上。 视场指定空间的哪个扇区在屏幕上可见。 在我们的图像中,屏幕为16像素宽; 可以计算世界坐标系中的长度吗? 这很简单:让我们关注由红色,灰色和灰色虚线形成的三角形。 很容易看到tan(视场/ 2)=(屏幕宽度) 0.5 /(屏幕相机距离)。 我们将屏幕放置在距相机1的距离处,因此(屏幕宽度)= 2 tan(视场/ 2)。


现在让我们说,我们要通过屏幕第12个像素的中心投射一个矢量,即我们要计算蓝色矢量。 我们该怎么做? 从屏幕左侧到蓝色矢量尖端的距离是多少? 首先,它是12 + 0.5像素。 我们知道屏幕的16个像素对应于2 tan(fov / 2)世界单位。 因此,向量的尖端位于 距左边缘的 (12 + 0.5)/ 16 2 tan(fov / 2)世界单位,或(12 + 0.5) 2/16 * tan(fov / 2) 的距离 -屏幕与-z轴之间的交点处的棕褐色(fov / 2)。 将屏幕纵横比添加到计算中,您将确切找到射线方向的公式。


第3步:添加更多球体


最困难的部分已经过去,现在我们的道路已经明确。 如果我们知道如何绘制一个球体,那么添加更多球体就不会花我们很长时间。 检查代码中的更改 ,这是生成的图像:


图片


步骤4:照明


除了光线不足之外,图像在所有方面都是完美的。 在本文的其余部分,我们将讨论照明。 让我们添加一些点光源:


 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; } 

可以进行上一步的修改,结果如下:


图片


步骤5:镜面照明


点积技巧可以很好地模拟无光泽表面的照度,在文献中将其称为漫射照明。 如果要绘制有光泽的表面该怎么办? 我想要一张这样的照片:


图片


检查需要多少修改 。 简而言之,发光表面上的光越亮,视角方向与反射光方向之间的夹角就越小。


这种带有哑光和发光表面照明的技巧被称为Phong反射模型 。 Wiki对此照明模型有相当详细的描述。 最好与源代码并排阅读。 这是了解魔术的关键图片:


图片


第6步:阴影


为什么我们有光却没有阴影? 不好啦! 我想要这张照片:


图片


仅用六行代码就可以实现这一点:在绘制每个点时,我们只需确保当前点和光源之间的线段不与场景对象相交即可。 如果有交叉点,我们将跳过当前光源。 只有一点点微妙之处:我沿法线方向移动该点:


 Vec3f shadow_orig = light_dir*N < 0 ? point - N*1e-3 : point + N*1e-3; 

怎么会这样 只是我们的点位于对象的表面上,并且(数字误差除外)该点的任何光线都将与对象本身相交。


步骤7:思考


这太不可思议了,但是要将反射添加到渲染中,我们只需要添加三行代码:


  Vec3f reflect_dir = reflect(dir, N).normalize(); Vec3f reflect_orig = reflect_dir*N < 0 ? point - N*1e-3 : point + N*1e-3; // offset the original point to avoid occlusion by the object itself Vec3f reflect_color = cast_ray(reflect_orig, reflect_dir, spheres, lights, depth + 1); 

亲自观察:与球体相交时,我们仅计算反射射线(借助于用于镜面反射高光的相同函数!),然后在反射射线的方向上递归调用cast_ray函数。 确保使用递归深度 ,将其设置为4,尝试从0开始盯着不同的值,图片中会发生什么变化? 这是我的反射和递归深度为4的结果:


图片


步骤8:折射


如果我们知道要进行反射,则折射很容易 。 我们需要添加一个函数来计算折射射线( 使用斯涅尔定律 ),并在递归函数cast_ray中添加三行代码。 这是最接近的球是“玻璃制成”的结果,它同时反射和折射光:


图片


Steo 9:超越领域


到目前为止,我们仅渲染球体,因为它是最简单的非平凡数学对象之一。 让我们添加一架飞机。 棋盘是经典的选择。 为此目的,添加十几行就足够


结果如下:



如承诺的那样,该代码有256行代码, 请自己检查


步骤10:家庭作业


我们已经走了很长一段路:我们已经学会了如何向场景添加对象,如何计算相当复杂的光照。 让我把两个作业留给您做功课。 绝对所有准备工作已经在分支homework_assignment中完成 。 每次分配将需要十行代码顶部。


作业1:环境图


此刻,如果光线不与任何对象相交,我们只需将像素设置为恒定的背景色即可。 为何实际上是恒定的呢? 让我们拍摄一张球形照片(文件envmap.jpg )并将其用作背景! 为了使生活更轻松,我将我们的项目与stb库链接在一起,以方便使用jpg格式。 它应该给我们这样的形象:


图片


作业2:嘎嘎嘎嘎!


我们可以同时渲染球体和平面(请参见棋盘格)。 因此,让我们绘制三角形网格! 我编写了一个代码,使您可以读取.obj文件,并且向其中添加了射线三角形相交函数。 现在将鸭子添加到我们的场景中应该是微不足道的:


图片


结论


我的主要目标是显示有趣(容易!)的项目。 我坚信,要成为一名优秀的程序员,必须要做很多附带项目。 我不了解您,但是即使代码的复杂性相当,我个人也不会被会计软件和扫雷器所吸引。


几个小时的时间和250行代码为我们提供了raytracer。 几天内可以完成五百行软件光栅化程序。 图形对于学习编程真的很酷!

Source: https://habr.com/ru/post/zh-CN477244/


All Articles