游戏艺术效果烟雾周围总是有一种神秘的气氛。它在美学上令人赏心悦目,而且难以建模。像许多物理现象一样,它是一个混沌系统,因此很难预测。模拟的状态在很大程度上取决于其各个粒子之间的相互作用。
这正是使用 GPU 解决如此大问题的原因:它可以分解为单个粒子的行为,在不同的位置同时重复数百万次。
在本教程中,我将引导您从头开始编写烟雾着色器,并教您一些有用的着色器技术,以便您扩展您的武器库并开发自己的效果!
你会学到什么
这是我们将努力实现的最终结果:
单击以产生更多烟雾。你可以 在 CodePen 上 fork 和编辑它。
我们将实现Jon Stam 关于游戏中的实时流体动力学的论文中提出的算法。您还将学习如何渲染到纹理,也称为使用帧缓冲区,这是在着色器编程中实现许多效果的非常有用的技术。
开始之前
本教程中的示例和具体实现细节使用 javascript 和ThreeJS,但您应该能够在任何支持着色器的平台上进行操作。(如果您不熟悉着色器编程的基础知识,请确保您至少完成了本系列的前两个教程。)
所有代码示例都托管在 CodePen 上,但您也可以在与本文相关的GitHub 存储库中找到它们(这可能更具可读性)。
理论与背景
Jos Stam 论文中的算法有利于速度和视觉质量而不是物理准确性,这正是我们在游戏设置中想要的。
这篇论文看起来可能比实际复杂得多,尤其是如果你不精通微分方程。但是,此图总结了该技术的全部要点:
这就是我们要获得逼真的烟雾效果所需的全部实现:每个单元格中的值在每次迭代中消散到其所有相邻单元格。如果不清楚这是如何工作的,或者您只是想看看它的外观,您可以修改这个交互式演示:
在 CodePen 上查看交互式演示。
单击任何单元格会将其值设置为100
。您可以看到每个单元格如何随着时间的推移逐渐失去其邻居的价值。单击“下一步” 查看各个帧可能是最容易看到的。切换显示模式以查看如果我们使颜色值对应于这些数字会是什么样子。
上面的演示都是在 CPU 上运行的,每个单元都有一个循环。这是该循环的样子:
//W = number of columns in grid //H = number of rows in grid //f = the spread/diffuse factor //We copy the grid to newGrid first to avoid editing the grid as we read from it for(var r=1; r<W-1; r++){ for(var c=1; c<H-1; c++){ newGrid[r][c] += f * ( griddata[r-1][c] + gridData[r+1][c] + gridData[r][c-1] + gridData[r][c+1] - 4 * gridData[r][c] ); } }
这个片段确实是算法的核心。每个单元格从其四个相邻单元格中获得一点点,减去它自己的值,其中 f
是一个小于 1 的因子。我们将当前单元格值乘以 4 以确保它从较高值扩散到较低值。
为了澄清这一点,请考虑以下场景:
取中间的单元格(在网格中的位置 [1,1]
)并应用上面的扩散方程。假设f
是0.1
:
0.1 * (100+100+100+100-4*100) = 0.1 * (400-400) = 0
不会发生扩散,因为所有单元格的值都相等!
如果我们考虑左上角的单元格(假设图中网格之外的单元格都是): 0
0.1 * (100+100+0+0-4*0) = 0.1 * (200) = 20
所以我们净增加了20!让我们考虑最后一个案例。经过一个时间步长(将此公式应用于所有单元格),我们的网格将如下所示:
让我们再看一下中间单元格上的漫反射:
0.1 * (70+70+70+70-4*100) = 0.1 * (280 - 400) = -12
我们净减少 了 12 个!所以它总是从较高的值流向较低的值。
现在,如果我们想让它看起来更逼真,我们可以减小单元格的大小(您可以在演示中这样做),但在某些时候,事情会变得非常缓慢,因为我们被迫按顺序运行通过每一个细胞。我们的目标是能够在着色器中编写它,在这里我们可以利用 GPU 的强大功能同时并行处理所有单元(作为像素)。
因此,总而言之,我们的一般技术是让每个像素在每一帧中将其一些颜色值传递给它的相邻像素。听起来很简单,不是吗?让我们实现它,看看我们得到了什么!
执行
我们将从一个在整个屏幕上绘制的基本着色器开始。为确保它正常工作,请尝试将屏幕设置为纯黑色(或任意颜色)。这是我使用的设置在 JavaScript 中的外观。
你可以 在 CodePen 上fork 和编辑它。单击顶部的按钮可查看 html、css和 JS。
我们的着色器很简单:
uniform vec2 res; void main() { vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = vec4(0.0,0.0,0.0,1.0); }
res
并pixel
在那里给我们当前像素的坐标。我们将屏幕的尺寸res
作为统一变量传递。(我们现在没有使用它们,但我们很快就会使用它们。)
第 1 步:跨像素移动值
这是我们要再次实现的内容:
我们的一般技术是让每个像素每帧将其一些颜色值提供给其相邻像素。
以目前的形式来说,这对于着色器来说是不可能的。 你能看出为什么吗?请记住,着色器所能做的就是为它正在处理的当前像素返回一个颜色值——所以我们需要以一种只影响当前像素的方式休息。我们可以说:
每个像素应该 从它的邻居那里获得一些颜色,同时失去一些自己的颜色。
现在这是我们可以实现的。然而,如果你真的尝试这样做,你可能会遇到一个根本问题......
考虑一个更简单的情况。假设您只想制作一个随着时间的推移慢慢将图像变为红色的着色器。你可以这样写一个着色器:
uniform vec2 res; uniform sampler2D texture; void main() { vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = texture2D( tex, pixel );//This is the color of the current pixel gl_FragColor.r += 0.01;//Increment the red component }
并期望,每一帧,每个像素的红色分量都会增加0.01
. 相反,您将得到的只是一张静态图像,其中所有像素都比开始时稍微红一点。尽管着色器每帧都运行,但每个像素的红色分量只会增加一次。
你能看出为什么吗?
问题
问题是我们在着色器中所做的任何操作都会发送到屏幕上,然后永远丢失。我们现在的流程是这样的:
我们将统一变量和纹理传递给着色器,它使像素稍微变红,将其绘制到屏幕上,然后重新从头开始。我们在着色器中绘制的任何内容都会在下次绘制时被清除。
我们想要的是这样的:
我们可以代替直接绘制到屏幕上,而是绘制一些纹理,然后将该纹理绘制到屏幕上。您会在屏幕上获得与其他情况相同的图像,但现在您可以将输出作为输入传回。所以你可以让着色器建立或传播一些东西,而不是每次都被清除。这就是我所说的“帧缓冲技巧”。
帧缓冲区技巧
通用技术在任何平台上都是相同的。用您使用的任何语言或工具搜索 “渲染到纹理”应该会显示必要的实现细节。您还可以查看如何使用帧缓冲区对象,这只是能够渲染到某个缓冲区而不是渲染到屏幕的另一个名称。
在 ThreeJS 中,它的等价物是WebGLRenderTarget。这就是我们将用作渲染的中间纹理。还有一个小警告:您不能同时读取和渲染相同的纹理。解决这个问题的最简单方法是简单地使用两个纹理。
让 A 和 B 成为您创建的两个纹理。您的方法将是:
将 A 通过着色器,渲染到 B。
将 B 渲染到屏幕上。
将 B 通过着色器,渲染到 A。
将 A 渲染到屏幕上。
重复 1。
或者,更简洁的编码方式是:
将 A 通过着色器,渲染到 B。
将 B 渲染到屏幕上。
交换 A 和 B (因此变量 A 现在保存 B 中的纹理,反之亦然)。
重复 1。
这就是它所需要的。这是 ThreeJS 中的一个实现:
你可以 在 CodePen 上fork 和编辑它。新的着色器代码位于 HTML选项卡中。
这仍然是一个黑屏,这是我们开始的。我们的着色器也没有太大的不同:
uniform vec2 res; //The width and height of our screen uniform sampler2D bufferTexture; //Our input texture void main() { vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = texture2D( bufferTexture, pixel ); }
除了现在,如果你添加这一行(试试看!):
gl_FragColor.r += 0.01;
您会看到屏幕慢慢变红,而不是仅仅增加0.01
一次。这是一个非常重要的步骤,因此您应该花点时间尝试一下,并将其与我们初始设置的工作方式进行比较。
挑战:与使用设置示例相比,如果您 gl_FragColor.r += pixel.x;
在使用帧缓冲区示例时放置会发生什么?花点时间思考为什么结果不同以及为什么它们有意义。
第 2 步:获取烟源
在我们可以让任何东西移动之前,我们首先需要一种方法来制造烟雾。最简单的方法是在着色器中手动将一些任意区域设置为白色。
//Get the distance of this pixel from the center of the screen float dist = distance(gl_FragCoord.xy, res.xy/2.0); if(dist < 15.0){ //Create a circle with a radius of 15 pixels gl_FragColor.rgb = vec3(1.0); }
如果我们想测试我们的帧缓冲区是否正常工作,我们可以尝试添加颜色值而不是仅仅设置它。你应该看到圆圈慢慢变得越来越白。
//Get the distance of this pixel from the center of the screen float dist = distance(gl_FragCoord.xy, res.xy/2.0); if(dist < 15.0){ //Create a circle with a radius of 15 pixels gl_FragColor.rgb += 0.01; }
另一种方法是用鼠标的位置替换那个固定点。您可以传递第三个值来指示是否按下鼠标,这样您就可以单击以创建烟雾。这是一个实现。
点击添加“烟雾”。你可以 在 CodePen 上 fork 和编辑它。
这是我们的着色器现在的样子:
//The width and height of our screen uniform vec2 res; //Our input texture uniform sampler2D bufferTexture; //The x,y are the posiiton. The z is the power/density uniform vec3 smokeSource; void main() { vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = texture2D( bufferTexture, pixel ); //Get the distance of the current pixel from the smoke source float dist = distance(smokeSource.xy,gl_FragCoord.xy); //Generate smoke when mouse is pressed if(smokeSource.z > 0.0 && dist < 15.0){ gl_FragColor.rgb += smokeSource.z; } }
挑战:请记住,着色器中的分支(条件)通常很昂贵。您可以在不使用 if 语句的情况下重写它吗?(解决方案在 CodePen 中。)
如果这没有意义,在之前的光照教程中有更详细的关于在着色器中使用鼠标的解释。
第 3 步:扩散烟雾
现在这是最简单的部分——也是最有价值的部分!现在我们已经得到了所有的部分,我们只需要最后告诉着色器: 每个像素应该 从它的邻居那里获得一些颜色,同时失去一些自己的颜色。
看起来像这样:
//Smoke diffuse float xPixel = 1.0/res.x; //The size of a single pixel float yPixel = 1.0/res.y; vec4 rightColor = texture2D(bufferTexture,vec2(pixel.x+xPixel,pixel.y)); vec4 leftColor = texture2D(bufferTexture,vec2(pixel.x-xPixel,pixel.y)); vec4 upColor = texture2D(bufferTexture,vec2(pixel.x,pixel.y+yPixel)); vec4 downColor = texture2D(bufferTexture,vec2(pixel.x,pixel.y-yPixel)); //Diffuse equation gl_FragColor.rgb += 14.0 * 0.016 * ( leftColor.rgb + rightColor.rgb + downColor.rgb + upColor.rgb - 4.0 * gl_FragColor.rgb );
我们和以前f
一样有我们的因素。在这种情况下,我们有时间步长(0.016
是 1/60,因为我们以 60 fps 运行),我一直在尝试数字直到到达14
,这看起来不错。结果如下:
单击以添加烟雾。你可以 在 CodePen 上 fork 和编辑它。
哦哦,卡住了!
这与我们在 CPU 演示中使用的扩散方程相同,但我们的模拟卡住了!是什么赋予了?
事实证明,纹理(就像计算机上的所有数字一样)具有有限的精度。在某些时候,我们减去的因子变得非常小,以至于它被四舍五入到 0,所以模拟卡住了。为了解决这个问题,我们需要检查它是否不低于某个最小值:
float factor = 14.0 * 0.016 * (leftColor.r + rightColor.r + downColor.r + upColor.r - 4.0 * gl_FragColor.r); //We have to account for the low precision of texels float minimum = 0.003; if (factor >= -minimum && factor < 0.0) factor = -minimum; gl_FragColor.rgb += factor;
我使用r
组件而不是rgb
来获取因子,因为使用单个数字更容易,并且因为所有组件都是相同的数字(因为我们的烟雾是白色的)。
通过反复试验,我发现0.003
这是一个很好的阈值,它不会被卡住。我只担心它为负数时的因素,以确保它总是可以减少。应用此修复程序后,我们将得到以下结果:
单击以添加烟雾。你可以 在 CodePen 上 fork 和编辑它。
第 4 步:向上扩散烟雾
不过,这看起来不太像烟雾。如果我们希望它向上流动而不是向各个方向流动,我们需要添加一些权重。如果底部像素总是比其他方向具有更大的影响,那么我们的像素似乎会向上移动。
通过调整系数,我们可以得出一个看起来相当不错的方程:
//Diffuse equation float factor = 8.0 * 0.016 * ( leftColor.r + rightColor.r + downColor.r * 3.0 + upColor.r - 6.0 * gl_FragColor.r );
这就是它的样子:
单击以添加烟雾。你可以 在 CodePen 上 fork 和编辑它。
关于漫反射方程的注释
我基本上摆弄了那里的系数,使它看起来很好向上流动。你也可以让它向任何其他方向流动。
需要注意的是,很容易让这个模拟“炸毁”。(尝试改变6.0
那里,5.0
看看会发生什么)。这显然是因为细胞获得的比失去的多。
这个方程实际上就是我引用的论文所说的“不良扩散”模型。他们提出了一个更稳定的替代方程,但对我们来说不是很方便,主要是因为它需要写入它正在读取的网格。换句话说,我们需要能够同时读取和写入同一个纹理。
我们所拥有的对于我们的目的来说已经足够了,但是如果你好奇的话,你可以看看论文中的解释。您还将在函数的交互式 CPU 演示diffuse_advanced()
中找到替代方程。
快速修复
如果您玩弄烟雾,您可能会注意到的一件事是,如果您在那里生成一些烟雾,它会卡在屏幕底部。这是因为该底部行上的像素试图从下面的像素中获取值他们,不存在。
为了解决这个问题,我们只需确保底行中的像素 0
位于它们下方:
//Handle the bottom boundary //This needs to run before the diffuse function if(pixel.y <= yPixel){ downColor.rgb = vec3(0.0); }
在 CPU 演示中,我通过简单地不使边界中的单元扩散来解决这个问题。您也可以手动将任何越界单元格设置为0
. (CPU 演示中的网格在每个方向上扩展了一行一列的单元格,因此您实际上看不到边界)
速度网格
恭喜!你现在有了一个工作的烟雾着色器!我想简要讨论的最后一件事是论文提到的速度场。
您的烟雾不必均匀地向上或向任何特定方向扩散;它可以遵循如图所示的一般模式。您可以通过发送另一个纹理来做到这一点,其中颜色值表示烟雾应该在该位置流动的方向,就像我们在照明教程中使用法线贴图指定每个像素的方向一样。
事实上,您的速度纹理也不必是静态的!您可以使用帧缓冲区技巧来实时更改速度。我不会在本教程中介绍它,但有很多潜力可供探索。
结论
如果要从本教程中学到什么,那就是能够渲染到纹理而不是仅仅渲染到屏幕是一种非常有用的技术。
帧缓冲区有什么用?
一种常见的用途是游戏中的后处理 。如果您想应用某种颜色过滤器,而不是将其应用到每个对象,您可以将所有对象渲染到屏幕大小的纹理,然后将着色器应用到最终纹理并将其绘制到屏幕上。
另一个示例是在实现需要多次传递的着色器时,例如 blur。 您通常会通过着色器运行图像,在 x 方向上模糊,然后再次运行以在 y 方向上模糊。
最后一个示例是延迟渲染,如之前的照明教程中所讨论的,这是一种有效地将许多光源添加到场景中的简单方法。很酷的一点是,计算照明不再取决于您拥有的光源数量。
- 第 1 步:跨像素移动值
- 问题
- 帧缓冲区技巧
- 第 2 步:获取烟源
- 第 3 步:扩散烟雾
- 哦哦,卡住了!
- 第 4 步:向上扩散烟雾
- 关于漫反射方程的注释
- 快速修复
- 帧缓冲区有什么用?