以前我已经决定在ClojureScript中实现一个相当基本的光线投射引擎。这非常有趣,体验非常棒。我已经实现了一个小迷宫游戏,并考虑在引擎中添加更多功能,比如相机抖动和墙高更改。但是当我开始研究这些功能时,我很快就明白了,我想要转到更有趣的东西上,比如也使用光线的真实3D渲染引擎。
显然,我的第一个想法是关于编写光线跟踪器1。这项技术是广为人知的,最近获得了很大的吸引力。有了光线跟踪的本机硬件支持,很多游戏都在使用它,有很多教程讲授如何实现一个2。简而言之,我们在3D空间投射一束光线,并计算它们的轨迹,寻找哪些光线会击中和反弹。不同的材质有不同的反弹属性,通过跟踪从相机到光源的光线,我们可以模拟照明。还有很多不同的方法来计算反弹,例如对于全局照明,与光线投射不同,大多数光线跟踪器需要多边形信息才能工作,光线投射只需要知道墙的起点和终点。
我一直想要一种类似的3D渲染方法,我们根据对象的数学表示来指定对象。就像球体一样,我们只需指定一个中心的坐标和半径,我们的光线就会找到与它的交点,为我们在屏幕上绘制这个球体提供了足够的数据。最近,我读到了一个类似的技术,它使用光线在屏幕上绘制,但不是像光线投射那样投射无限的光线,而是按步骤行进一条光线。它还使用了一个特殊的技巧,使这个过程非常优化,因此我们可以使用它来渲染真实的3D对象。
我已经决定将这篇文章的结构与那篇关于光线投射的帖子类似,所以这将是另一篇阅读很久的文章,通常更多的是关于茴香而不是光线行进,但最后我保证我们会得到类似于以下内容的东西:
所以,就像光线投射一样,我们首先需要了解光线行进引擎是如何在纸上工作的。
光线行进可以类似于光线投射器进行说明,不同之处在于它需要更多步骤才能渲染图像。首先,我们需要一个摄影机和一个要查看的对象:
我们的第一步是投射光线,但是,与光线投射不同的是,我们将投射一部分光线:
然后我们检查光线是否与球体相交。不是,所以我们再做一步:
哎呀,光线过冲,现在位于球体内。这对我们来说并不是很好的选择,因为我们希望光线直接结束于对象的曲面,而不计算与对象本身的交点。我们可以通过投射较短的光线来修复此问题:
但是,这是非常低效的!而且,如果我们稍微改变角度或移动相机,我们又会过度拍摄,这意味着我们要么会得到错误的结果,要么需要很小的步长,这会破坏计算过程,我们如何解决这个问题呢?
这个问题的解决方案是一个带符号的距离函数,或所谓的距离估计器。想象一下,如果我们知道我们在任何时间点离对象有多远?这意味着我们可以向任何方向发射这种长度的光线,但仍然不会击中任何东西。让我们向场景中添加另一个对象:
现在,让我们绘制两个圆,这两个圆将表示从对象到我们要投射光线的点的距离:
我们可以看到,有两个圆,一个比另一个大。这意味着,如果我们选择最短的安全距离,我们可以安全地向任何方向投射光线,任何东西都不会超调。例如,让我们向正方形投射一条光线:
我们可以看到,我们还没有到达广场,但更重要的是我们没有超调。现在我们需要再次行进光线,但它应该覆盖多远?要回答这个问题,我们需要另一个距离估计,从光线端到场景中的对象:
我们再一次选择较短的距离,向广场行进,然后再次获得距离,并重复整个过程:
你可以看到,每走一步到物体的距离就会变小,因此我们永远不会超过物体。然而,这也意味着,如果我们真的这样做了,我们将走很多非常小的步骤,直到我们最终完全击中物体。这不是一个好主意,因为它比使用固定距离效率更低,产生的结果太准确,这是我们并不真正需要的。所以,我们不是一直前进,直到我们准确地击中物体,而是行进足够多的时间。例如,如果我们这样做的话,我们会走足够多的时间,因为它比使用固定距离的效率更低,产生的结果太准确,这是我们并不真正需要的。在到物体的距离足够小之前,没有继续行进的真正意义,因为很明显我们很快就会击中物体。但这也意味着,如果光线靠近物体的边缘,我们要做很多昂贵的计算距离估计的步骤。
这是一条平行于正方形一侧的光线,它朝向圆形行进:
我们做了很多看似毫无意义的测量,如果光线更靠近正方形的一侧,我们会做更多的步骤。但这也意味着,我们可以使用这些数据(因为我们已经计算了它)来渲染诸如辉光或环境光遮挡之类的东西。
一旦光线击中一个物体,我们就有了所有需要的数据。光线代表了屏幕上的一个点,我们投射的光线越多,图像的分辨率就越高。由于我们没有使用三角形来表示物体,所以我们的球体永远是平滑的,无论我们离它有多近,因为没有涉及多边形。
基本上就是这样。光线行进是一个非常简单的概念,就像光线投射器一样,尽管它有点复杂,因为我们现在必须在3D空间中计算东西。所以让我们通过安装所需的工具和设置项目来开始实现它。
正如您从标题中了解到的,我们将使用两个主要工具来创建Ray-Marcher,它们是免费的游戏引擎LöVE和编程语言Fennel。我选择了Fennel,因为它是一种类似Lisp的语言,可以编译成Lua,而且我是Lisp的忠实粉丝。但是我们也需要在某个地方画画,我知道没有LuGUI工具包。但是有一个运行Lua代码的游戏引擎LÖVE,它能够在所有系统上运行,因此是我们任务的完美候选者。
安装步骤可能因操作系统而异,因此请参阅手册3、4。在撰写本文时,我使用的是Fedora GNU/Linux,因此对我来说,这意味着:
$sudo DNF install Love luarocks readline-devel$luarock install--local fennel$luarock install--local readline#要求readline-devel$export path=";$path:$home/.luarock/bin";
最好将$HOME/luarock/bin(如果安装不同,则将另一个路径)永久添加到shell中的PATH变量,以便能够使用已安装的实用程序,而无需每次都指定完整路径。您可以通过在命令行中运行fennel来测试是否正确安装了所有内容。
$fennel欢迎使用Lua 5.3上的Fennel 0.5.0!使用(Doc Thing)查看文档。>;>;(+1 2 3)6>;>;
对于其他发行版,安装步骤可能会有所不同,而对于Windows,我认为跳过readline部分是安全的,这是完全可选的,但在REPL中进行编辑会更方便一些。
安装完毕后,让我们创建项目目录和main.fnl文件,我们将在其中编写代码。
仅此而已!我们可以通过将以下代码添加到main.fnl来测试是否一切正常:
现在我们可以用fennel编译它--编译main.fnl>;main.lua,从而生成main.lua文件,然后运行Love。(点是故意的,表示当前目录)。
就像在raycaster中一样,我们需要一个将发射光线的摄影机以及一些要查看的对象。让我们从创建一个Camera对象开始,该对象将存储坐标和旋转信息。我们可以这样做,方法是使用var声明文件的本地变量,稍后可以使用集5更改该变量:
对于那些不熟悉Lisp的人,尤其是Clojure,让我快速解释一下这个语法是什么。如果您知道这些东西,可以跳过这一部分。
我们首先使用var特殊形式,它将一个值绑定到如下名称:(var name值)。因此,如果我们在shell中使用fennel命令启动REPL,并写入(Var A 40),将创建一个新的变量a。然后,我们可以通过键入a并按Return键来检查它是否具有所需的值:
然后,我们可以使用SET SPECIAL FORM更改此变量的内容,其工作方式如下(SET NAME NEW-VALUE):
现在是花括号和方括号。花括号中的所有东西都是一个哈希图。我们可以使用任何Lua值作为键,最常见的选择是字符串,但是茴香有定义键的附加语法-冒号后跟单词::A。这被称为关键字,在茴香中它本质上与";a";相同,但是我们不需要写一对引号。但是,关键字不能包含空格和其他一些符号。在茴香中,它本质上与";a";相同,但是我们不需要写一对引号。但是,关键字不能包含空格和其他一些符号。
因此,在REPL中写入{:A 0:B2:C:HELLO}将生成一个新表,其中包含三个键值对,稍后我们可以使用另一种语法-点..将其与var结合使用,我们可以看到它是有效的:
此语法还有一个简写,即,我们可以键入m.b并访问:b键的值:
请注意,即使我们将:c的值指定为:hello,REPL还是将其打印为";hello";。
现在我们只剩下方括号了,这是一个简单的向量,它可以增大和缩小,并在其中存储任何Lua值:
但是,Lua并没有真正的向量或数组,它使用表来实现这一点,其中键只是索引。因此,上面的代码等同于这个茴香表达式{1 0 2";a";3";b c";4(fn[x]x)},但是为了方便起见,我们可以使用方括号。
请注意,我们可以将索引表(向量)和普通表(哈希图)组合在一起。我们可以如上所示,将索引指定为键,或者定义一个向量变量并将其中的键设置为某个值:
>;>;(var v[0 1:A])>;>;(集合V.A 3)>;>;v{:a 3 1 0 2 1 3";a";}。
因此,Camera实质上是一个Lua表,其中存储了关键点:位置、:X-旋转和:Y-旋转,每个关键点都存储了一个值。我们使用一个向量作为位置,使用两个浮点作为旋转角度。现在我们可以制作对象,但在此之前,我们需要一个场景来存储这些对象:
是的,这就是我们的场景。没有什么花哨的东西,只是一个空向量,我们稍后会向其中添加对象。
现在我们可以创建这些对象了,所以让我们从可能最简单的球体开始。我还将简要解释光线行进与其他创建3D图形的方法的不同之处。
什么是球体?这取决于我们正在使用的域。让我们打开Blender,移除默认立方体,并使用Shift+a、网格、UV球体创建球体:
对我来说,这看起来一点也不像球体,因为它是由矩形组成的。但是,如果我们细分曲面,我们可以得到更正确的表示:
这看起来更像一个球体,但这仍然只是一个近似值。理论上,如果我们非常接近它,我们会看到边和角,特别是带有平面着色的边和角。此外,每个细分都会添加更多的点,计算成本也会越来越高:
我们必须做这些权衡,因为当我们需要实时处理时,我们不需要非常精确的球体。但是光线行进没有这个限制,因为光线行进中的球体是由点和半径长度定义的,然后我们可以使用带符号的距离函数来处理。
(fn球体[半径位置颜色]➊(let[[x y z]➋(或位置[0 0])[r g b](或颜色[1 1 1])]{:半径(或半径5):位置[(或x 0)(或y 0)(或z 0)]:颜色[(或r 0)(或g 0)(或b 0)]:sdf球距➌})。
有很多事情在进行,所以让我们潜入其中。
在大多数类型化语言中,我们会定义一个类或结构来表示这个对象,但是在Fennel中(因此在Lua中),我们只能使用表。这是我最喜欢的这类语言的一部分。
因此,我们使用fn特殊形式创建了一个名为➊的函数,它有三个参数:半径、空间位置pos和颜色let。然后我们看到另一个特殊形式let,它用于引入局部作用域变量,并且具有另一个很好的属性-析构➋。
让我们快速了解一下let在这种情况下是如何工作的。如果您知道解构是如何工作的,您可以跳过这一部分。
我们引入了两个局部变量a和b,它们分别包含值1和2,然后计算它们的和并返回结果。
这很好,但是如果我们想要计算三个向量元素乘以b的和呢?让我们把一个向量放到a中:
有很多方法可以做到这一点,比如使用对元素求和的函数对向量进行Reduce,或者从循环中的向量中获取值,然后将这些值放入某个局部变量中。但是,在我们的项目中,我们总是确切知道将有多少元素,所以我们只需按索引取出这些元素,而不需要任何类型的循环:
>;>;(设[a[1 2 3]b2A1(.。A1)a2(.。A 2)A3(.。A 3)](*(+a1 a2 a3)b))12。
但是,这非常冗长,而且不是很好。我们可以通过跳过局部变量定义并直接在总和中使用值来使其变得不那么冗长:
>;>;(设[a[1 2 3]b 2](打印(..。";第二个元素的值是";(.。A 2)(*(+(.。A 1)(.。A 2)(.。A 3))b))第二个元素的值为212。
然而,这又一次不是很好,因为我们必须重复相同的语法三次,如果我们想在几个地方使用向量中的第二个值怎么办?就像这里,我添加了print,因为我特别关心第二个元素的值,并希望在日志中看到它,但我必须重复自己并获取第二个元素两次。我们可以使用本地绑定来实现这一点,但我们不想手动执行此操作。
这就是解构派上用场的地方,相信我,这是一件非常方便的事情。我们可以指定一个模式,该模式应用于我们的数据,并为我们绑定变量,如下所示:
>;>;(设[[a1a2a3][1 2 3]b2](打印(..。";第二元素的值为";a2))(*(+a1 a2 a3)b)第二元素的值为212。
这比前面的任何示例都要短得多,并且允许我们在多个位置使用任何向量值。
>;>;(var m{:A-KEY 1:B-KEY 2})>;>;(设[{:A-KEY a:B-KEY b}m](+a b))3
这也有一个表示键名称和所需本地绑定名称何时匹配的简写:
>;>;(var m{:a1:b2})>;>;(设[{:a:b}m](+a b))3。
--向量解构--(设[[a b][1 2]](+a b))local_0_={1,2}local a=_0_[1]local b=_0_[2]return(a+b)--(let[{:a:b}{:a 1:b}](+a b))local_0_={a=1,b=2}local a=_0_[";a";]本地b=_0_[";b";]返回(a+b)。
这真的没什么特别的,但是这个例子仍然展示了Lisp宏系统的强大功能,在这个宏系统中实现了解构。但是当我们在函数形式中使用它时,它变得非常酷,我们稍后将看到这一点。
如果我们现在调用(➌),我们会得到一个错误,因为我们为键sdf指定了一个尚不存在的值sdf。sdf代表符号距离函数。也就是说,这是一个函数,它将返回从给定点到对象的距离。当点在对象外部时,距离为正,当点在对象内时,距离为负值。
让我们为球体定义一个SDF。球体的伟大之处在于,要计算到球体表面的距离,我们只需要计算到球体中心的距离,然后从这个距离减去球体的半径。
(LOCAL sqrt math.sqrt)➊(fn sphere-Distance[{:pos[sx sy sz]:Radius}[x y z]]➋(-(sqrt(+(^(-sx x)2)(^(-sy y)2)(^(-sz)2))Radius)。
出于性能原因,我们将math.sqrt声明为保存函数值的局部变量sqrt,以避免重复查找表格。
正如后来指出的,Luajit确实优化了这类调用,并且不会重复查找方法调用。这对于普通Lua仍然是正确的,所以我将保持原样,但是如果您愿意,您可以跳过所有这些局部定义,直接使用方法。
在➋,我们再次看到解构,但是不是在LET块中,而是在函数参数列表中。这里本质上发生的是这样的-函数接受两个参数,第一个是散列图,它必须具有与三个数字的向量相关联的:pos关键字,以及一个:RADIUS关键字和一个值。第二个参数只是三个数字的向量。我们立即将这些参数解构为函数体局部的一组变量。Hashmap被解构为球位置向量,该向量立即解构为sx、sy和sz,以及存储球的半径的半径变量。第二个参数被解构为x,然后我们使用上面的公式计算结果值,但是Fennel和Lua只理解从上到下的顺序定义,所以我们需要先定义球面距离,然后再定义球面距离。
让我们通过传递几个点和一个半径为5的球体来测试我们的函数:
>;>;(球体距离(球体5)[5 0 0])0.0>;>;(球体距离(球体5)[0 150])10.0>;(球体距离(球体5)[0 0 0])-5.0。
很好!首先我们检查我们是否在球体的表面上,因为球体的半径是5,我们也把x坐标设为5,然后我们检查我们是否离球体有10左右的距离,最后我们检查我们是不是在球体内部,因为球体的中心和我们的点都在原点上。
这是可行的,因为Lua中的方法是语法糖,当我们编写(s:sdf p)时,它本质上等于(s.sdf s p),并且我们的距离函数将sphere作为它的第一个参数,这允许我们利用方法语法。
现在我们需要一个距离估计器-一个函数,它将计算到所有对象的距离,并返回最短的一个,这样我们就可以安全地将光线扩展这个量。
(LOCAL绘制距离1000)(FN Distance-估计器[点场景](var min绘制距离)(var color[0 0 0])(每个[_object(ipains场景)](let[Distance(object:SDF point)])(When(<;Distance min)(设置最小距离)(设置颜色(.。对象:颜色)(值最小颜色)
该函数将使用我们的符号距离函数计算从给定点到场景中每个对象的距离,并将选择该光线的最小距离和颜色。尽管从距离估计器返回颜色意义不大,但我们在这里这样做是因为我们不想仅仅为了获得端点的颜色而再次计算整个过程。(=。
>;>;(距离估计器[5 4 0][(球体)(球体2[5 7 0][0 1 0])])1.0[0 1 0]。
它起作用了,我们得到了到第二个球体的距离,它是颜色的,因为我们指定的点离这个球体比到另一个球体更近。
有了相机、物体、场景和这个功能,我们就有了所有需要的东西来开始拍摄光线并将其渲染到屏幕上。
就像在光线投射器中一样,我们从相机投射光线,但现在我们在3D空间投射光线。在光线投射中,我们的水平分辨率是由光线数量指定的,而垂直分辨率基本上是无穷大的。对于3D,这不是一个选项,所以我们的分辨率现在取决于2D光线矩阵,而不是1D矩阵。
快速计算。我们需要投射多少光线才能填满512乘4。
.