世界上最美的立体图形是球体,最美的平面图形是圆。
——毕达哥拉斯学派

球公式的推导

我们要正式开始让光线和其他什么东西碰撞了。我们先制作一种这个世界上最简单的物体——球。
你还记得你学高中数学的时候学到的球的表达式么?如果你忘记了,我贴在这里:
五:球 - 图1
这个公式表示以原点为球心,半径是R的球。所有坐标是(x,y,z)的点满足上面的表达式都在球上。如果想知道点是不是在球内?看看这个公式:
五:球 - 图2
满足上述公式的点都在球内。那下面这个公式是干嘛的就不用多说了吧?
五:球 - 图3%22%20aria-hidden%3D%22true%22%3E%0A%20%3Cuse%20xlink%3Ahref%3D%22%23E1-MJMATHI-78%22%20x%3D%220%22%20y%3D%220%22%3E%3C%2Fuse%3E%0A%20%3Cuse%20transform%3D%22scale(0.707)%22%20xlink%3Ahref%3D%22%23E1-MJMAIN-32%22%20x%3D%22809%22%20y%3D%22583%22%3E%3C%2Fuse%3E%0A%20%3Cuse%20xlink%3Ahref%3D%22%23E1-MJMAIN-2B%22%20x%3D%221248%22%20y%3D%220%22%3E%3C%2Fuse%3E%0A%3Cg%20transform%3D%22translate(2249%2C0)%22%3E%0A%20%3Cuse%20xlink%3Ahref%3D%22%23E1-MJMATHI-79%22%20x%3D%220%22%20y%3D%220%22%3E%3C%2Fuse%3E%0A%20%3Cuse%20transform%3D%22scale(0.707)%22%20xlink%3Ahref%3D%22%23E1-MJMAIN-32%22%20x%3D%22706%22%20y%3D%22583%22%3E%3C%2Fuse%3E%0A%3C%2Fg%3E%0A%20%3Cuse%20xlink%3Ahref%3D%22%23E1-MJMAIN-2B%22%20x%3D%223425%22%20y%3D%220%22%3E%3C%2Fuse%3E%0A%3Cg%20transform%3D%22translate(4425%2C0)%22%3E%0A%20%3Cuse%20xlink%3Ahref%3D%22%23E1-MJMATHI-7A%22%20x%3D%220%22%20y%3D%220%22%3E%3C%2Fuse%3E%0A%20%3Cuse%20transform%3D%22scale(0.707)%22%20xlink%3Ahref%3D%22%23E1-MJMAIN-32%22%20x%3D%22663%22%20y%3D%22583%22%3E%3C%2Fuse%3E%0A%3C%2Fg%3E%0A%20%3Cuse%20xlink%3Ahref%3D%22%23E1-MJMAIN-3E%22%20x%3D%225626%22%20y%3D%220%22%3E%3C%2Fuse%3E%0A%3Cg%20transform%3D%22translate(6683%2C0)%22%3E%0A%20%3Cuse%20xlink%3Ahref%3D%22%23E1-MJMATHI-52%22%20x%3D%220%22%20y%3D%220%22%3E%3C%2Fuse%3E%0A%20%3Cuse%20transform%3D%22scale(0.707)%22%20xlink%3Ahref%3D%22%23E1-MJMAIN-32%22%20x%3D%221074%22%20y%3D%22583%22%3E%3C%2Fuse%3E%0A%3C%2Fg%3E%0A%3C%2Fg%3E%0A%3C%2Fsvg%3E#card=math&code=x%5E2%2By%5E2%2Bz%5E2%3ER%5E2&id=IWW8W)
我再给出一个更一般的公式:
五:球 - 图4
没错,球心改到了五:球 - 图5上。
但是这个公式没有办法在我们的项目中使用,为什么呢?别忘记了,我们的底层使用的vec3类,我们更希望看到向量而不是标量表示,我们需要简单的改变一下这个公式。现在假设球心所在的坐标用C这个vec3类常量表示,即五:球 - 图6。同样的令五:球 - 图7。有:
五:球 - 图8
上述式子的左侧是一个向量模的平方,右侧是一个距离公式。它们都表示P点和C点之间的距离的平方。所以有:
五:球 - 图9
所有满足这样要求的P——它到C点的距离为r,这样的点一定在以C为球心,r为半径的球上。
现在我要把光线的概率引入了,假设一个光线射到了球上,我们怎么描述呢?
如果光线曾在某一个时刻打在球上,则表示有一个t,使得五:球 - 图10 正好传播到了球的位置。我们带入它,把光线的概念和球的概念交合在一起。
五:球 - 图11
展开:
五:球 - 图12
把左侧括号乘开,这里我们把五:球 - 图13看作一个整体,再把右侧的五:球 - 图14移到左侧。
五:球 - 图15
这是什么?一个t的一元二次方程。(b表示光线方向,A是光源位置,C是球心,全是常量)。
image.png
如上图所示,方程的根的数量就是光线和球交点的数量。只要搞明白这个,我们下面的代码你就很容易理解了。
为了教学的顺畅性,我会把这一坨代码暂时放在main所在文件里,如果你有代码洁癖,也别着急,在下一章里,我们会对球的代码进行抽离和封装。

球的简易碰撞函数

  1. //简易的球的碰撞检测函数,吃球心,半径和一根光线,吐给你光线是否击中球的bool值。
  2. bool hit_sphere(const point3& center, double radius, const ray& r) {
  3. // 这个oc就是上面函数里的(A-C).
  4. vec3 oc = r.origin() - center;
  5. // 对应上面公式里的b的平方。即平方项的系数。
  6. auto a = dot(r.direction(), r.direction());
  7. // 对应上面公式里的2*(A-C)点乘b,即一次项的系数
  8. auto b = 2.0 * dot(oc, r.direction());
  9. // (A-C)点乘(A-C)减去r的平方,即常数项。
  10. auto c = dot(oc, oc) - radius*radius;
  11. // 高中生最爱的Δ,b的平方减4ac。
  12. auto discriminant = b*b - 4*a*c;
  13. //返回方程有没有根,即光线有没有碰撞到球体。
  14. return (discriminant > 0);
  15. }
  16. color ray_color(const ray& r) {
  17. // 如果我们击中了这个球心在(0,0,1)且半径是0.5的球,就直接返回颜色为红色。
  18. if (hit_sphere(point3(0,0,-1), 0.5, r))
  19. return color(1, 0, 0);
  20. // 如果没有击中的话你就继续画蓝天吧。
  21. vec3 unit_direction = unit_vector(r.direction());
  22. auto t = 0.5*(unit_direction.y() + 1.0);
  23. return (1.0-t)*color(1.0, 1.0, 1.0) + t*color(0.5, 0.7, 1.0);
  24. }
  1. 你就会看到一轮炽热的炎日。<br />我们往虚拟视口中射出的密集的光线完美的捕捉到这颗球的轮廓,这些命中了球的光线都带回了鲜艳的红色,仿佛就是在告诉你:"小心,那里有一颗球!"。<br />你仔细想想你就会明白为啥这个球长这个样子了,它的球心正好在虚拟视口的中心,且它的直径为1,恰好是虚拟视口高度的一半。<br />![image.png](https://cdn.nlark.com/yuque/0/2021/png/25639626/1640248248343-38f56170-f819-49f6-983c-b9e381ad2681.png#clientId=u89f2f1d3-c7f9-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=172&id=uac0cc1e5&margin=%5Bobject%20Object%5D&name=image.png&originHeight=343&originWidth=525&originalType=binary&ratio=1&rotation=0&showTitle=false&size=15736&status=done&style=none&taskId=ued3d6463-b24f-4d49-bf5c-ada391c2ad5&title=&width=262.5)<br />怎么样,是不是忍不住想唱《红太阳》,但是这一抹艳红也会让你想到某个岛国对吧。<br />那我们不再让光线带回无聊的纯色,我们把目光聚焦到光线和球碰撞中产生的一些其他奇妙的事件,看看我们能从这次碰撞中拿取什么其他重要信息。

可视化法线

如果我们得知光线和球的碰撞点为P,我们要怎么得到这一点的法线呢?它应该是从球心发射,穿过这一点指向球的外侧,所以是五:球 - 图17
image.png
法线很容易理解,但是涉及到具体编码,就有一些问题需要提前阐明:

  1. 它应该是单位向量吗?是的,它应该是,单位化法线可能会在某些方面为我们的渲染提供便利,但是不强制,我并不要求法线一定是单位向量,如果必须是单位化的地方我们进行单位化即可。当然,如果你是一个严格的人,你可以选择永远单位化它们。
  2. 你要注意之前我们给的hit_sphere函数的框架已经不足以满足我们获取法线的需要了,因为我们不仅仅需要了解球和光线是否碰撞,我们还得知道光线和球的第一个焦点的位置,因为只要不是极端的相切的情况,我们总能找到两个交点,所以我们需要t较小的那个交点。

明白了以上两点,我们就可以开始制作可视化法线贴图了。什么是可视化法线贴图呢?就是把法线的(x,y,z)分量分别当作颜色的rgb三个通道输出,当然我们需要进行简单的映射,毕竟颜色分量的范围都是[0,1]。
你可能会很惊讶,因为你可能在其他游戏引擎或者着色语言中找到类似的概念,你会很好奇它们是不是一种东西——是的,而且我们从更底层的地方去实现了可视化法线贴图。
看如下代码:

  1. //它不再返回bool,而是返回一个浮点数,表示光线第一次打在球上的时候的时间t。
  2. double hit_sphere(const point3& center, double radius, const ray& r) {
  3. vec3 oc = r.origin() - center;
  4. auto a = dot(r.direction(), r.direction());
  5. auto b = 2.0 * dot(oc, r.direction());
  6. auto c = dot(oc, oc) - radius*radius;
  7. auto discriminant = b*b - 4*a*c;
  8. if (discriminant < 0) {
  9. // Δ小于0,别看了,光线没打到球,直接返回一个负值。
  10. return -1.0;
  11. } else {
  12. // 求根公式,我们返回了较小的那个根。emm...它让我想起了高中的美好岁月。
  13. return (-b - sqrt(discriminant) ) / (2.0*a);
  14. }
  15. }
  16. color ray_color(const ray& r) {
  17. auto t = hit_sphere(point3(0,0,-1), 0.5, r);
  18. //如果光线击中了球。
  19. if (t > 0.0) {
  20. //拿到法线,嘿嘿,我们之前写过很久的at函数终于派上用场了。我们这次单位化它。
  21. vec3 N = unit_vector(r.at(t) - vec3(0,0,-1));
  22. //返回法线可视化之后的颜色值,注意我们做了一个[-1,1]到[0,1]的映射。
  23. return 0.5*color(N.x()+1, N.y()+1, N.z()+1);
  24. }
  25. //如果没打中?继续画蓝天吧。
  26. vec3 unit_direction = unit_vector(r.direction());
  27. t = 0.5*(unit_direction.y() + 1.0);
  28. return (1.0-t)*color(1.0, 1.0, 1.0) + t*color(0.5, 0.7, 1.0);
  29. }

一颗能量球!好像孙悟空的元气弹。
image.png
这次我们射出的光线碰到了球,并没有粗暴的返回一个纯色,而是把该点的法线方向转换为颜色值带了回来。你是不是已经体会到了光线在3D空间中穿梭并带回各种信息的魅力了呢?
我留下了几个问题,看看你是否深刻理解到了上面的代码。

问题与思考

  1. 把代码退回红太阳阶段,修改ray_color中部分代码如下:

    1. // 嗯???一颗在相机背后的球????
    2. if (hit_sphere(point3(0,0,1), 0.5, r))
    3. return color(1, 0, 0);

    运行并生成图片,想想为什么会这样?

  2. 再把球的圆心位置改成(0,1,-1),运行。这是什么?嘿,一个明显被拉长的半球!这是广角镜头的边界扭曲效果!!!我们并没有专门为他编码它就自己把自己做好了!!!想想看为什么会这样?

image.png

  1. 想想看为什么元气弹的上方颜色偏绿?

  2. 使用元气弹版本的代码,更改hit_sphere函数,这次返回较大的那个根,生成图片另外保存。

    1. // 求根公式,这次返回较大的那个根。
    2. return (-b + sqrt(discriminant)) / (2.0 * a);

    仔细对比这两张图片,想想看其中的哲理。

下一章开始,我们会暂时小憩一会,不再添加新的东西,而是把球的代码抽象并整合出来,这将是一次很好的训练到你面向对象编程的能力的机会。

参考文献

https://raytracing.github.io/books/RayTracingInOneWeekend.html
参考自《RayTracingInOneWeekend》第5节和第6.1节。