0%

如果有不懂的地方,可以看下参考资料中的3、4的视频。

第一步:下载项目

打开项目地址:https://github.com/binary-husky/chatgpt_academic

按下图下载项目压缩包,放在随意路径下,进行解压。记住这个安装路径,后面会经常用到。

image-20230417151426691

第二步:安装依赖环境

安装Anaconda

安装依赖的意思就是安装该项目运行所需要用到的库。

Anaconda的安装教程(Windows版)可参考这篇博客:

https://blog.csdn.net/weixin_42855758/article/details/122795125

唯一需要注意的是这一步推荐点上前三个,其他的跟着网页进行操作,直到第十二步。

Anaconda成功安装之后,即网页中第十二步就可以了

换国内源

(此步骤为了换国内的源,可以跳过,当遇到下载速度较慢再来更换也行)点击左下角的win图标,然后按下图打开Anaconda Navigator

image-20230418180619584

image-20230418181050879

点击launch启动cmd进行换源。换国内的源是为了更快下载,具体可以参考:

https://www.ngui.cc/el/1234004.html?action=onClick

清华或科大的源选一个进行安装就可以,直接复制代码粘贴到刚刚打开的那个黑色窗口回车就可以。最后可以使用下面的语句进行检查,如何安装的是中科大源就是下图的样子:

1
conda config --show-sources

image-20230418181751512

安装依赖

后面我们可以通过下图这种方式来打开这个Anaconda Prompt窗口,后面的一些代码都会在这个窗口中输入。

image-20230417153922227

1.创建项目运行的虚拟环境,复制粘贴代码后回车,中途会询问yes还是no,一直y就行

1
conda create -n chatgpt python=3.11

2.激活创建的虚拟环境

1
conda activate chatgpt

3.在创建的虚拟环境中进入项目所在的目录安装项目运行所需要的库,这一步操作后面每次使用时都需要用到,请牢记:

例如:我项目路径是F:\中科院chatgpt\chatgpt_academic-master,那么需要首先输入F: 如果你是安装在D,那就是D:

1
F:

之后输入cd空格项目路径,项目路径可以直接进行项目文件然后复制路径image-20230419151628127

1
cd F:\中科院chatgpt\chatgpt_academic-master

通过这两步定位到程序所在位置,再使用下面代码安装依赖:

1
python -m pip install -r requirements.txt

image-20230418182143153

看到红圈中的内容就是成功安装,可直接跳到第三步,忽略下面的内容。

(安装成功的人不用看!)如何你安装失败了,可能是因为会遇到没有gradio-3.23.0这个版本,所以我们去网站上下载:

https://pypi.org/project/gradio/#file

下载完之后可以放在一个位置(自己决定)

img

然后直接pip刚刚下载的文件:

1
pip install gradio-3.23.0-py3-none-any.whl

然后再在项目文件中找到requirements.txt文件,注释这第一行,因为不需要安装gradio了,加个#号即可。再重新安装一遍:

在这里插入图片描述

1
python -m pip install -r requirements.txt

第三步:配置代理和KEY

在我们第一步下载的项目中打开 config.py 文件(可以使用pycharm、vs等等变成软件,都没有可以选择记事本),进行配置:

image-202304191521586701)OpenAI API Key 获取参考:
https://www.163.com/dy/article/HUPI06JN05561QFI.html
2)代理地址和端口
https://github.com/binary-husky/chatgpt_academic/issues/1

将项目中的config.py复制一份,原地粘贴,更名为config_private.py,config_private.py不受git管控,可以让您的隐私信息更加安全。

第四步:运行并使用

最后使用Anaconda Prompt,同样的进入到项目地址(如果不记得了,可以看第二步),输入下列代码运行程序,会自动打开网页版中科院GPT,可直接使用

1
python main.py

参考资料

  1. https://blog.csdn.net/weixin_37977006/article/details/129920530?spm=1001.2101.3001.6650.1&utm_medium=distribute.pc_relevant.none-task-blog-2%7Edefault%7ECTRLIST%7ERate-1-129920530-blog-129866977.235%5Ev29%5Epc_relevant_default_base3&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2%7Edefault%7ECTRLIST%7ERate-1-129920530-blog-129866977.235%5Ev29%5Epc_relevant_default_base3&utm_relevant_index=2
  2. https://blog.csdn.net/weixin_44383402/article/details/129888358?spm=1001.2101.3001.6650.2&utm_medium=distribute.pc_relevant.none-task-blog-2%7Edefault%7ECTRLIST%7ERate-2-129888358-blog-129866977.235%5Ev29%5Epc_relevant_default_base3&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2%7Edefault%7ECTRLIST%7ERate-2-129888358-blog-129866977.235%5Ev29%5Epc_relevant_default_base3&utm_relevant_index=3
  3. https://www.bilibili.com/video/BV1Th411V75K/?spm_id_from=333.337.search-card.all.click&vd_source=3fd6272f9e1a2f5ab97bcdd9e17bb11f
  4. https://www.bilibili.com/video/BV1hL411X7bZ/?spm_id_from=333.337.search-card.all.click&vd_source=3fd6272f9e1a2f5ab97bcdd9e17bb11f

二十一:简单纹理

别沉浸在包围盒的那些递归函数里了,暂时忘掉它们。接下来几章的内容和包围盒毫无关系,甚至和动态模糊以及时空光追都没有什么关系。换句话说,丢弃掉之前的那些代码也不影响这几章程序的运行。

从这一章开始,我们将目光重新移回物体表面。回顾一下初篇中介绍的三大材质:磨砂、金属和玻璃——难道我们的世界仅限于此吗?渲染器还不能描述陶瓷的纹路,又或是婴儿细腻的皮肤,甚至这个世界上的所有物体表面!世界上所有的东西都是不完美的,你见过没有划痕的金属和不沾上人类指纹的玻璃球么?

换句话来说,这个世界上不存在仅仅使用材质就可以完美模拟的物体表面。是时候请出材质最好的搭档——“纹理”了!

纹理简谈

纹理,在图形学领域通常表示程序化的物体表面颜色。比如,通过一些参数和数据,来计算得到物体表面某一点的颜色。根据具体计算方式不同可以大致分为两种:

①:找一张图,对于这个物体表面上的任意一点,都赋予其一个二维坐标,通过这个二维坐标在这个二维平面图上去找,找到的那一点是什么颜色,那物体表面该处就是什么颜色。这个二维坐标如何找到呢?一般来说是由建模师进行安排,建模师在制作模型的时候会为模型上的一些点指定一个二维坐标,其他的点的二维坐标就由这些给定点的插值进行计算——当然我们的渲染器到目前为止尚未引入三角面片的概念,这里不多谈。

这种方式得到的纹理看上去就好像是把这张二维图片贴到三维表面上一样,所以这又叫做贴图纹理。它的应用相当的广泛。

image.png

②:另一种获取各点的颜色的方式就显得比较理科生了:利用某种数学函数直接得到某点的颜色。这种纹理我们已经接触过了。还记得“元气弹”么?这就是一种纹理,我们把物体表面的法线信息映射到颜色区间并显示出来,当然这种纹理还有很多,利用法线只是其中的一种。

纹理基类

每次引入一个新事物必定要先完成基类的编写,新建文件texture.h,并敲入如下代码:

本纹理基类基于上小节中的第一种纹理类型:他只有一个函数,这个函数需要一个二维坐标u和v,返回一个颜色。当然,我们提到的拿着这个坐标去贴图中找颜色等等,这些都放到子类的value函数里来处理。从这个基类建立完成之后,如何得到颜色也就被规范化了,无论你是拿这个uv进行某种函数计算得到颜色,还是拿到某张图里去查找,这些都是子类该干的事情。

当然,仅仅传入uv并不能得到诸如“元气弹”之类的纹理,如果你需要通过纹理类创建元气弹,我们可以在之后再改造这个基类,加入诸如法线等等的参数。

至于这个u,v坐标从何而来,不是纹理类该管的事情,uv的计算不该放在这个类里,这个我们之后介绍。

uv坐标作为引入纹理之后碰撞中最重要的信息之一,需要把它写进record结构体:

纯色纹理

我们的金属和磨砂类都有名为albedo的变量来控制最终颜色。其实纯色也可以被当作一种纹理,就好像我们把

y=c

也当作一种函数(常值函数)一样。

并不是所有的渲染器都会把纯色当作一种纹理,但在本渲染器中,我推荐这么做。这样意味着纹理类搭建完毕之后,对物体表面颜色的所有的解释都会被放到一个类里面。这会使代码架构变得更加易懂。

当然,把纯色解释为纹理会使得纯色材质会比过去更加耗时,因为有更多需要维护的变量,但不会比过去慢多少。

继续把这个类写在texture.h文件里

球的纹理坐标——经纬度

现在,我们来思考一下关于uv坐标的事情,因为现在我们的物体类只有球这一个子类,现在我们要讨论的是如何把球的表面上任意一点和一个二维坐标uv对应起来。

我们可以根据球的解析式轻松地把球的表面上的每一点和一个三维坐标对应,但是把球表面和一个二维平面对应有点反直觉。我们换一个生活中的例子去看你会明白很多——世界地图就好像是把空心球剪开之后平铺在平面上那样。你可以在世界地图上找到你家的位置,且你随机在地图上选一点也可以在现实世界中找到对应的真实的坐标,这简直就是我们这个问题的天然解决方案。

假设

θ

是球上任意一点代表的向量和-Y方向向量的夹角,而

ϕ

是围绕Y轴的方向角(从

-X 方向到+Z 再到+X 到-Z 最后返回-X

)。这个

ϕ

θ

就是常说的经纬度。

工厂

简单工厂

简单工厂

简单工厂就是根据传入不同的参数,生成不同类的实例,不同的实例继承自同一个基类,通过多态统一调用方法。

工厂方法模式

工厂方法

工厂方法从一开始就需要选择创建的这个物体的专属工厂,它们都继承自同个接口,每个工厂生产专属的类实例。

简单工厂vs工厂方法

简单工厂的优点在于工厂类中包含了必要的逻辑判断,根据客户端的选择动态实例化相关的类,对客户端来说去除了与具体类的依赖。而工厂方法需要在客户端中决定实例化哪一个工厂,就是将简单工厂的内部判断移到了客户端,如果想添加功能,简单工厂需要修改工厂类,工厂方法只需添加类。

抽象工厂

抽象工厂

定义:提供一个创建一个很系列相关胡相互依赖的对象接口,无需指定它们具体的类。就是为创建不同的产品对象,客户端使用不同的具体工厂。

优点:便于交换产品的系列,它让具体创建实例的过程与客户端分离。但一个很大的劣势就是,当需要增加一个产品要更改一系列的具体工厂,而且,这样的类会非常的多,这是非常糟糕的,所以我们可以使用简单工厂区改进它。

用简单工厂升级抽象工厂

抽象工厂结合简单工厂

这里使用简单工厂替换了一系列的工厂,使用字符串区进行判断生成哪一系列的产品,简化了逻辑,但需要客户端做额外的工作了,且添加新系列的产品需要修改工厂类,添加switch判断,算是有利也有弊了,这里利用.NET的反射手段可以更加漂亮。

用反射升级抽象工厂

反射就是根据程序集加载特定命名空间下的类,它的格式为:Assembly.Load(“程序集名称”).CreateInstance(“命名空间:类名称”),现在看看他们的区别:

1
2
3
4
5
6
7
8
//之前的写法
ProductA_1 product = new ProductA_1();

//现在的写法
ProductA_1 product = (ProductA_1)Assembly.Load("抽象工厂模式").CreateInstance("抽象工厂模式.ProductA_1");

//改变变量更换另一个系列产品
ProductB_1 product = (ProductB_1)Assembly.Load("抽象工厂模式").CreateInstance("抽象工厂模式.ProductB_1");

这样我们只需要更改字符串变量就可以灵活的生产不同的类,从而去掉工厂里面的switch判断,代码结构仍然保持不变。

策略模式

策略模式1

定义:它定义了算法家族,分别封装,让它们之间可以互相替换,让它们可以互相替换,此模式让算法的变化,不会影响到使用算法的客户。

解释:上下文中保存了一个策略类,使用具体的策略将它初始化,之后利用多态调用不同的策略。

优点:通常用来定义一系列的算法,所有算法完成相同的工作,只是实现不同,可以以相同的方式调用所有算法,减少各种算法之间的耦合。且优化了单元检测,每个算法有自己的类,可以用自己的接口单独检测

装饰模式

装饰模式1

定义:动态地给一个对象添加一些额外的职责,就增加功能来说,装饰模式比生成子类更加灵活。

解释:总的来说就是像包装,一层包一层,调用时一次拆开,调用每层的方法。

优点:它是为已有的功能能够动态的添加更多功能的一种方式。它即不会增加主类的复杂度、也让每种装饰(功能)之间分离开来,有效的把类的核心职责和装饰功能区分,客户可以按顺序包装实现特殊功能。

代理模式

代理模式1

定义:为其他对象提供一种代理以控制对这个对象的访问

解释:通过在代理中组合真正实例的方式,为客户间接的调用功能。

应用:

  1. 远程代理:为一个对象在不同地址空间提供局部代表,可以隐藏一个对象在不同的地址空间的事实。
  2. 虚拟代理:根据需要创建开销很大的对象,通过它存放真实对象。
  3. 安全代理:用来控制真实对象访问的权限。
  4. 智能指引:调用真实的对象时,代理处理另外的事。

模板方法模式

模板方法1

定义:定义了一个算法骨架,讲其中的一些步骤延迟到子类中。模板方法使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。

解释:它把重复的不变的行为搬到基类,去除子类中的重复代码体现优势,它提供了一个很好的代码复用平台。

观察者模式

观察者模式

定义:定义了一种一对多的依赖关系,让多个观察者对象同时监听一个主题对象,这个主题状态变化会通知所有观察者,更新自己。

动机:一个系统分成一系列相互协同的类时需要不断维护对象之间的一致性,比如一个状态发生改变,需要调整相关对象进行改变,所以当一个对象改变引起不知道具体有多少其他对象有待改变时,就可以用到观察者模式。

实质:其实就是在接触耦合,并让耦合双方依赖于抽象,而不是依赖于具体,从而实现各自不同的变化。

不足:如果要通知的对象动作各不相同(方法名不相同)目前这个方式就无法实现了,这时可以利用委托来实现。在ConcreteSub中定义一个委托,把通知逻辑转移到客户端,客户端将不同的函数赋值给这个委托。之后在update函数中调用这个委托,就可以实现,但委托搭载的方法必须有相同的原型和形式(参数列表和返回值)。

上一章我们引入包围盒的概念后,大大缩短了我们运行时间,但整体逻辑开始变的复杂起来,没关系,让我们来一起梳理一下之前的内容。

回顾

首先我们把man函数当成一个raycast类,他除了需要一些长宽比,分辨率、采样数、反射深度等变量外,还需要一个自由相机、一个bvh_node构成的世界,有了这些,它就可以给我们呈现一张”美丽”的图片了,这其中两个关键就是相机和世界了。

先来看看相机,它由很多参数构造而成,但构造完成后,我们就不必管那么多,我们只需要在main函数传入一个0-1内的u、v值就可以得到我们想到的光线,再由ray_color函数来获得该光线带回的颜色。ray_color函数只需要光线,世界和深度就可以得到颜色,这个过程就是所谓的碰撞。

world是bvh_node类型,由一个hittable_list和两个时间构造而成,在构造函数中,对物体按随机轴进行二分直到只有一个物体或物体列表,为它们套上各自的包围盒后,回溯为每个节点的左右孩子一起套上更大的包围盒。目前的这种方式也代表了虽然我们可以在hitable_list里面包含hittable_list但是该函数并不会打开列表将里面的物体拿出来进行二分,所以当下为了更快的速度不建议这么做。

再回到我们的ray_color函数,光线首先会world这个最大包围盒检测,如果通过,继续检测左右孩子,当所有包围盒检测都通过之后,那么它便会和我们具体物体进行碰撞检测,由于我们的bvh的排序仅仅是通过比较每个节点最小值得到,所以是可能同时通过所有检测来到多个不同的物体进行碰撞,所以hit函数中深度检测也是必不可少的一环,将碰撞带回反射率和新的光线带回的颜色相乘就得到了这一次采样的结果,上述过程是一个递归的过程,反射光线会再一次与世界”碰撞“,直到反射的光线什么都没有碰到或者是碰到了光源。

这里还有一个非常重要的点,就是怎么得到的新的反射(折射)光线呢?这就是材质存在的意义了,每个物体中都包含一个材质,通过hit函数调用材质内scatter方法得到就可以得到一根新的光线!

将这样的采样重复一定次数取平均就得到这了这个像素点的颜色了,填满它们就得到我们的图片了。为了更加方便的理解这里放上他们的类图:

光线追踪类图1

最后谈谈我们是如何管理这个world,一切还得从hittable说起,它有我们最重要的碰撞函数hit,以及刚刚引入的bounding_box包围盒生成函数,还有四个继承者分别是球、移动球、物体列表以及bvh_node,继承者们自然需要实现基类的各自的碰撞及包围盒函数。

让我们来看看这些继承者们,首先最简单球和移动球,他们是抽象模型中最底层最具体的类,无论上层如何处理,最后的任务都要落到他们的头上,当然以后也会增加更多的物体进行扩充,物体对hittable的继承也让我们可以通过容器统一进行管理实现多态,这种设计方式也非常符合面向对象的设计思想,及应该尽可能的与抽象类互动而不是底层的派生类,从而提高代码的可复用性。

第三个继承者物体列表可以看成一个大团队,bvh_node那就可以理解为经理,给他一个团队,经理给团队下的人排序编号,当有任务来时,就看看适合哪个部门的哪个编号的人(球),如果底下的人(球)都干不了就直接放弃这单,合适就交给对应的人(球)去处理,这样公司就运作起来了,不对是光线就可以找到对应的物体进行碰撞了。

拓展

  1. 创建空类图,再脑内模拟图片生成过程,补全类图。

看了一些星际大战之后,想做一个激光武器,既然地球online里面做有点危险,那还是在unity里面做一个吧,先来看看效果

激光

激光5

激光11

最初呢,链接的激光是瞬间生成的,有一些假,第二个做了一些优化,可以看到链接丝滑了很多,第三个做了顶点动画看起来要更有威力了一些,看看怎么实现的吧,也可以取代码自用。

需求

有几个基本的需求

  1. 当激光路径碰到敌人,激光会锁住敌人,并产生连锁。
  2. 当敌人走出一定范围,链接会断开,重新生成链接。
  3. 当有新的敌人进入范围,而且没达到连锁上限,可以再次重新生成链接。

另外有几个自己可有可无的需求

  1. 用点光源照亮被连锁到的敌人
  2. 光线是有动感的,不能仅仅只一条线,那太low了
  3. 光线打出去是有速度的。

成员变量

首先是一些变量,建议跳过。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#region 共有变量
//可供调节激光参数相关
public int maxAtkNum = 5;
public int atkLinkRange = 7;
public float maxDistance = 20;
public float pulseSpeed = 0.7f;
public float minWidth = 0.5f;
public float maxWidth = 0.6f;
public float laserSpeed = 8;
#endregion

#region 私有变量
private LineRenderer lineRenderer;

//控制状态切换
private Ray ray;
private RaycastHit hit;
private bool isAtking = false;

//转折点相关
private GameObject[] lights;
private Collider[] colliders;
private HashSet<Collider> enemyIsAtking = new HashSet<Collider>();
private int pointSize = 1;
private Vector3[] tempPoints;
private Vector3 lastPos;
private float startTime;
private int curPointSize = 2;
#endregion

update

我们可以想到使用射线来检测我们的激光是否碰到了第一个敌人,这很重要,因为当激光是否连锁敌人对line组件的选点完全不同,所以,我们需要一个保存一个RaycastHit来判断状态,只有打到第一个敌人才开始连锁。击中敌人之后该怎么做呢?我将它分为四个状态,分别是NotAtking、Atking、NotAtk2Atking、Atk2NotAtking,控制四个状态进行转换的变量为前面我们保存的RaycastHit和一个表示是否开启攻击状态的bool值。先来看update代码吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
void Update()
{
//让控件依附在父物体身上
transform.localPosition = Vector3.zero;
transform.localRotation = Quaternion.identity;

//控制射线检测方向
ray.origin = transform.position;
ray.direction = transform.forward;
Physics.Raycast(ray, out hit, maxDistance);

//控制四种状态的切换
if (!isAtking && hit.transform)
{
NotAkt2Atking();
}
else if (isAtking && !hit.transform)
{
Atk2NoAkting();
}
else if(isAtking && hit.transform)
{
Atking();
}
else
{
NotAtking();
}
}

C++ Primer告诉我们最好先实现宏观调用,再去写细节方法,这里通过射线检测返回的hit和bool值在四种状态进行切换,有人可能会问:你这个怎么怎么不传参数啊,当然可以,但我想在这里尽量不在Atking和NotAtking中使用参数传递来调用函数,因为这个函数在update中调用频率非常之高,倒不如全部使用成员变量,这点空间换取的时间是相当值得的。

四种状态

当然我们接下来要去实现这四种状态,从最简单的开始吧,notAtking()函数,它只需要绘制一条慢慢变长的直线就可以了:

1
2
3
4
5
6
7
8
9
10
11
12
void NotAtking()
{
//用插值取得终点位置。
Vector3 endPos = Vector3.Lerp(lastPos, transform.position + transform.forward * maxDistance, (Time.time - startTime) * laserSpeed);
lineRenderer.SetPositions(new Vector3[]
{
transform.position,
endPos
});
//之后会提到
curPointSize = 2;
}

没什么好说的,非常简单,第二个自然就是NotAtk2Atking(),这里需要弄清楚链接逻辑,这个切换函数所要做的就是把一切数据都准备好,它主要是为Atking()函数做准备,先看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
//这里的形参后面会提到
void NotAkt2Atking()
{
//还记得控制状态的切换的两个关键吗?这是其中之一,这里新建局部对象用来作为循环条件
Collider col = hit.collider;

//用来收集范围检测到的敌人
Collider[] tempCol;

//循环寻找下一个单位
while (col && pointSize <= maxAtkNum)
{
//加入hashset中
enemyIsAtkingSet.Add(col);
colliders[pointSize - 1] = col;

//范围检测,这里检测层级可以自己定义
tempCol = Physics.OverlapSphere(col.transform.position, atkLinkRange,
1 << LayerMask.NameToLayer("Corpse"));

if (tempCol.Length == 0) col = null;
//依次遍历数组,若没有在hashSet中,就可以继续循环
else
{
col = null;
foreach (Collider collider in tempCol)
{
if (!enemyIsAtking.Contains(collider))
{
col = collider;
break;
}
}
}
//用于记录总共需要多少个点,默认值为1个,每多一个敌人就增加一个
pointSize++;
}
//设置状态准备进入Atking()函数
isAtking = true;
//此变量是为了控制激光进行位移
startTime = Time.time;
}

这个函数中使用一个Colloder类型的col作为循环条件,依次寻找到所有单位,当然还可以设置一个上限值,其中范围检测因为仍然会找到已经被链接的单位,所以这里选择使用hashSet来将它们排除。剩下一个变量curPointSize,它为当前已链接点数量,在Atking中就可以明白它的作用。Atking太长了,先看第一部分吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
void Atking()
{
//为Atking2NotAtk做准备,我不希望看到断开之后是重新从自身位置慢慢出来,而是从敌人这个位置继续向前
lastPos = hit.point;

//这里需要判断的是当敌人之间距离过大,我们需要重新进行状态的转换
for (int i = 1; i < pointSize - 1; i++)
{
if ((colliders[i].transform.position -
colliders[i - 1].transform.position).magnitude > atkLinkRange * 1.2f)
{
//为了让画面更连贯,我们直接跳过NotAtk阶段
Atk2NoAkting();
curPointSize = i + 1;
NotAkt2Atking();
return;
}
}

//防止有敌人挡在了第一个敌人和自身之间
if(hit.collider != colliders[0])
{
Atk2NoAkting();
curPointSize = 2;
NotAkt2Atking();
return;
}

...
}

curPointSize = i + 1;该参数的意义是下一次进入Atking函数时,从哪个点开始渐进光线,总不能状态一变就从头开始吧。后面的逻辑就是激光的渐进逻辑,具体分为两个不同的状态,一个是渐进状态,一个是光线已经全部链接完毕状态,具体看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
void Atking()
{
...

//渐进状态
if(curPointSize < pointSize)
{
//创建临时数组,该数组之后直接传到lineRenderer组件中
tempPoints = new Vector3[curPointSize + 1];

//第一个点是自身,中间点为已经链接到的敌人位置,最后一个点是从倒数第二个敌人向最后敌人渐进
tempPoints[0] = transform.position;
for (int j = 1; j < curPointSize; ++j)
{
tempPoints[j] = colliders[j - 1].transform.position;
}
tempPoints[curPointSize] = Vector3.Lerp(tempPoints[curPointSize - 1],
colliders[curPointSize - 1].transform.position, (Time.time - startTime) * laserSpeed);

//设置点数量并传入数组
lineRenderer.positionCount = curPointSize + 1;
lineRenderer.SetPositions(tempPoints);

//当渐进点和渐进位置差的不多就可以认为已经到达
if (Vector3.Distance(colliders[curPointSize - 1].transform.position,
tempPoints[curPointSize]) < 0.2f)
{
++curPointSize;
startTime = Time.time;
}

//别忘了使激光看起来更有威力的灯光效果,将它们点亮并移动位置
for (int i = 0; i < curPointSize - 2; i++)
{
lights[i].SetActive(true);
lights[i].transform.position = tempPoints[i + 1];
}
}
//链接完毕状态
else
{
//老五样了,最后一个点不需要渐进了。
tempPoints = new Vector3[pointSize];
tempPoints[0] = transform.position;
for (int i = 1; i < pointSize; i++)
{
tempPoints[i] = colliders[i - 1].transform.position;
}
lineRenderer.positionCount = curPointSize;
lineRenderer.SetPositions(tempPoints);

//开灯,比上面多一盏
for (int i = 0; i < pointSize - 1; i++)
{
lights[i].SetActive(true);
lights[i].transform.position = tempPoints[i + 1];
}

//还需要在最后一个位置继续判断有没有新加进来的敌人
Collider[] tempCol = Physics.OverlapSphere(tempPoints[pointSize - 1],
atkLinkRange * 0.8f, 1 << LayerMask.NameToLayer("Corpse"));
//同样的逻辑
foreach (Collider collider in tempCol)
{
if (!enemyIsAtking.Contains(collider))
{
Atk2NoAkting();
NotAkt2Atking();
return;
}
}
}
}

这里需要提到之前NotAtking中的CurPointSize = 2了,因为我们中Atking中的转换为了画面流畅都跳过了NotAtking这一步,所以需要补上,不然就会出现下次Atking时很多多余的奇怪激光。

其他看起来没有什么困难吧,但其实这里各种各样的边界条件,数组下标的选取让你各种越界,还好都是小问题,最麻烦的Atking结束,那有请最后的状态切换函数登场:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void Atk2NoAkting()
{
for (int i = 0; i < maxAtkNum; i++)
{
lights[i].SetActive(false);
}
pointSize = 1;
enemyIsAtking.Clear();
isAtking = false;
lineRenderer.positionCount = 2;
Array.Clear(colliders, 0, colliders.Length);
Array.Clear(tempPoints, 0, curPointSize.Length);
startTime = Time.time;
}

非常朴实,这里都是处理我们Atking留下来的烂摊子,把它们收拾好之后,准备下次的Atking或者是NotAtking函数。

生命周期函数

还有一些初始化相关,基本搞定!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private void Awake()
{
lineRenderer = GetComponent<LineRenderer>();
}
// Start is called before the first frame update
void Start()
{
//把各种各样的东西先初始化一下
lastPos = transform.position;
colliders = new Collider[maxAtkNum];

//点光源池
lights = new GameObject[maxAtkNum];
for (int i = 0; i < maxAtkNum; i++)
{
lights[i] = GameObject.Instantiate(Resources.Load<GameObject>("redPoint"), transform.position, Quaternion.identity);
lights[i].SetActive(false);
}
}

动感光波

让我们先来看看真实的激光大概是什么样子的

img

img

大部分外围有淡淡的光芒(我还是比较喜欢紫色激光),中间有一条白色线条,但就这对我来说还是有点不够有表现力,我希望有动态效果,于是在此基础上添加了纹理动画以及顶点动画,这样看起来更有威力了不是吗?

  1. 我们很容易想到用一个纯白色来根据片元的v坐标的sin函数实现,将0-1的线性变化映射为0-1-0平滑过度的曲线,再搭配Gloss对其次方控制宽度。

  2. 之后是外围我想要实现的纹理动画效果了,用ps做了一张左右都为蓝色,中间为紫色的纹理,为保证平滑过渡,所以左右两边必须是一模一样的颜色,之后在顶点着色器中将顶点的uv坐标进行移动,_Time是unity定义的计时器,为float4类型,t代表自场景加载所经过的时间,4个分量分别为(t/20,t,2t,3t),不论用哪一个,将它与我们的ColorSpeed相乘取小数,就可以得到0-1之间的一个数与转换的顶点uv相加就可以得到不断移动的纹理了,但由于这样的方向是反的,我们还需要用1减去小数仍然为0-1之间。

紫蓝紫

  1. 别忘了还有抖抖的效果,也就是我们的顶点动画,这里首先我们一定会想到用随机数,那怎么取得随机数呢?这里可不能像C#脚本一个Ramdon.Range()就得到随机数,所以这里需要想新的方法,在图形学中这种随机也称为噪声,它通常用于实现完全不规则的一些效果,如岩浆、电波等等,具体见参考文献第一篇。这里用三个魔法数字可以得到近乎完全的随机数,第一个问题解决了,还有一个问题是我们不希望整条线都在抖动,那样也会让人感动非常不真实,所以这里我同样让它乘以一个sin函数,映射为0-1,抖动幅度随距离增加,另外通过Magnitude来调节整体抖动幅度。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'
Shader "Particles/Additive" {
Properties{
_TintColor("Tint Color", Color) = (1,1,1,1)
_MainTex("Particle Texture", 2D) = "white" {}
_InvFade("Soft Particles Factor", Range(0.01,3.0)) = 1.0
_ColorGloss("Gloss", Float) = 10
//颜色渐变
_ColorSpeed("Color Speed", Range(0.01, 10)) = 1
//顶点动画
_Magnitude("Distortion Magnitude", Float) = 1
_Frequency("Distortion Frequency", Float) = 1
}

Category{
//分别为开启透明通道,开启渲染类别,以及禁用批处理
Tags { "Queue" = "Transparent" "RenderType" = "Transparent" "DisableBatching" = "True"}
//颜色混合,会和组件上的颜色进行混合处理
Blend SrcAlpha One

SubShader {
Pass {

CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile_particles
#pragma multi_compile_fog

#include "UnityCG.cginc"

sampler2D _MainTex;
fixed4 _TintColor;
float _ColorSpeed;
float4 _MainTex_ST;
float _ColorGloss;
//顶点动画
float _Magnitude;
float _Frequency;

struct appdata_t {
float4 vertex : POSITION;
fixed4 color : COLOR;
float2 texcoord : TEXCOORD0;
};

struct v2f {
float4 vertex : SV_POSITION;
fixed4 color : COLOR;
float4 texcoord : TEXCOORD0;
UNITY_FOG_COORDS(1)
};

//取随机数函数,shader中没有直接可用的函数,返回-1到1(不包含边界)的随机数
float InterleavedGradientNoise(float2 pos)
{
float3 magic = float3(12.9898, 78.233, 43758.5453123);
return frac(magic.z * frac(dot(pos, magic.xy)));
}

v2f vert(appdata_t v)
{
float halfPi = 1.57079632675f;
v2f o;
//随机偏移值
float4 offset = float4(0.0, 0.0, 0.0, 0.0);
//调用该随机函数,传入的参数也很重要,我选择使用顶点位置当作参数
//后面用sin调控光线越远抖的越厉害,Magnitude调控整体幅度
offset.xyz = _Magnitude * InterleavedGradientNoise(float2(v.vertex.x, v.vertex.y)) *sin(v.texcoord.x * halfPi);
o.vertex = UnityObjectToClipPos(v.vertex + offset);
o.color = v.color;
//用zw保存uv,并加上一个时间函数,让uv动起来!
o.texcoord.zw = TRANSFORM_TEX(v.texcoord,_MainTex) +
(1 - frac(float2(_ColorSpeed, 0.0) * _Time.y));
o.texcoord.xy = v.texcoord.xy
//用UnityCG.cginc头文件中内置定义的宏处理雾效,从顶点着色器中输出雾效数据
UNITY_TRANSFER_FOG(o,o.vertex);
return o;
}

sampler2D_float _CameraDepthTexture;
float _InvFade;

fixed4 frag(v2f i) : SV_Target
{
float Pi = 3.1415926535f;
//控制光线内部的白色,Gloass调节大小
fixed ratio = pow(sin(i.texcoord.y * pi), _ColorGloss);
//将纹理与主颜色(我这里就用白色)混合
fixed4 col = tex2D(_MainTex, i.texcoord.zw) * (1 - ratio) + _TintColor * ratio;
//中间向外围慢慢变淡
col.a = sin(i.texcoord.y * pi)
//用UnityCG.cginc头文件中内置定义的宏启用雾效
UNITY_APPLY_FOG_COLOR(i.fogCoord, col, fixed4(0,0,0,0));
return col;
}
ENDCG
}
}
}
}

可以看出初中学的三角函数是多么的好用啊,它让我们实现平滑过渡是如此简单,目前为止大部分的需求都满足了,但还是又一些美中不足,就是强力的激光一定会在周围的地面上产生光影,目前这一点还没能实现,如果大伙们有兴趣,可以自己动手去实现它。

资源地址

链接:https://pan.baidu.com/s/1ew1lCZNnSHyS4A4UC1PAYg?pwd=naqo
提取码:naqo

参考文献

实验通过使命召唤所使用的 Interleaved Gradient Noise 在Shader中生成随机噪声

物体列表的AABB

上一章中基本讲完了各个类的aabb写法,还剩最后一个,物体列表类的aabb生成,下面给出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
...
#include "aabb.h"
...

class hittable_list : public hittable {
public:
...
virtual bool hit(
const ray& r, double t_min, double t_max, hit_record& rec) const override;

//包围盒生成函数
virtual bool bounding_box(
double time0, double time1, aabb& output_box) const override;
...
};

...
bool hittable_list::bounding_box(double time0, double time1, aabb& output_box) const {
//如果物体列表为空,我们无法为空物体生成包围盒,直接返回false。
if (objects.empty()) return false;

aabb temp_box;
//设定一个flag用来初始化一个包围盒。
bool first_box = true;
//遍历列表内物体。
for (const auto& object : objects) {
//如果遍历刀列表内有物体无法建立包围盒(返回fasle),那对不起,物体列表也无能为力了,直接返回。
//注意,这个if中的bounding_box函数把这个子物体的包围盒赋给了temp_box。
if (!object->bounding_box(time0, time1, temp_box)) return false;
//如果是第一次建立包围盒,那直接使用temp_box。
//否则要求本循环子物体的aabb和之前包围盒的包围盒,使用surrounding_box函数。
output_box = first_box ? temp_box : surrounding_box(output_box, temp_box);
//在首次建立包围盒成功之后,把first_box置false。
first_box = false;
}

return true;
}

​ 物体列表的aabb的求法就是逐个子物体遍历,求它们这些物体共同的包围盒。其中使用到了上一章中提到的计算两个包围盒的包围盒的函数surrounding_box。

自此,我们已有的物体类:球类、移动的球类和物体列表类都接入了生成包围盒的函数。

BVH类

是时候把这一切组织起来了,记得上一章中提到的对象划分已经对象划分下的树状结构吗?我们还没有给它起名字呢:我们将创建一个树状结构,树的每一个节点都是一个包围盒或者一个物体,包围盒一层层的嵌套,物体作为叶子节点被一层一层的包裹着,这种结构叫层次包围盒(Bounding Volume Hierarchies),或称BVH

BVH既然是一棵树,我们首先得创建它的节点,和其他所有树结构一样,它的节点得储存孩子信息。如果我们把事情再想得简单一点,每个大包围盒内部包着两个子包围盒,那BVH结构就变成了一个二叉树,看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#ifndef BVH_H
#define BVH_H

#include "rtweekend.h"

#include "hittable.h"
#include "hittable_list.h"

//这个节点类是物体类的子类,我们还是得依靠多态的便利性。
class bvh_node : public hittable {
public:
bvh_node();
//这是传入物体列表的构造,直接调用下面的有vector的构造。
bvh_node(const hittable_list& list, double time0, double time1)
: bvh_node(list.objects, 0, list.objects.size(), time0, time1)
{}
//这个构造函数非常复杂,之后再说。
bvh_node(
const std::vector<shared_ptr<hittable>>& src_objects,
size_t start, size_t end, double time0, double time1);

//override hit函数。
virtual bool hit(
const ray& r, double t_min, double t_max, hit_record& rec) const override;

//生成包围盒的函数
virtual bool bounding_box(double time0, double time1, aabb& output_box) const override;

public:
//本节点的左右子节点指针,它们都是物体类型的指针。
shared_ptr<hittable> left;
shared_ptr<hittable> right;
//本节点的包围盒,会在构造函数里生成。
aabb box;
};

//注意,bvh节点类的包围盒我们会在构造函数里生成,这里直接赋值即可。
bool bvh_node::bounding_box(double time0, double time1, aabb& output_box) const {
output_box = box;
return true;
}

#endif

​ 来看一下hit函数,在这里你会看到包围盒是如何减少计算的——你连我的盒子都碰不到,那我们就没有继续看下去的必要了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bool bvh_node::hit(const ray& r, double t_min, double t_max, hit_record& rec) const {
//你连我的盒子都碰不到,那我们就没有继续看下去的必要了。
if (!box.hit(r, t_min, t_max))
return false;

//继续遍历左右物体。
bool hit_left = left->hit(r, t_min, t_max, rec);

//见下方讲解。
bool hit_right = right->hit(r, t_min, hit_left ? rec.t : t_max, rec);

//遍历过程中只要不是所有的子物体都没碰到,就算产生了碰撞。
return hit_left || hit_right;
}

​ 注意上面的代码中有一个剪枝操作,在第十行中,遍历右子树节点的时候先检查了左子树返回值是否为true,即光线有无和左子树碰撞,如果有,那么在判断右子树的时候就又有一个参照物了:“右子树上的物体是否是先被光线碰到的?或者它被左子树上的物体遮挡了?改变t的范围,只在右子树物体离光源更近的情况下,才认可这次碰撞,其他的直接剪掉。”

我们现在来理一下思路,我们写这样的hit函数会发生什么,假设一个bvh在场景中构造完毕(虽然我们暂时没有给出用来构造bvh的构造函数),即,现在main函数中那个world物体不再是一个物体列表,换做是一个已经构建好的bvh,现在有一根光线自相机或者某处发射过来:

既然有光线,我们就得调用场景中所有物体的hit,现在只有一个物体那就是bvh,我们只需要调用它的hit即可。如果这根光线没有碰到最外层的盒子,那上述代码中的第四行return false就是整个碰撞检测的最后一句代码,在这之前我们只调用了一个aabb的hit函数,无论场景中有多少物体,也只需要调用这一个函数即可。再想想如果我们没有bvh,而是使用物体列表来管理场景中的物体,那我们会做哪些工作——如果t的范围不考虑进去的话,即便这根光线没有碰到任何一个物体,我们也需要调用每个物体的hit函数,其中就包括一些球,每个球意味着要求一次一元二次方程的求根公式。

要知道,场景是无限大的,物体只占了场景中微不足道的某个角落,我们创建的大部分光线实际上都是没有和物体发生碰撞的光线,光考虑这部分光线,bvh就比传统的物体列表要快上几个数量级了。

构造BVH

到此为止,核心问题还没有解决,如何构造一棵bvh树呢?

先来讲一讲上一章的遗留问题,对象划分的依据是什么?首先,必须要明确一个概念:我们现在不需要任何规律的把bvh里的所有物体粗暴的分成两堆,代码也能很好的工作,即便包围盒内部的包围盒比外面的盒子还要大,也不影响代码的运行,充其量只是慢了一点。我们要找的对象划分的依据,是能让程序运行更快,更发挥bvh优势的一种划分方式,它可以是这样一个划分:

先随便找一个轴,x、y或者z轴,把物体列表里的物体按照这个轴从小到大的顺序进行排序并划分成两堆,对于左右子树,用这两堆分别再次进行递归。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include <algorithm>
...

bvh_node::bvh_node(
const std::vector<shared_ptr<hittable>>& src_objects,
size_t start, size_t end, double time0, double time1
) {
//创建一个指针指向形参中的物体列表,增加代码可读性。
auto objects = src_objects;
//随机一个int值,0,1,2分别代表x,y,z轴,这个函数之后会给出。
int axis = random_int(0,2);
//通过选定的是哪个坐标轴选到特定的比较器,注意,这个comparator是一个函数指针
//之后会给出box_x_compare等三个函数的签名即具体函数体。
auto comparator = (axis == 0) ? box_x_compare
: (axis == 1) ? box_y_compare
: box_z_compare;
//本列表中有多少物体?
size_t object_span = end - start;
//如果发现这个列表中只有一个物体了,那令本节点的左右孩子指针都指向这个物体。
if (object_span == 1) {
left = right = objects[start];
}
//如果有俩物体,按照给定轴信息,看看谁在左,谁在右。
else if (object_span == 2) {
if (comparator(objects[start], objects[start+1])) {
left = objects[start];
right = objects[start+1];
} else {
left = objects[start+1];
right = objects[start];
}
}
//如果本段物体列表有超过两个物体,直接对其进行排序,并且二分之后,开启下一轮递归。
else {
std::sort(objects.begin() + start, objects.begin() + end, comparator);
//二分找中值。
auto mid = start + object_span/2;
left = make_shared<bvh_node>(objects, start, mid, time0, time1);
right = make_shared<bvh_node>(objects, mid, end, time0, time1);
}

//注意,代码运行到这里,说明本节点的左右孩子递归代码都已经执行完毕。是时候给他们套上包围盒了。
aabb box_left, box_right;
//给左右节点分别套盒子,并且利用返回信息来判断盒子是否成功生成。
if ( !left->bounding_box (time0, time1, box_left)
|| !right->bounding_box(time0, time1, box_right)
)
//进入这个if表示左右孩子的某个盒子没有生成成功。
std::cerr << "No bounding box in bvh_node constructor.\n";
//注意,这里有一个bug,如果bouding_box返回false,其box一定是没有被赋值的,即为空。
//空box调用下面的surrounding_box函数是会出错的,因为访问了空物体。
//我们暂时没有无法生成包围盒的物体,如:无限大的平面。所以这个问题我们先不要管。
box = surrounding_box(box_left, box_right);
}

随机范围内的int值的函数如下所示,工具函数都写到rtweekend.h里:

1
2
3
4
inline int random_int(int min, int max) {
// 返回[min,max]范围内int值。
return static_cast<int>(random_double(min, max+1));
}

​ 最后,我们给出比较器的函数代码,注意把它写在上述构造函数的前面,让编译器可以成功的找到他们:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//比较两个物体的盒子“大小”的主要函数,第三个参数axis表示比较的是哪个轴。
inline bool box_compare(const shared_ptr<hittable> a, const shared_ptr<hittable> b, int axis) {
aabb box_a;
aabb box_b;
//先求两个物体的盒子。
if (!a->bounding_box(0,0, box_a) || !b->bounding_box(0,0, box_b))
std::cerr << "No bounding box in bvh_node constructor.\n";
//比较两个盒子的较小的边,谁的给定轴分量越大,谁就越大。
return box_a.min().e[axis] < box_b.min().e[axis];
}

//下面是三个轴比较函数,都是调用上面的比较函数。这三个函数存在的意义是方便构造函数中的函数指针。

bool box_x_compare (const shared_ptr<hittable> a, const shared_ptr<hittable> b) {
return box_compare(a, b, 0);
}

bool box_y_compare (const shared_ptr<hittable> a, const shared_ptr<hittable> b) {
return box_compare(a, b, 1);
}

bool box_z_compare (const shared_ptr<hittable> a, const shared_ptr<hittable> b) {
return box_compare(a, b, 2);
}

测试包围盒*

现在回到动态模糊那一章的最后一个场景,我们分别使用物体列表和BVH来装载场景物体,并运行光追器,来对比一下两者的耗时,首先,给main函数打个时间戳:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#if !MULTITHREAD

...
//需要引用此头文件
#include <windows.h>

...
int main() {
//这个函数可以获取当前时间,并转换成某种基于毫秒的整数。
auto start = GetTickCount64();

//main中的全部代码都要被包进来。
...

std::cerr << "\nDone.\n";
//再次获得当前时间,并且减去main函数开头时记录的时间,便得到了毫秒差。
int time = GetTickCount64() - start;
time = time / 1000;
auto minute = time / 60;
auto second = time % 60;
//简单转换一下,并输出出去。
std::cerr << "took " << minute << "m " << second <<"s to complete. \n";
}

}

紧接着,用bvh提到程序中的物体列表,在main函数中调用random_scene的地方,直接把物体列表传到bvh_node类的构造函数里,创建一个bvh即可:

1
2
3
//调用函数生成一个有着许多随机小球的场景!!!
//auto world = random_scene();
auto world = bvh_node(random_scene(),0,1);

传入构造函数的两个时刻值0和1是和相机的快门打开时间和关闭时间保持一致的。这决定了我们程序中所有的移动的球对象的包围盒都是通过这两个时间值来计算的。对于直线运动的球来说,在两个极端情况下的包围盒的包围盒,即是最终的包围盒。

运行程序,可见命令行中的运行时间(release-x86):

img

对比前者——物体列表作为容器下的运行时间,后者bvh容器下的运行时间快了好几倍。

课后实践

  1. 在脑海中构建一个少量物体的bvh,按顺序阅读bvh的构造函数,体会物体被二分和生成包围盒的过程。接下来思考hit函数中包围盒是如何和光线求交的,这有助于理解整体架构。
  2. 将bvh应用于多线程模式,并生成一个视频,比较使用包围盒前后的序列帧生成时间。
  3. 倘若使用八叉树包围盒,我们该如何设计?并了解另外两种包围盒KD-Tree和BSP-Tree。
  4. 将之前所有的关键类的关系做出一个类图,并梳理他们之间的调用过程,回想一下我们是怎么得到一张图片的。

参考文献

https://raytracing.github.io/books/RayTracingTheNextWeek.html

参考自《Ray Tracing: The Next Week》第3.8节到第3.10节。

我们开开心心的讲完了中篇的前两章,渲染出了炫酷的动态模糊效果,它简单而且漂亮。但是,该来的总归还是要来,下面的几章会给大伙儿整些头疼的:包围盒。

光线碰撞中的重复冗余

回顾一下我们发射光线到回收颜色的全过程,就会发现,我们的代码有哪些不合理的地方:

相机朝虚拟视口中的像素格子发出光线,这跟光线会尝试调用场景中所有物体(本项目中一个名为world的物体列表)的hit函数——会按物体插入物体列表时候的顺序对物体进行逐一调用它们自己的hit函数。这些子物体要不还是物体列表,或者是球、移动的球之类的实体物体。

我们再来看看球和移动的球是如何处理光线碰撞的,我们通过解一个二元一次方程来判断球是否和光线碰撞,返回碰撞时刻以及碰撞点信息等等(具体见《球》)。这意味着对于每一次射出光线(无论是相机射出的,还是材质表面反射得来的)都要和场景中所有的球进行一次判断。如果场景中有100个物体,意味着,每一次hit之后反射的光线,就要解100个二元一次方程。假设平均每根光线会在场景中弹射5次,对于每一根光线我们要解500个方程,采样数会把这个数字拉到50000(假设每个像素采样100次),而像素数量会把这个数字拉到亿级别(假设我们的场景是400*300),现在看看我们球类代码中的hit函数,那么一大坨,你能想象每次在命令行中点击回车,电脑运行了几亿次这部分的代码吗?

不合理的地方在哪里呢?假设我们有一个光线朝西边径直而去,在光线射过去的方向的相反方向有99颗球,这九十九颗球也都逃不过碰撞检测函数。这分莫名其妙的开销应该是可以通过某种方式避免的。

总结一下刚刚所说的:光线-物体交集是光线追踪器中的主要时间瓶颈,时间与物体数量成线性关系。这是对同一模型的重复冗余搜索,有没有什么办法可以使之复杂度降低呢?比如通过某种二分法让其的复杂度降低到对数等级?

空间划分与八叉树

我提到了二分法,但应该知道,二分法必须针对已排序的数据才有作用,也就是说,我们必须让场景中的物体依照某种规则变得有序,才可以使用二分法的思想降低复杂度。

假设我们的物体都集中在以原点为中心的单位立方体里面,很容易可以想到一种划分方式:先通过三个坐标轴把空间划分为八个小立方体。

如果把任意一个数带入t,光线的公式img,最终得到的坐标点的x,y,z都不会大于0,那我们就没有必要检查位于第Ⅰ象限的物体了。如果x,y,z有全大于0的情况,说明光线与第Ⅰ象限有交点,我们就对第一象限中的小立方体继续划分——划分成八个更小的立方体,再检查光线是否与它们有交点,位于和光线无交点的小立方体内的物体,我们就无需检查了。这种思想叫空间划分

整个搜索链条就如同树一样,先看有没有和大立方体有交集,如果有交集就再看其中的小立方体,然后一直往下看,直到我们人为规定的某个大小的立方体内不再有更小的立方体,就直接查看该立方体内的物体即可。

img

八叉树——这种结构有一个浅显易懂的名字。很容易可以写出关于八叉树的伪代码(这个八叉树只有两层,第三层就是物体层):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
if (光线碰到了某个立方体)
if(光线碰到了子立方体1号){
if(光线碰到了子立方体1号内的某个物体){
处理碰撞信息。
return true;
}
}
else if(光线碰到了子立方体2号){
...
}
...
else if(光线碰到了子立方体7号){
...
}
else{
...
}
else
return false

我们没有讨论如何判断光线和立方体求交,也没有讨论物体在两个立方体的边界上我们要如何处理,篇幅有限。我们不会在项目中使用八叉树,但是它确实是一个可行的方案,并且很容易被图形工程师们提起。

我们并不准备使用空间划分的八叉树来管理我们场景中的物体,因为还有另一个备选方案可以使用——对象划分。这种空间划分比空间划分使用更为广泛。

对象划分简述

现在假设空间中有15个对象,我们按照某种规律把它们分成俩个部分,如下图所示:

img

至于划分对象的算法我们之后再说。

这是三维空间的某种对象划分在二维上的投影示意图。可见对象划分之后的各个区域是很可能会重叠的,但是也就不再存在一个物体处于区域边界的情况。

当然这里也依然存在一个树状结构。光线先和最外层和紫色部分算交点,如果存在交点才会查看红蓝两个区域的交点情况。

AABB

我们已经决定使用对象划分来加速光线碰撞,这里有很多问题待解决:具体如何划分物体?树状结构如何搭建?新的场景物体又该如何管理(物体列表显然已经不满足要求了)?

这些问题先靠一边,我们先来把目光瞄准到所谓“区域”,也就是包围盒。

包围盒应该是什么样的?我们又如何计算光线与盒子求交呢?

如果不考虑性能,包围盒可以是任何形状,它可以是一个大球,包裹住内部的小球,这样我们只需要算一次一元二次方程就可以得到光线和大球碰撞的结果,如果有碰撞再计算光线和内部小球的碰撞情况。而且我们计算光线和大球求交的时候不需要计算具体反射光线,完全不需要调用材质代码,只需要返回是否碰撞到信息,以便我们去判断需不需要和内部小球进一步计算碰撞即可。

但是光线和球的碰撞计算复杂度还是高了一点,有一种结构最擅长处理和光线求交,这就是轴向立方体。使用轴向立方体作为包围盒的形状,这就是轴向包围盒(**Axis-Aligned Bounding Boxes ,又叫AABB**。

轴向立方体就是所有的边都和坐标轴平行的立方体,它和光线求交的计算复杂度低,且易于理解。

先不看Z轴,在平面上的轴向包围盒如下图所示,空间中的包围盒和其原理相同,可以通过推广得到:

img

平面上的AABB(2D-AABB)即是四条直线img围成的区域。对于这对平行线img,光线会和它们产生两个交点,当然,另外一对平行线同理:

img

看看光线传播到$x$分量为和$x_0$时$x_1$,时间$t_1$和$t0$是多少,只要把光线原点位置坐标向量A的x分量和方向向量b的x分量带入公式$P(t) = A + tb$即可:

imgimg

同理,在y轴上你同样可以得到两个时间:

imgimg

好,有了这四个时间值,我们如何判断这根光线有没有和2D-AABB相交呢?看下图:

img

对于上图中上方的光线,其(t0,t1)和(t2,t3)两个时间区域并无交集,在图中的表现即是蓝色和绿色的线段并无重合处,那就可以断言,这根光线和盒子并无交集。而下面的光线可见明显蓝绿重合,它一定是一根穿过盒子的光线。

很容易就能把这个原理推广到三维的情形下:

3D-AABB由三对面组成,假设光线传播到与XoY面平行的那一对面上的时间是t0,t1,传播到YoZ面上的时间t2,t3,ZoX则是t4,t5。则一定有:光线和盒子相交 <==> (t0,t1)、(t2,t3)和(t4,t5)两两有交集。

伪码如下:

1
2
3
4
5
compute (tx0, tx1)
compute (ty0, ty1)
compute (tz0, tz1)
//返回这三个时间段有无重叠
return overlap?( (tx0, tx1), (ty0, ty1), (tz0, tz1))

对于坐标轴上的两个区间(a,b)和(c,d),判断区间有无重叠,只需要取a和c较大的那一个和b与d中较小的那一个比较,如果前者小于后者,说明有重叠,这部分的伪码如下:

1
2
3
4
bool overlap(a, b, c, d)
f = max(a, c)
F = min(b, d)
return (f < F)

铺垫完成,接下来我们来从代码层面构造AABB。

编写AABB类

创建aabb.h文件,敲入如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#ifndef AABB_H
#define AABB_H

#include "rtweekend.h"

class aabb {
public:
aabb() {}
// 如何唯一确定一个3D-AABB?只需要两个点即可!
aabb(const point3& a, const point3& b) { minimum = a; maximum = b;}

//返回三个分量都较小的那一个点。
point3 min() const {return minimum; }
//返回minimum点在盒子上的对角点,即三个分量都较大的那个点。
point3 max() const {return maximum; }

//光线和AABB求交代码,注意这个hit和物体类的hit没有一毛钱关系。
bool hit(const ray& r, double t_min, double t_max) const {
//for循环判断三个轴向的三对面和光线碰撞的时间。
for (int a = 0; a < 3; a++) {
//见解析。
auto t0 = fmin((minimum[a] - r.origin()[a]) / r.direction()[a],
(maximum[a] - r.origin()[a]) / r.direction()[a]);
auto t1 = fmax((minimum[a] - r.origin()[a]) / r.direction()[a],
(maximum[a] - r.origin()[a]) / r.direction()[a]);
t_min = fmax(t0, t_min);
t_max = fmin(t1, t_max);
if (t_max <= t_min)
return false;
}
return true;
}


point3 minimum;
point3 maximum;
};

#endif

​ 我们使用两个point3来唯一确定一个3D-AABB,就如同我们可以在平面直角坐标系上用左下角和右上角两个点确定一个2D-AABB一样。

我们来看看hit函数里的for循环中做了什么?

首先,我们把minmum和maximum点的$x$分量都带入了公式$P(t) = A+tb$来计算光线穿过这一对面的时间值$t_0$和$t_1$,注意,这里的fmin和fmax函数是为了确保$t_0$小于$t_1$。因为minmum[0]一定是小于maximum[0]的,但是无法确定$\frac{minimum[0]-A_x}{b_x}$就一定比$\frac{maxmum[0]-A_x}{b_x}$小,想想光线从x轴无穷大处朝x变小的方向射过来,这时候$\frac{minimum[0]-A_x}{b_x}>\frac{maxmum[0]-A_x}{b_x}$,我们就需要调换存储和$t_0$和$t_1$。

紧接着,我们开始对区间求交集。hit函数传进来的t_min和t_max代表我们认可的t的范围,这个与物体类的hit函数的参数中的t_min和t_max是一个道理,用来给我们切去不必要的碰撞(如负值t)的。现在把($t_0$,$t_1$)和(t_min,t_max)求交,得到的值如果是一个不存在的区间,则表示“在您认可的t的区间内,不存在一个t让光线传播到AABB的x轴同向对面中”。

紧接着对y和z轴上的对面做同样的操作,期间一旦发现求交后区间不存在,便可断定“光线在给定的时间区间内不曾跨过指定对面”,对三个对面进行这样的操作之后,求交的结果存在与否则代表了“光线是否在某一时刻同时在三个对面之间,即是否曾射入AABB之中”。

优化hit函数

我们换一种方式描述hit代码中的逻辑,下面的代码比起原先的hit函数冗长的逻辑显得更为清晰。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
inline bool aabb::hit(const ray& r, double t_min, double t_max) const {
for (int a = 0; a < 3; a++) {
auto invD = 1.0f / r.direction()[a];
auto t0 = (min()[a] - r.origin()[a]) * invD;
auto t1 = (max()[a] - r.origin()[a]) * invD;
//如果光线的方向向量某个分量为负,则光线一定会逆向穿过本轴上的对面。
if (invD < 0.0f)
std::swap(t0, t1);
t_min = t0 > t_min ? t0 : t_min;
t_max = t1 < t_max ? t1 : t_max;
if (t_max <= t_min)
return false;
}
return true;
}

给物体绑定包围盒

AABB类已经被构造出来了,我们现在得把它和物体类联系起来了,因为包围盒子终归是要包裹住物体的。

先来修改底层,给物体类添加一个纯虚函数。

1
2
3
4
5
6
7
8
9
10
11
#include "aabb.h"
...

class hittable {
public:
...
virtual bool hit(const ray& r, double t_min, double t_max, hit_record& rec) const = 0;
//制造包围盒子的函数。
virtual bool bounding_box(double time0, double time1, aabb& output_box) const = 0;
...
};

来看看这个新的函数,首先,它的返回值是bool类型,这是因为我们得给无法被包围盒包裹的物体一个解释,比如一个无限大的平面(以后可能会有),如果返回true,则表示包围盒生成失败。

生成的包围盒通过参数中的引用传递出去 :aabb& output_box

至于另外两个参数time0和time1,它对于移动的球类以及后续所有动态的物体都是必要的,因为如果不给定一个时间区间,物体运动的轨迹将会无限长,无法被包围盒包裹。

很容易可以写出球类的包围盒生成函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class sphere : public hittable {
public:
...
virtual bool hit(
const ray& r, double t_min, double t_max, hit_record& rec) const override;

//override包围盒生成函数。
virtual bool bounding_box(double time0, double time1, aabb& output_box) const override;
...
};

...
bool sphere::bounding_box(double time0, double time1, aabb& output_box) const {
//这是一个球的外切立方体。它是能包裹住球且体积最小的AABB。
output_box = aabb(
center - vec3(radius, radius, radius),
center + vec3(radius, radius, radius));
return true;
}

​ 下面是移动的球类的包围盒生成函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
...
#include "aabb.h"
...

class moving_sphere : public hittable {
public:
...
virtual bool hit(
const ray& r, double t_min, double t_max, hit_record& rec) const override;

//override包围盒生成函数。
virtual bool bounding_box(
double _time0, double _time1, aabb& output_box) const override;
...
};

...
bool moving_sphere::bounding_box(double _time0, double _time1, aabb& output_box) const {
//我们先生成移动的球在两个极端时间下的包围盒。
aabb box0(
center(_time0) - vec3(radius, radius, radius),
center(_time0) + vec3(radius, radius, radius));
aabb box1(
center(_time1) - vec3(radius, radius, radius),
center(_time1) + vec3(radius, radius, radius));
//然后再求这两个包围盒的包围盒。这个surrounding_box函数会在之后给出。
output_box = surrounding_box(box0, box1);
return true;
}

​ 下面添加两个包围盒的包围盒函数surrounding_box(box0, box1)到aabb.h中,它的本质是通过比较去筛选包围盒中的两个point3的分量,使得minimum的各个分量取两个盒子的minimum分量的较小值,maximum的所有分量都取两个盒子的maximum分量的较大值,如下所示:

1
2
3
4
5
6
7
8
9
10
11
aabb surrounding_box(aabb box0, aabb box1) {
point3 small(fmin(box0.min().x(), box1.min().x()),
fmin(box0.min().y(), box1.min().y()),
fmin(box0.min().z(), box1.min().z()));

point3 big(fmax(box0.max().x(), box1.max().x()),
fmax(box0.max().y(), box1.max().y()),
fmax(box0.max().z(), box1.max().z()));

return aabb(small,big);
}

aabb对于本章开头提到的树状结构来说相当于叶子节点,本章基本完成了叶子节点的构造,但对于整个树状结构的搭建来说,才是刚刚开始。

课后实践

尝试编写物体列表类的包围盒生成函数(这个函数会在下一章给出)。

参考文献

https://raytracing.github.io/books/RayTracingTheNextWeek.html

参考自《Ray Tracing: The Next Week》第3.1节到第3.7节。

动态模糊

“带时间的光线”划开了新旧篇章的界限,新的维度下,新的可能性在孕育。

这一章中就来看看,“时间”会给这个世界带来什么变化。本章我们会创建继“球”之后,第二个实体物体类:“移动的球”。呃,抱歉,它还是球。

匀速运动的球

创建新文件moving_sphere.h,并敲入如下代码,其实其中大部分代码是从球类中复制过来的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#ifndef MOVING_SPHERE_H
#define MOVING_SPHERE_H

#include "rtweekend.h"
#include "hittable.h"


class moving_sphere : public hittable {
public:
moving_sphere() {}
//带参构造,比sphere类要多吃四个参数,分别是两个时间值,以及这两个时间下对应的球心位置。
moving_sphere(
point3 cen0, point3 cen1, double _time0, double _time1, double r, shared_ptr<material> m)
: center0(cen0), center1(cen1), time0(_time0), time1(_time1), radius(r), mat_ptr(m)
{};

virtual bool hit(
const ray& r, double t_min, double t_max, hit_record& rec) const override;

//多出来的函数,这个函数用来返回当前时间下球心位置。
point3 center(double time) const;

public:
//两个时间下的球心位置。
point3 center0, center1;
//两个时间值
double time0, time1;
double radius;
shared_ptr<material> mat_ptr;
};

// 通过插值得到球心位置
point3 moving_sphere::center(double time) const {
return center0 + ((time - time0) / (time1 - time0))*(center1 - center0);
}

#endif

center函数的参数time不一定非要处于time0和time1之间,它也可以小于time0或者大于time1。从另一个角度来说,time0,time1和其对应的球心位置center0,center1并不是球运动的边界,而是用于确定球运动方向和速度的参数。

总结来说,这颗球沿着空间中的某条直线做匀速运动,它在time0时刻处于直线上的center0位置,time1时间下处于center1位置

接下来补完moving_sphere类,它还差一个hit函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
bool moving_sphere::hit(const ray& r, double t_min, double t_max, hit_record& rec) const {
// 传入光线r的发射时间r.time()到center函数里,移动的球会根据这个值调整球心坐标。
vec3 oc = r.origin() - center(r.time());
auto a = r.direction().length_squared();
auto half_b = dot(oc, r.direction());
auto c = oc.length_squared() - radius*radius;

auto discriminant = half_b*half_b - a*c;
if (discriminant < 0) return false;
auto sqrtd = sqrt(discriminant);

auto root = (-half_b - sqrtd) / a;
if (root < t_min || t_max < root) {
root = (-half_b + sqrtd) / a;
if (root < t_min || t_max < root)
return false;
}

rec.t = root;
rec.p = r.at(rec.t);
//同理,法线的计算中的center也得更换成插值计算版本。
auto outward_normal = (rec.p - center(r.time())) / radius;
rec.set_face_normal(r, outward_normal);
rec.mat_ptr = mat_ptr;

return true;
}

材质类对应修改

每次递归都会产生新的光线,得确保时间信息可以传递到新光线上,修改材质类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class lambertian : public material {
...
virtual bool scatter(
const ray& r_in, const hit_record& rec, color& attenuation, ray& scattered
) const override {
...
//把time信息传递给出射光线。
scattered = ray(rec.p, scatter_direction, r_in.time());
...
}
...
};

class metal : public material {
...
virtual bool scatter(
...
) const override {
...
//把time信息传递给出射光线。
scattered = ray(rec.p, reflected + fuzz*random_in_unit_sphere(), r_in.time());
...
}
...
};

class dielectric : public material {
...
virtual bool scatter(
...
//把time信息传递给出射光线。
scattered = ray(rec.p, direction, r_in.time());
...
}
...
};

修改初篇最终场景

我们尝试把初篇最终场景中的所有漫反射小球都替换成移动的球,并且让他们移动的方向都为y轴正方向,为了做出差异性,让所有的小球移动的速度都不同,我们要利用随机数给定不同的参数。

假设相机镜头在 time0=0 时刻打开镜头,time1=1 时刻关闭镜头,且time0时刻这些移动的球正好都在y = 0.2 平面上(这也是我们以前最终场景中的设计),也就是说center0.y() 是 0.2 ,那么center1就可以根据center0加上一个随机长度的y轴正方向向量得到,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
...
#include "moving_sphere.h"

...
hittable_list random_scene() {
...

for (int a = -11; a < 11; a++) {
for (int b = -11; b < 11; b++) {
...

if (choose_mat < 0.8) {
auto albedo = color::random() * color::random();
sphere_material = make_shared<lambertian>(albedo);

// center2表示1.0时刻球心位置。
auto center2 = center + vec3(0, random_double(0,.5), 0);

// 创建moving_sphere时,比sphere多传入四个参数。
world.add(make_shared<moving_sphere>(
center, center2, 0.0, 1.0, 0.2, sphere_material));

} else if (choose_mat < 0.95) {
...

}

再修改相机和图片参数,为了不等待太久,适当降低了分辨率

1
2
3
4
5
6
7
8
9
10
11
12
13
int main() {

// change
auto aspect_ratio = 16.0 / 9.0;
// ↓↓↓
int image_width = 400;
// ↓↓↓
int samples_per_pixel = 100;

...

//相机传入的两个时间值和刚刚移动的球中的值保持一致,方便同步修改。
camera cam(lookfrom, lookat, vup, 20, aspect_ratio, aperture, dist_to_focus, 0.0, 1.0);

会得到:

img

虽然这些移动的球的运动轨迹是“直破云霄”往天上去的,但是看起来就好像是球在平台上弹跳一样。这是因为无论是向下运动还是向上运动,动态模糊看上去都是一个样子的。

以下内容为个人探索(需要先完成初篇第十六章)。

配置ffmpeg*

现在来休息一下,暂停对技术的探索,一起来做一些工程方向的趣事。让我们来生成一系列的图片,并且把它串成视频。你不想看到自己的小球真的动起来吗?如果答案为肯定,那就来继续探索时空光线追踪的极限吧。

图片按序列帧转视频这件差事如果要靠代码完成可得费一番功夫,幸好伟大的前辈们早就写好了统一解决方案——ffmpeg!一个伟大的开源程序!它可以用来记录、转换数字音频、视频,并能将其转化为流,总之,它在音视频方面几乎无所不精无所不能。

但本篇并不是ffmpeg的教程贴,而且为了整体项目的精简,把ffmpeg接入光追项目暂时来看是没有必要。我们仅仅只利用ffmpeg作为外部工具生成视频,或者更准确来说,仅仅只用到一个命令。

一:先需要下载ffmpeg,直接点击该链接进行下载:https://www.gyan.dev/ffmpeg/builds/ffmpeg-git-full.7z。

如果上方链接失效,可以通过以下方法:

  1. 先上官网:https://ffmpeg.org/download.html
  2. 因为我是windows系统,就按下图所示进入链接(其他系统下的ffmpeg安装远比windows下安装简单,具体请自行搜索):

img

进入之后,第一行就是最新的git分支的压缩包,俩个分别是只包含基础功能的以及全部功能的压缩包,随便选一个,因为我还使用ffmpeg做其他项目,所以安装的是full版本,但是应对本项目,essential应该就足够了。

img

你会下载到和刚刚我给你的链接里一样的安装包。

二:把下载的压缩包解压到某个目录,我将其解压到了D:\ffmpeg文件夹下。

三:配置环境变量。我的电脑右键属性-高级系统设置-环境变量-再编辑path-新建一个path为ffmpeg目录下的bin文件夹。(本部分我简单描述,环境变量配置为程序员必修课,如果不会的话去看教程,这里贴一个百度经验教程https://jingyan.baidu.com/article/a17d5285c9b0c48099c8f26a.html),配置完成你的path应该会大概如下多出一行:

img

四:配置完毕之后就可以通过命令行测试是否配置成功,在任意目录下输入ffmpeg -version,如果出现版本信息,即表示配置成功:

img

多线程输出序列帧*

切换代码为多线程模式,修改多线程的main所在文件如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
#if MULTITHREAD
//我们需要用到c语言中的部分内存操作函数,比如sprintf,在C++标准中它们被认为是危险和禁忌的。
//使用这个宏即可解禁这些函数,你亦可将其写入预处理器命令中。
#define _CRT_SECURE_NO_WARNINGS
...
#include "moving_sphere.h"

// 调整为16/9,和本章中动态模糊示例代码保持一致。
const auto aspect_ratio = 16.0 / 9.0;
// ↓↓↓,因为要输出多张图片,我们让其尽可能的快。
const int image_width = 200;

const int image_height = static_cast<int>(image_width / aspect_ratio);
const int samples_per_pixel = 100;
const int max_depth = 50;

...

hittable_list random_scene() {
...

if (choose_mat < 0.8) {
...

// 让center2尽可能的高,我打算让球高高的飞起!
auto center2 = center + vec3(0, random_double(0, 50), 0);

// 时间依然不变,卡在1个单位时间内运行完毕。
world.add(make_shared<moving_sphere>(
center, center2, 0.0, 1.0, 0.2, sphere_material));
} else if (choose_mat < 0.95) {
...
...
}

int main() {
// 视频一共有多少帧。
int video_frame_num = 10;
// 把这些序列帧存放在指定目录下,方便查看和编辑。
std::string video_folder_path = "video";

world = random_scene();
point3 lookfrom(13, 2, 3);
point3 lookat(0, 0, 0);
vec3 vup(0, 1, 0);
auto dist_to_focus = 10.0;
auto aperture = 0.1;

// C的新建文件夹的多种方式之一,这种通过system命令新建文件夹的方式不需要多余的头文件包含,很方便。
std::string command;
// mkdir命令就是命令行中新建文件夹的命令,后跟新文件夹目录
command = "mkdir " + video_folder_path;
// 会在当前目录上创建video文件夹。
// system只接受c风格字符串,所以要用c_str转换一下。
system(command.c_str());
// 具体文件名。
char filename[50];

// 循环创建多帧。
for (int i = 0; i < video_frame_num; i++) {

std::cerr << "LineIndex:" << i << std::endl;

// sprintf和c语言中人尽皆知的printf几乎没有什么不同。
// 唯一的区别是它要把字符串输出给一个char数组而不是标准输出流。
// 图片名需要规律排列,ffmpeg会从编号为0的图片开始串帧成视频。
sprintf(filename, "./%s/videoframe%04d.ppm", video_folder_path.c_str(), i);

buf = new imageoutput(image_width, image_height, filename);
std::vector<std::thread> threads;

//本帧图片的时间区间,随后讲解。
cam = new camera(lookfrom, lookat, vup, 20, aspect_ratio, aperture, dist_to_focus, pow(i / (float)video_frame_num, 5), pow((i + 1) / (float)video_frame_num, 5));

for (int k = 0; k < numThread; k++) {
threads.push_back(std::thread(ray_tracing_by_thread, k));
}
for (auto& thread : threads) {
thread.detach();
}

while (thread_remain > 0) {
std::cerr << "\rScanlines remaining : " << line_remain - 1 << ' ' << std::flush;
}

//注意初始化这些进程监视相关变量。
line_remain = image_height;
thread_remain = numThread;

std::cerr << "\nDone.\n";
buf->buffer_2_file();

delete cam;
delete buf;
}

return 0;
}

#endif

移动的球类是一些匀速运动的小球,但这个匀速运动也是有条件的,那就是时间流速是匀速不变的。

我们将均匀增加的自变量i(每次循环加一),套进img函数,并将其映射到(0,1)区间内(和移动的球在创建之初指定的时间区间保持同步),即可实现球速逐渐加快效果,因为img在此区间为凹函数。可以简单理解为,套用该函数之后,时间流速不再均匀,而是会逐渐加快。

运行代码,如果你是在命令行中运行exe文件,就可以在exe所在目录下找到video文件夹。如果是vs内运行生成,这个文件夹会在代码所在目录下。

img

ffmpeg命令生成视频*

打开命令行,走到video目录,然后输入以下命令:

ffmpeg -r 10 -i videoframe%04d.ppm -q:v 1 output.avi

如图所示:

img

我们分开看看这个命令讲了什么:

ffmpeg 表示运行的exe的名字,系统会在环境变量里面的那些目录去找叫这个名字的exe,显然,它要找的就是ffmpeg/bin文件夹下的那个exe。

-r 10 表示我们要以一秒10帧的速度安排这些图片,最后生成的图片每秒钟会有十张图片播放。

-i videoframe%04d.ppm 表示图片输入,ffmpeg会找指定格式化输入的%d从0开始,这样它就可以找到目录下全部的10张图片。

-q:v 1 表示我们希望最后生成的图像质量高一些,但可惜的是,即使是这样它还是会失去一些清晰度。

output.avi 输出文件目录,没有前缀的话它就会存放在和图片同样的目录里。

在一阵提示输出之后,你会得到视频:

img

它的时长是1秒。我们做到了三个时间统一:移动的球类指定时间,相机区间和最终生成图片时间区间。

时间统一有很多好处,它可以让时间轴一目了然。

当然它们三个也可以不完全统一,而是某种映射关系,这取决于你的设计。

拓展

可以尝试通过修改代码,达到以下效果:

nxr3w-nkpff.gif

i1khw-t25yk.gif

参考文献

https://raytracing.github.io/books/RayTracingTheNextWeek.html

参考自《Ray Tracing: The Next Week》第2.2节到第2.5节。

曝光时间

初篇的最后我们一直在和相机较劲,一个拥有景深(散焦模糊)的相机也并不是我们相机进化的终点,在本篇的开始,我们将让相机进化为完全形态。这将是本系列最后一次改动相机类,在本篇之后,相机类将不会再有任何改动——当然,你自己可以对其进行一些个性化改动或者添加你认为需要的功能,但是对于我们介绍的所有功能中,相机将进化为完全体。

真实的相机,有一个概念叫做曝光时间:它表示镜头打开的时间,在这段时间内射到相机的光线都会被相机捕捉。

但是我们当前的相机,曝光时间是趋于无穷小的,也就意味着,任何高速移动的物体,在相机镜头打开的这一瞬间里,移动的距离都为0

依照我们在散焦模糊那一章的处理思路,我们得自断一臂,通过某些方式让我们的相机产生缺陷去迎合真实的相机。

要怎么模拟曝光时间这一概念呢?按照逆光路模型,我们得让光线在一段时间内先后从相机射出,而不是一瞬间。按照曝光时间的长短,让某一个像素中射出的若干采样光线在此曝光时间内的随机时刻射出,并带回信息即可。

要想实现这一点,需要先改动光线类,让光线和时间挂钩。

请不要把这个时间和我们光线公式中 P( t ) = A + tb 的 t 弄混,这个 t 表示这一根光线在发射之后过了多少时间,并不涉及到光源发射的时间。

看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class ray {
public:
ray() {}
// 更改构造函数,tm的缺省默认值为0。
ray(const point3& origin, const vec3& direction, double time = 0.0)
: orig(origin), dir(direction), tm(time)
{}

point3 origin() const { return orig; }
vec3 direction() const { return dir; }

//tm的getter函数。
double time() const { return tm; }

point3 at(double t) const {
return orig + t*dir;
}

public:
point3 orig;
vec3 dir;

// 光线发射时刻。
double tm;
};

光线现在有发射时间的概念了,接下来修改发射光线的相机类,让相机能发射有不同tm值的光线。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
class camera {
public:
camera(
point3 lookfrom,
point3 lookat,
vec3 vup,
double vfov,
double aspect_ratio,
double aperture,
double focus_dist,
//相机快门打开时间。
double _time0 = 0,
//相机快门关闭时间,开关差值既是曝光时间。
double _time1 = 0
) {
auto theta = degrees_to_radians(vfov);
auto h = tan(theta/2);
auto viewport_height = 2.0 * h;
auto viewport_width = aspect_ratio * viewport_height;

w = unit_vector(lookfrom - lookat);
u = unit_vector(cross(vup, w));
v = cross(w, u);

origin = lookfrom;
horizontal = focus_dist * viewport_width * u;
vertical = focus_dist * viewport_height * v;
lower_left_corner = origin - horizontal/2 - vertical/2 - focus_dist*w;

lens_radius = aperture / 2;
//开关时间初始化
time0 = _time0;
time1 = _time1;
}

ray get_ray(double s, double t) const {
vec3 rd = lens_radius * random_in_unit_disk();
vec3 offset = u * rd.x() + v * rd.y();

return ray(
origin + offset,
lower_left_corner + s*horizontal + t*vertical - origin - offset,
//光线的tm值给一个time0和time1中的随机值。
random_double(time0, time1)
);
}

private:
point3 origin;
point3 lower_left_corner;
vec3 horizontal;
vec3 vertical;
vec3 u, v, w;
double lens_radius;
//快门开关时间。
double time0, time1;
};

现在我们拥有了一个能在曝光时间内随机发射光线的相机!你可能会觉得这根本不算什么,就是给光线类多加了一个成员变量罢了,但这对我们的渲染器来说是跨时代的一步。我们的光追器不再仅仅只是描绘光线在空间上的碰撞,而且开始描绘时间上的交错变化。这叫做时空光线追踪(SpaceTime Ray Tracing)。

参考文献

https://raytracing.github.io/books/RayTracingTheNextWeek.html

参考自《Ray Tracing: The Next Week》第1节和第2.1节。

本章内容为个人探索。

在《抗锯齿》那一章中,增加了多次采样的逻辑后,程序的运行速度开始变得非常缓慢。多次采样是渲染真实感图像的基石,想要保持多次采样的同时加快程序的运行速度,就得另辟蹊径。

线程概念

在计算机中我们常听到进程与线程,进程是资源(CPU、内存等)分配的基本单位,通俗来说就是我们桌面上的每一个应用当点击QQ时,就创建了一个进程,我们所用的VS本地调式也是一个进程。线程是一条执行路径,是程序执行时的最小单位,他们之间的关系就好比一个部门和其中的每一个人,当然人越多干活越快,需要的总工资(内存和cpu资源)也越多。一个线程的使用示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <thread>
func(par1, par2){...}

//直接构造thread对象t,第一个参数为函数,后面依次传入对应函数所需参数
std::thread t(func, par1, par2...);

//detach表示彻底放养t线程,这是一个危险的行为,如果不能保证主线程在t线程之后走完
//就会导致t线程的资源无法回收。
t.detach();

//join t线程到本线程,在join完后表示接下来的代码必须等待t执行完成之后才开始执行。
t.join();

建立输出管理类

单线程代码中,渲染循环是由主线程独自运行完成的。建立多个线程平摊渲染循环中繁杂重复的任务是很容易想到的多线程加速方案。

可以建立N个线程,让它们同时从不同行开始绘制。因为最终的ppm文件是有严格的顺序要求,每个线程绘制的像素都不可能都把画好的像素立即的输出到文件里,这里可以在内存上开一个缓冲区用来暂时存放结果,每个线程绘制好的像素先填入其中,等所有线程都绘制完成的时候,再将数组中的内容输出。

对于这部分缓冲区的管理,和最终输出到ppm文件的操作,我们可以封装到一个类里,创建imageoutput.h文件,敲入如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#ifndef IMAGEOUTPUT_H
#define IMAGEOUTPUT_H

#include <iostream>
#include <fstream>

// 颜色的值最高只到255,只需要8bit存储,使用int类型太过浪费。
// 我们给unsigned char这个8bit类型重命名,用它来当缓冲区类型。
typedef unsigned char D_BYTE;

class imageoutput {
public:
// 构造函数,通过文件名直接输出。
imageoutput(int wth, int hgt, const char* file_nm) :width(wth), height(hgt) {
// 分配一块大小为width * height * 3的区域用于存取像素的rgb。
buffer = new D_BYTE[width * height * 3];
// 建立输出文件流。
outFile = std::ofstream(file_nm);
// ppm文件的开头要加上这句话。
outFile << "P3\n" << width << " " << height << "\n255\n";
}
// 析构函数,释放缓冲区。为保证安全性,需要确认它不为空。
~imageoutput() {
if (buffer != nullptr) {
delete[] buffer;
}
}

// 填写颜色到数组中,需要传入像素的位置,以及rgb值。这里做了之前color类做的一些事情。
void write_buffer(const int& x, const int& y, const double red, const double green, const double blue) {
D_BYTE ir = D_BYTE(255.999 * clamp(red, 0, 0.999));
D_BYTE ig = D_BYTE(255.999 * clamp(green, 0, 0.999));
D_BYTE ib = D_BYTE(255.999 * clamp(blue, 0, 0.999));
// 二维坐标转一维数组坐标,然后依次填入rgb。
int n = (y * width + x) * 3;
buffer[n] = ir;
buffer[n + 1] = ig;
buffer[n + 2] = ib;
}

// 这个函数把整个缓冲区内的数据写入文件。
void buffer_to_file() {
for (int j = (height - 1); j >= 0; j--) {
for (int i = 0; i < width; i++) {
int n = (j * width + i) * 3;
outFile << (int)buffer[n] << " " << (int)buffer[n + 1]
<< " " << (int)buffer[n + 2] << std::endl;
}
}
}

private:
int width;
int height;
D_BYTE* buffer;
std::ofstream outFile;
};

#endif

平摊渲染循环任务

输出管理类提供了写缓冲区和最终生成图片的函数,接下来的任务就很明确了:创建线程们调用write_buffer函数写缓冲区,再在main函数的最后,把缓冲区写成文件。

为了拆分渲染循环,我们把它从main中抽离,并且把与之相关的一些变量都改为全局变量,方便多线程共享调用。

以下代码会重构main函数所在文件,为保留之前的代码,建议新建RayTracingByMultithread.cpp文件作为新的main文件,老代码记得保存,新代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
#include "camera.h"
#include "rtweekend.h"
#include "color.h"
#include "hittable_list.h"
#include "sphere.h"
#include "material.h"
#include "imageoutput.h"
#include <thread>

//图片参数
const auto aspect_ratio = 3.0 / 2.0;
const int image_width = 400;
const int image_height = static_cast<int>(image_width / aspect_ratio);
const int samples_per_pixel = 100;
const int max_depth = 50;

//thread相关,通过hardware_concurrency()获得处理器核心数量,来初始化线程数。
int numThread = max(std::thread::hardware_concurrency(), (unsigned int)1);
//定义一个输出设置
imageoutput* buf;

//相机与场景
hittable_list world;
camera* cam;

//着色函数,直接复制。
color ray_color(const ray& r, const hittable& world, int depth) {...}
//最终场景,直接复制。
hittable_list random_scene(){...}

//把渲染循环切分成numThread份,分配给每个线程执行。
//这是分配第k个线程的任务。
void ray_tracing_by_thread(int k) {
for (int j = image_height - k - 1; j >= 0; j -= numThread) {
for (int i = 0; i < image_width; i++) {
color tmp(0, 0, 0);
for (int s = 0; s < samples_per_pixel; s++) {
auto u = (i + random_double()) / (image_width - 1);
auto v = (j + random_double()) / (image_height - 1);
ray r = cam->get_ray(u, v);
tmp += ray_color(r, world, max_depth);
}
tmp /= float(samples_per_pixel);
//将颜色gamma矫正,存入缓冲区
buf->write_buffer(i, j, sqrt(tmp[0]), sqrt(tmp[1]), sqrt(tmp[2]));
}
}
}

int main() {
// 摆放场景
world = random_scene();

// 初始化缓冲区和输出文件流,并给图片命名。
buf = new imageoutput(image_width, image_height,"image.ppm");

// 线程列表
std::vector<std::thread> threads;

// 放置相机
point3 lookfrom(13, 2, 3);
point3 lookat(0, 0, 0);
vec3 vup(0, 1, 0);
auto dist_to_focus = 10.0;
auto aperture = 0.1;
cam = new camera(lookfrom, lookat, vec3(0, 1, 0), 20, aspect_ratio, aperture, dist_to_focus);

//多线程下的renderloop
for (int k = 0; k < numThread; k++) {
threads.push_back(std::thread(ray_tracing_by_thread, k));
}
for (auto& thread : threads) {
//阻塞主线程,必须等待所有线程执行完毕,再会执行后续的代码。
thread.join();
}
//缓冲区已经填充完毕,输出成文件。
buf->buffer_2_file();

//释放堆上内存。
delete cam;
delete buf;
}

既然选择使用文件流的方式创建文件,那么也不需要使用命令窗口了,可以直接点击vs中的本地调试以生成文件,紧接着就可以在代码所在目录下找到image.ppm文件了。

如果还是采用命令行的方式生成(不使用重定向符号,命令行中直接运行exe),image.ppm文件会生成在exe文件同目录下。

如果本次生成感到卡顿,可以降低线程数量,但总体来说,生成文件的速度肯定比单线程要快上好几倍。

进度提示

最后一步,加上进度提示。

先在每个线程里对共享的记录变量进行操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//剩余行数
int line_remain = image_height;
//剩余多少线程没完成任务
int thread_remain = numThread;

void ray_tracing_by_thread(int k) {
for (int j = image_height - k - 1; j >= 0; j -= numThread) {
for (int i = 0; i < image_width; i++) {
...
buf->write_buffer(i, j, sqrt(tmp[0]), sqrt(tmp[1]), sqrt(tmp[2]));

}
//执行到这说明有一行像素存缓冲区完毕。
--line_remain;
}
//执行到这说明有一个线程执行完毕。
--thread_remain;
}

然后在main函数里进行记录,这次把线程改成detach于主线程。因为主线程可以通过thread_remain来判断线程们的运行情况,就不必担心主线程会先结束了,并且还可以利用主线程的等待时间进行进度的显示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
int main() {

...
cam = new camera(lookfrom, lookat, vec3(0, 1, 0), 20, aspect_ratio, aperture, dist_to_focus);

//多线程下的renderloop
for (int k = 0; k < numThread; k++) {
threads.push_back(std::thread(ray_tracing_by_thread, k));
}
for (auto& thread : threads) {
//detach单独使用是非常危险的,但后续的while循环会卡住主线程,让线程结束顺序变得可控。
thread.detach();
}
while (thread_remain > 0) {
// line_remain-1是为了最后能让显示数字归零,工整好看。
std::cerr << "\rScanlines remaining: " << line_remain-1 << ' ' << std::flush;
}
std::cerr << "\nDone.\n";
buf->buffer_2_file();

delete cam;
delete buf;

return 0;
}

这样控制台就会有进度提示了。

img

数字刷新速度开始很慢,这说明在程序运行的一开始,main函数中的while循环争取到cpu的频率不高,cpu大多被副线程们占据,最后会随着副线程们一个个全都完成,主线程能争取到的cpu时间会变多,刷新速度变得顺畅。

注意到line_remain和thread_remain变量在同时被多个线程访问中所产生的问题吗?如果有那么一个时刻,有两个线程同时拿取了line_remain的值——假设是53,他们同时对其进行—操作,然后相继写回了它们各自认为的正确的值——52,实际上这个值是错误的,有两个线程在同一时刻完成了图片中的某行的渲染,这个值理应减到51。

对变量加同步锁,可以完美的解决这个问题。

1
2
3
4
5
6
7
8
//运行到这的线程都会尝试给some_mutex加上属于它们自己的锁。
//但是如果这时候锁已经有主人了,这个请求就会失败,申请的线程会被阻塞,直到锁被持有者释放。
some_mutex.lock();

//互斥代码
...
//锁的持有者释放了这个锁!现在其他线程们可以来抢夺这枚锁了!
some_mutex.unlock();

锁的本质是创造一个只有一个线程可以独享的区域,上述代码中,some_mutex是个同步信号量,对其实行lock()操作意味有某个线程想要占有这枚锁,之后所有想要拥有这枚锁的线程,都必须要等待本线程释放掉它。

在lock和unlock之间的代码,在同一时间里,至多只有一个线程执行。这样就避免了同时写变量,导致变量值出错的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include<mutex>
//信号量
std::mutex line_remain_mutex;
std::mutex thread_remain_mutex;
for (int j = image_height - k - 1; j >= 0; j -= numThread) {
for (int i = 0; i < image_width; i++) {
...
}
line_remain_mutex.lock();
--line_remain;
line_remain_mutex.unlock();
}
thread_remain_mutex.lock();
--thread_remain;
thread_remain_mutex.unlock();

用宏控制要执行的代码块

因为之后依然是在单线程模式的基础上进行编写的,有时候需要切回单线程模式,来回黏贴代码很麻烦,可以使用如下方式自由的在两种模式之间切换,首先,确保项目目录下有第十五章结束时候的main函数所在文件和本章新创建的文件。

img

在老文件RayTracing.cpp(当然你的可能不叫这个名字)中加入如下宏,包裹所有的代码

1
2
3
4
5
6
#if !MULTITHREAD

//所有代码
...

#endif

在新文件中使用同样的操作:

1
2
3
4
5
6
#if MULTITHREAD

//所有代码
...

#endif

接下来尝试添加一下预编译命令,此处展示vs中更改预编译命令的方法:

注意,一定要保证修改的配置页是当前的代码环境,比如下图中修改的是“活动(Release)”-“活动(Win32)”平台,如果你的代码是Debug平台或者是x64,此更改并不会生效。

img

添加MULTITHREAD命令,本程序便会进入多线程模式:

img

添加完成后,显示如下:

img

在MULTITHREAD的宏定义成功后,老文件中的代码应该全显示为灰色。

img

如果想使用单线程,就再次编辑上述预处理器文本,删除MULTITHREAD命令即可。

其他编辑器有各自的预处理命令的添加方式,或者你可以通过新建一个.h文件作为配置文件,再把#define MULTITHREAD 1or0输入到配置文件中,来定义并使用宏。

拓展

  1. 我使用的是互斥锁来进行加锁,尝试使用其他方法来对临界区访问(也就是互斥区),如: