其他
Vulkan使用教程—SHADERS
大家好。这是我的第二篇Vulkan教程,所讨论的事情将会更加复杂。我们的话题将从基本的三角形绘制方法之后展开继续讨论。如果你没有阅读我的第一篇Vulkan教程,那么我强烈建议你在阅读这篇教程之前先去看看我的第一篇Vulkan教程:《Vulkan教程-101》。
我们首先用适当的投影、视图和模型矩阵建立我们的着色器界面。之后,我们开始布置灯光,最终,我们会得到纹理映射。说实话,我省略了我在我的第一篇教程中提及的一些重要的概念和代码。如你所见,我所忽略的内容会避免使我的第一篇教程复杂化。不可忽视的是,我觉得如果没有我将在这篇文章分享的信息,我的Vulkan教程是不完整的。
但首先,我会简短的回顾一下我的第一篇教程。我很感谢给我提出极好反馈的朋友。让大家都对Vulkan产生浓厚的兴趣是很难的。对于人们对Vulkan的一些误解,我的观点是Vulkan确实比不上 OpenGL 或者 DirectX,Vulkan比它们两个的级别要低。虽然我没有参与这个应用程序接口的制作,但是从我的使用体验来看,我觉得我可以认为OpenGL比Vulkan更好(而不是Vulkan比OpenGL更好),不过这毕竟是我个人的观点。因此,这确实很复杂(甚至更复杂),你们会这样觉得吗?可能不会,因为有80%的人只是想使用Unreal,或者Unity,或者无论其他什么引擎,你们可能都是这些人中的一员,这也是极好的。但如果你们身处在剩下的20%那部分,你们若为那80%的人编写程序,那么Vulkan可能就变得很有潜力了。我的意思是,对于第三方或者折中的解决方案来说,Vulkan还有很大的空间。难道这是早就设计好的吗?
现在,为了证实这个问题,让我们回到一些奇怪的细节中来寻找答案。我将遵循与第一篇教程相同的原则来分析。下面的语句包含了记录所有代码以及提交的标示符,因此你可以从这份报告中自行查找(方法和第一篇教程相同)。
我并不是在写一个文章的框架结构,我是在向你展示那里的所有代码,出于教程的需要,我找到了这个典型的例子。
有些人问我为何又从头讲起,说实在的,依我个人的经验来看,如果你仅仅会使用一个第三方库(你知道如何操作)来解决你的主要问题,这不能轻易的说你已经理解了解决方法。因为即便你能使它运作,你仍然不理解它运作的机理。可能现在你认为你理解的它的运作机理,并且你将继续提出假设,继续编写代码,但是这些都是不符合现实的。如果我要学习,我便要学习正确的知识。你曾注意到如今的代码都多么可怕吗?请允许我撕心裂肺的发泄一下VIVA ÀPROGRAMAÇÃO 。。。(我并没有用西班牙语。。。如你所知,就像历史的分歧等等)(开个玩笑啦!)
好吧,我们言归正传。那么Vulkan到底怎样?首先让我们建立一个测试矩阵,并将它上传到我们的着色器上。 建立Uniform缓冲区
我们在第一篇教程中遗漏的一个Vulkan的重要特点是,我们不向着色器提交任何参数。我们想提交一些矩阵,也许还有其他一些不常变的着色器参数。这些就是我们的Uniforms,并且我们将要把它们建立起来。首先,我们用熟悉的代码创造一个缓冲区来容纳一个4×4的矩阵。为了做到这样,我们需要建立一个 Vk缓冲区,建立一个Vk设备储存器,我们需要分配并绑定着色器。我们将它储存在一个新的体系中,命名为shader_uniform,之后我们建立一个该格式的变量,并将这个新变量添加到vulkan_context中。
当我们的着色器模块建立完成之后,我们将建立好的缓冲区添加到着色器模块上。现在,我们定义一个单位矩阵,并且是缓冲区满足设定的条件。下面这个代码和我们经常用于创建顶点缓冲的代码非常相似。
值得注意的是我们是如何给我们的4×4矩阵分配恰好足够的空间,并且我们使用的语句也变成了VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT。现在,余下的代码应该也变得很熟悉了。
万事俱备,只欠东风。只剩一件事情要做,那就是映射内存并且把我们的单位矩阵复制到缓冲区。
好了,到这里我们已经有了可以输入矩阵的缓冲区,但是现在的问题时,我们该如何将我们输入的矩阵变成着色器的参数呢?
描述符
现在,我们不得不谈谈描述符,描述符集,以及描述符集布局。这就是我在第一篇教程没有加入这条语句的原因。
描述符是一种不透明数据结构,它代表了一个着色器所使用的资源。所有的描述符被集合在描述符集的项目中。这些不透明的项目中储存了一组描述符。在描述符集中,描述符的类型和数量是通过描述符集布局来定义的。
Vulkan 支持很多种描述符类型。我们将使用VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER 这一类型来创建我们统一的缓冲区,并且我们将使用VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER这一类型来创建我们的样板(后面将会介绍)。(Vulkan使用规范中的第十三章有关于Vulkan支持的所有描述符类型的介绍,无论如何,我还是十分推荐你们阅读以下这一章。)
我们的着色器通过特殊的变量访问缓冲区和图像资源区,Vulkan间接的把着色器和缓冲区与图像视图绑定在一起。这种绑定机制的设定是为了同我们Vulkan的描述符集相匹配。最终描述符集布局项目被用于绘制需要同描述符集联合的资源,绘制着色器平台和着色器资源库的接口。说实话,这些语句是高等级的联合语句,我们用于连接描述符和储存区或者其他硬件资源区。如果我给你们看一些语句的例子,可能会更容易理解。
如果你们还记得,或者找到我们创建传递途径布局的代码,你们会注意到,我们并没有设定集布局。我们首先需要建立我们的集,之后才能设定我们的集布局。现在就让我们先为Uniform缓冲区建立Vk描述符集布局的绑定,然后再建立Vk描述符集布局:
记录下绑定的符号:0。当我们为着色器编码的时候我们会用到。剩余的绑定语句大家自己就可以理解。 值得注意的是,我们的Uniform仅能用于顶点着色器。如果你想让它可以同所有的着色点平台连接,你可以更改它的代码,例如VK_SHADER_STAGE_ALL_GRAPHICS 。或者其他VkShaderStageFlagBits参数的排列组合。 那么接下来,我们就来用这个新建立的单一绑定建立我们的集布局:
记住我曾说过的,描述符集是包含描述符的一个储存库吗?没错,接下来我们就需要给描述符集分配一些储存空间。为了做到这样,我们需要在我们能够分配我们描述符集的地方建立一个Vk描述符池:
我们还是十分保守的,让储存空间的大小刚好容纳一个Uniform缓冲描述符。一旦我们建立好描述符池,我们就可以分配给我们的描述符集:
值得注意的是,我们所使用的集布局是作为分配的参数。这是因为集布局实际上自身包含了分配的方法和内容的信息。我们把集保存在我们的上下文中,当我们之后在绘制程序中需要它时我们就可以方便的使用了。 这一阶段几乎已经完成了。但还剩一件非常重要的事情没有做。我们的描述符集仅仅是被分配了,但是却是高度的未初始化。事实上的问题是,每次我进行到这里,我的电脑都崩溃了,按规格来说,所有通过图像或者调度命令途径静态调用的入口,必须在描述符集被调用命令使用之前被迁移出去。好吧,系统崩溃还是很酷炫的,不过还好。让我们通过写入我们的单位矩阵来更新描述符集(通过我们的Uniform缓冲区):
而且,我们可以回到我们的途径布局创建,并且将它更新,让它使用我们的布局集:
建立着色器 到现在为止,我们还没有应用任何有关我们之前建立的成果。我们仅需要一两个步骤来通过并且使用我们的uniform缓冲区。在我们的渲染函数中,我们需要添加一个命令使得描述符集绑定到绘图管线。在我们写命令之前,我们先用一个调用vkCmdBindDescriptorSets的命令。一旦完成了与绘图管线的绑定,记录在我们命令缓冲区的后续渲染将使用我们描述符集的绑定,直到我们绑定另一个描述符集。
现在,我们能够从我们的顶点着色引擎中获取矩阵。在GLSL中,集和绑定数是通过布局限定符分配的。值得注意的是,数组元素被隐式分配连续启动,数组元素阵列的第一个元素的指数等于1。之后我们将会明白这句话的意思。现在,我们先更新着色器代码:
好了,到这里就需要进行一些关于着色器接口的解释,特别是关于顶点输入接口。顶点着色器从一个接口输入变量(我们用关键词in来修饰这些变量),接口带有顶点输入属性。这些接口是用来匹配我们设置的输入属性的(通过使用修饰布局),我们在 VkGraphicsPipelineCreateInfo0中 pVertexInputState中 pVertexInputState部分的设置。如果你找到了这条语句,你就会看到我们曾定义通过绑定0来容纳我们顶点位置属性。 我们的Uniform变量是和描述符集着色器接口是相互关联的。值得注意的是绑定修饰的用法,我们用于作为使VkDescriptorSetLayoutBinding(描述符集布局绑定)绑定为0。而且,我们的uniform缓冲区块必须要根据一些严格的规则来布置,在GLSL规范中的std140布局规则(如果想获取更多信息,请查看规范章节14.5.4)。 事实上,当你运行之后,你应该会发现和之前相同的结果:我们美丽的宝蓝三角。静静的看着这幅景象。。。但我保证我们并不是只能哑口无言!事已至此,我们需要讨论矩阵。这是一个很大的讨论话题,在这里就不说了。我喜欢把需要运算的矩阵放在右侧,把注释放在最后一栏。那就这样吧,如果你更喜欢其他的布局方式,不要忘记转置你的矩阵并且乘在左面。 现在,让我们把三角形向右移动一点,就是为了确保一切运行正常。回到我们单位矩阵编码,做以下的改动:
重新编译并运行,你就会看到三角形滑移到了右侧。现在一切都正常了,不亦乐乎? MVP 好了,让我们严肃点。我们的目标是用我们的投影、视图、模型矩阵来填充uniform缓冲区。接下来我们更新代码,首先从建立矩阵开始:
走到这里,我们的"Model-View-Projection"(模型-视图-投影)矩阵。我不是在展示如何得到投影矩阵,我很抱歉。而且,这里没有做透视变换,因为固定功能流水线会自动帮我们完成。现在,让我们再加入一些其他的代码:
值得注意的是,我们把我们矩阵和一些额外内容储存在文档里。你可能早就知道为什么这么做,这么做就会允许我们驱动我们的相机并且测试我们的MVP 。让我们继续,我们需要更新分配并且更新uniform的代码。毕竟,我们现在已经输入了3个矩阵:
我们应该一直思考的问题应该是,现在,我们有三个矩阵,我们该如何将它们接入着色器呢?好消息是,我们不需要处理描述符。我们仍然只有一个描述符和一种绑定。我们所需要做的是用我们的新矩阵来填充缓冲区:
之后呢,我们要编码更新我们的uniform块,让我们在着色器中得到两个或者更多的4×4矩阵。还记得我在处理数组元素时关于隐式连续分配的大惊小怪吗?没错,这就是它如此重要的原因。投影矩阵将占用前十六个单精度浮点,视图矩阵占用接下来的十六个单精度浮点,模型矩阵占用最后的单精度浮点。
现在,我们该如何更新视图矩阵呢?如果我们不去关心与缓冲区更新的同步,那么我们就可以直线到达,很令人惊讶吧。我们先从简单的部分开始,更新我们的摄像机。在我们的渲染函数中,摄像机的更新就在顶部:
这可以驱动我们的摄像机后移并形成三角形。 接下来,我们必须要更新我们的uniform缓冲区。这是一个关于绘制我们uniform缓冲储存区并且写入当前值的问题。我们要解决的唯一问题就是我们必须使得写入的值会对当前帧产生影响。这意味着确保在我们向阵列提交命令缓冲之前设备储存器和映像储存器是关联的。这能通过分配储存空间形成一个带有VK_MEMORY_PROPERTY_HOST_COHERENT_BIT 属性的堆内存来实现,关联也可以通过调用vkFlushMappedMemoryRanges() 和vkInvalidateMappedMemoryRanges()来确保映像储存是与主机和设备相连的。我们的储存区不在这个堆内存中,因此我们人为调用 vkFlushMappedMemoryRanges()这个语句。而且,我们确保我们开始执行仅仅在我们已经上传了我们的新的uniform缓冲区之后,上传的方式是通过在命令缓冲区顶部放置一个内存屏障:
现在你的三角形应该已经在你的屏幕上开party了。这时,我们已经形成了模型-视图-投影,而且我们进行了有效的3D渲染。 我们宝蓝的三角形就显得很老旧很呆板。让我们做点什么改善一下。
顶点属性 为了使我们的三角形看起来更漂亮,我们需要添加一些额外的顶点属性,比如说法线和UV贴图。我们更新顶点结构,再添加这些新的属性。
然后,给我们的三个顶点添加一些有用的值:
我们让这两个位于上面的顶点有一个Y轴正向的法线,并且第三个位于最下面的顶点的法线是指向屏幕的。这是我故意为之,之后这将允许我们测试我们光照的代码。到此,简单的部分就结束了。我们需要回到我们顶点输入配置代码位置,更改它来创造正确的绑定:
注意我们是如何为每个属性设定位置信息。这是我们之后需要匹配到着色器上的值。我们仅剩一件事没有做,那就是让这些正确的值通过VkPipelineVertexInputStateCreateInfo:
好啦,这就是管线设置。现在我们更新着色器代码。我们从顶点着色器开始,我们曾经在顶点属性中把布局装饰添加到我们的法线(1)和UV贴图(2)的位置,我们从我们通过几个输入变量第一次绘制顶点输入接口的地方开始。我们还需定义我们的顶点输出接口,通过构造一个名为 vertex_out的结构,将顶点输出接口匹配下一个界面(对我们工作来说,下一个界面就是片段着色器的输入接口)。这个结构的成分组成了顶点界面和片段界面的接口:
这时,仅需书写实际的顶点着色器:
(如果你正苦思冥想我到底在对这些计算做些什么,那你可能就要再读读书了。谷歌搜索"propernormal transformation adjoint matrix"(“适当正态变换伴随矩阵”),或者阅读这里。)
现在我们可以在片段着色器中定义我们的输入,作为顶点输出的镜像:
就在我们假定有光的摄像位置点,我们要用这些信息来进行简单的照明设定:
那会产生一种稍微有趣一些的三角形渲染,但我觉得我们还是能改善一下的。 纹理贴图 有一种改善渲染的方法,就是利用UV贴图来绘制我们的三角形。我希望你现在应该想想我们该怎样做。首先,我们需要一个VkImage 和一个 VkImageView。我们还需要分配影像并将影像填充。这都是你早已熟悉的事。 但是在我们进入编写代码之前,我们需要加载一个影像。好吧,我试过了,但是我不能把BMP装载机装载一个足够小的代码例句中,因此我决定我们要制造一些编程艺术!一个棋盘纹理就可以轻松解决并且我们可以飞快地完成它。下面是代码:
尽情自由地使用你们的影像加载器吧。为了我们伟大的目的,这是极好的。接下来,我们建立影像:
注意我们的初始布局是设定到 VK_IMAGE_LAYOUT_PREINITIALIZED.的。这个设定会告诉Vulkan我们将把影像填充并且当我们改变布局时系统不会将影像内容丢弃。而且,通过规范,由于我们正在使用初始布局,所以我们把tiling语句设定到VK_IMAGE_TILING_LINEAR。 接下来是我们分配并绑定影像内存区的时候了。一大波熟悉代码正在接近:
还剩唯一一件要做的事就是上传我们美妙的棋盘纹理。这个代码和uniform缓冲区更新代码非常相似,并且我们也要确保更新设备内存:
接下来,我们把影像布局从VK_IMAGE_LAYOUT_PREINITIALIZED更改到着色器预期位置:VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL:(这应该是整篇教程中重复次数最多的代码了!)
这样的话,还有什么没做呢?对了,就是影像视图:
那可是一大堆的代码。但是现在,我们有了一个可以使用的样本的影像。我们将在VkSampler中定义将影像转换为样本的方式。一个采样器涵盖影像的陈述,而且通过从影像进行滤波和其他变换的方式来使用。下面是建立一个采样器的代码:
好了,我们已经完成了资源的设置。现在,我们想从我们片段着色器纹理创建样本。如果想实现这个创建,我们需要在我们VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER类型的描述符集中创建一个新的绑定。这个类型将一个影像和采样器组合在一起。并且,按规格,它将在一些平台有更好的表现。它也更容易使用。因此,让我们更新描述符集布局代码:
值得注意的是,我们还需要更新我们的着色器池创建代码。因为现在,我们需要着色器池能够提供一个新类型的描述符:
为了结束我们的代码,我更新了我们最近分配组合的采集器:
几乎已经完成了。在片段着色器上,我们能够像这样生命我们的uniform 2D 采样器:
注意我们是如何在顶点着色器上从相同的集中绘制绑定1作为我们uniform缓冲区。这让我们能够从着色器代码的纹理中建立样本:
到这里我们的教程就完成了。我希望你能够回顾我所讲的内容并且理解我为什么决定把这部分教程从第一篇教程移除。无论怎样,现在的你应该已经对Vulkan有足够的了解,余下的API你们就可以自己攻克了。我接下来要调查的事情是为着色器推送常数,如何有效的输送场景图(你在哪储存几何,你怎样更新同步模型矩阵?),可能还有如何设置另一个图形流水线来建立一些阴影图。
近期热文:
经验分享丨项目实践项目孵化丨渠道发行做有梦想的游戏人
-GAME AND DREAM-