Vulkan 编程模型

Vulkan 编程模型,下面我们将深入讨论Vulkan的编程模型。对于初学者来说,首先需要理解下面几个概念:

  • Vulkan的编程模型。
  • 渲染的执行模型。
  • Vulkan的工作流程。

下图给出了Vulkan应用程序编程模型的自顶向下的实现过程,我们将深入学习这个过程,探索一些更细分级别的模块和子功能。

Vulkan 编程模型

硬件初始化

当一个Vulkan应用程序启动的时候,它的第一项工作就是初始化硬件设备。应用程序需要与加载器进行通信来激活Vulkan的驱动。下图给出了加载器(Loader)的各个子模块的框图。

Vulkan 编程模型

  • 加载器:加载器是一段应用程序启动时执行的代码,它使用平台无关的方式来定位系统中的Vulkan驱动。以下给出了加载器的职责说明:
  • 定位驱动:这是加载器的主要职责,它需要从当前系统中的指定位置来定位Vulkan驱动。如果找到了驱动程序,那么将加载它。
  • 平台无关性:初始化Vulkan的过程对于所有的系统平台来说都是相同的。这一点与OpenGL不同,后者创建设备环境的过程在不同环境的窗口系统API下都是不一样的,包括EGL、GLX和WGL。Vulkan的平台差异性都是通过功能扩展的方式来体现的。
  • 注入层:加载器支持层次化的结构,并且可以在运行过程中随时注入不同类型的层。这样做的一个巨大的好处是,驱动不需要做任何验证(也不需要在继续执行之前保存当前的任何状态)来判断应用程序使用的API是否是合法的。因此,我们完全可以根据程序开发的需要,在开发阶段打开所有需要注入的层,而在发布程序的时候关闭它们。例如,可注入的层能够实现的功能包括:
  • 跟踪Vulkan API的指令执行。
  • 捕获渲染的场景,稍后再继续执行。
  • 为了满足调试需要,进行错误处理和验证。

Vulkan应用程序首先需要和加载器库执行一次握手操作,并初始化Vulkan的功能驱动,加载器库负责动态地载入Vulkan API。加载器还提供了一种机制来实现某个层自动加载到所有的Vulkan应用程序,这一特性被称作隐式启用层(Implicit-Enabled layer)。

当加载器定位到驱动位置并成功链接到API之后,应用程序就可以开始执行下面的操作了:

  • 创建一个Vulkan实例。
  • 查询物理设备上所有的可用队列。
  • 查询扩展功能并保存为新的函数指针,例如WSI或者有特定功能的API。
  • 支持注入层来实现错误检查、调试或者验证的功能。

窗口展示表面

当加载器成功地定位到Vulkan的驱动程序后,我们就可以使用Vulkan API来绘制一些内容了。为此我们需要用一幅图像来承载绘制的任务,并且将它放到展示窗口上进行显示,如图所示。

Vulkan 编程模型

构建展示图像和创建窗口的工作与平台密切相关。在OpenGL中,窗口是通过底层平台进行链接的,而窗口系统负责创建设备/环境以及对应的帧缓存。与OpenGL不同,Vulkan在创建设备/环境的过程完全不需要包含一套窗口系统。这是通过窗口系统集成(Window System Integration,WSI)API完成的。
WSI包括了一系列跨平台的窗口系统管理功能:

  • 一套独立的跨平台实现,可以支持大多数系统平台,包括Windows、Linux、Android等OS。
  • 一套一致的API标准,可以简便地创建窗口表面并显示它们,不需要关注过多的细节。

WSI支持多个窗口系统,包括Wayland、X、Windows,它同时还通过交换链的方式实现了图像所有权的管理。
WSI提供了交换链机制来实现多幅图像的同时使用,此时窗口系统只显示一幅图像,而应用程序同步开始准备下一幅。
下图显示了这种双缓存的图像交换过程。它包括两幅图像,分别命名为第一幅图和第二幅图。通过WSI的使用,我们可以在应用程序和显示设备之间反复交换这两幅图像。

Vulkan 编程模型

WSI是作为显示设备和应用程序之间的接口使用的。它可以确保显示设备和应用程序处理图像的过程互不干涉。因此,当应用程序在处理第一幅图的时候,WSI会将第二幅图传递给显示设备进行内容的渲染。当应用程序完成了第一幅图的绘制之后,它将图像提交到WSI,然后获取第二幅图并继续处理,如此往复。

在这里,系统需要先后执行如下任务:

  • 创建一个本地窗口(类似Windows OS中的CreateWindow方法)。
  • 创建WSI表面并关联到窗口上。
  • 创建交换链来显示表面。
  • 从创建后的交换链中获取绘制后的图像。

资源设置

设置资源的过程意味着将数据存储到内存区域中。数据可以是任何类型的,例如,顶点属性,如位置、颜色或者图像类型/名称。当然,数据总是保存在内存当中,以便Vulkan访问它。

OpenGL会通过隐式的方式来管理场景背后的内存数据,与之不同的是,Vulkan提供了一整套底层接口来控制和管理内存。Vulkan在物理设备之上提供了多种多样的内存类型,以便应用程序有更好的机制来显式地管理各种不同类型的内存数据。

内存堆可以按照其表现形式,划分为两种不同的类型:

  • 宿主本地host local):这是一种速度较慢的内存。
  • 设备本地device local):这是一种带宽更高的内存类型,速度较快。

内存堆也可以按照其配置方式进行划分:
* 设备本地(device local):这种类型的内存是关联到物理设备的。
* 对设备可见。
* 对宿主不可见。
* 设备本地,宿主可见(device local,host visible):这种类型的内存也是关联到物理设备的。
* 对设备可见。
* 对宿主可见。
* 宿主本地,宿主可见(host local,host visible):它是宿主机的本地内存,但是速度比本地设备更慢。
* 对设备可见。
* 对宿主可见。

Vulkan中的资源是交由应用程序显式地进行管理的,所有的内存控制接口都直接暴露出来。以下给出了资源管理的主要过程:

  • 资源对象(resource object):设置资源的时候,应用程序需要负责分配资源所用的内存。资源可以是图像,也可以是缓存对象。
  • 分配(allocation)和子分配(suballocation):当我们创建了资源对象之后,它们只关联了一个逻辑地址,并没有真的物理地址可用。应用程序负责分配物理内存并且将逻辑地址绑定到内存。完全的分配过程是非常耗时的,而子分配则是一种高效的内存管理的方式,它可以将物理内存的很大一部分立即分配完成并存入不同的资源对象。子分配是由应用程序负责完成的。如图给出了从物理内存中实现对象的子分配的过程。
    Vulkan 编程模型
  • 稀疏内存(sparse memory):对于非常庞大的图像对象,Vulkan可以支持全部稀疏内存相关的功能。稀疏内存是一种特殊的功能,可以存储巨大的图像资源,并且可以比实际的内存容量更大。这一技术会将图像分割为多个小块,并且根据应用程序的实际逻辑,只加载当前必需的小块。
  • 阶段缓存(staging buffer):对象和图像缓存数据的布设是通过不同阶段来完成的,这里通过两类不同的内存区域来完成物理分配的过程。存储资源的理想内存区域对于宿主机是不可见的。因此,应用程序需要首先将资源设置到阶段缓存中,它对于宿主机是可见的,然后再传递到理想的存储区域。
  • 异步传输(asynchronous transfer):我们通过各种异步指令来实现数据在任意图形或者DMA/传输队列中的异步传递。

物理内存的分配需要付出很大的代价。因此,一种良好的习惯是先分配一块较大的内存,然后使用子分配的方法来创建对象。

与之相反的是,OpenGL的资源管理过程并没有提供这么细节的内存控制方法。它不具备宿主或者设备内存的概念。驱动会私下完成所有的幕后分配工作。此外,分配和子分配的过程也不是完全透明的,不同驱动层面的实现方法可能有所不同。这种连续性的缺失和隐式的内存管理会导致各种无法预测的问题。以此为戒,Vulkan会严格地在选定的内存中进行对象的分配,这样它的行为就是高度可预测的。

因此,在资源设置的阶段,用户需要先后执行如下任务:

1)创建一个资源对象;
2)查询应用程序内存实例,创建一个内存对象,例如缓存或者图像;
3)获取对象分配相应的内存需求;
4)分配空间并且保存数据到其中;
5)将内存绑定到我们创建的资源对象上。

流水线设置

流水线指的是根据应用程序逻辑定义的一系列事件,它们按照固定的顺序执行。事件主要包含以下几种:设置着色器、绑定到资源,以及状态的管理。
Vulkan 编程模型

1.描述符集以及描述符缓冲池

描述符集合指的是资源和着色器之间的接口。它的结构非常简单,可以将着色器绑定到资源,例如图像或者缓存。它也可以将资源内存关联或者绑定到准备使用的着色器实例上。以下给出了描述符集合相关的一些特性:

  • 频繁变化:描述符集的自然特性就是可以频繁地进行修改。通常来说,它对应于材质、纹理等数据。
  • 描述符缓冲池(descriptor pool):它与描述符集的特性密切相关,后者就是从描述符缓冲池中分配而来的,不需要引入全局的数据同步。
  • 多线程的扩展性:支持多线程同步进行描述符集合的更新。
    更新或者改变描述符集的过程是Vulkan渲染中最为关键的性能瓶颈之一。因此,描述符的设计对于性能最优化的需求而言也是最重要的一个方面。Vulkan支持场景中的多个描述符集的逻辑分割(低频率更新)、建模(中频率更新),以及渲染层(高频率更新)。这样就确保了高频率更新的描述符不会影响到低频率的描述符了。

2.基于SPIR-V的着色器
Vulkan中设置着色器或者计算内核的唯一方法就是通过SPIR-V完成。下面给出了与之相关的一些特性:

  • 多重输入:SPIR-V提供了针对不同源语言的编译器工具,包括GLSL和HLSL。它可以将人类可读的着色器语言代码转换为SPIR-V格式的中间层解释语言。
  • 离线编译:着色器/内核的编译是离线完成的,不过预先就进行了注入。
  • glslang验证器:LunarG SDK提供了一个glslangValidator编译器,它可以将GLSL着色器源码转换成等价的SPIR-V着色器。
  • 多重程序入口:着色器对象提供了多种不同的程序入口。这样我们可以很方便地减小SPIR-V着色器的装运尺寸(以及加载尺寸)。着色器中的不同功能可以以单独的模块形式打包使用。

3.流水线的管理
物理设备包括一系列硬件设置,用来定义准备发送的几何输入数据是如何解释和绘制的。这些设置可以被统称为流水线状态。其中包括了光栅化状态、融混状态,以及深度/模板状态,此外还包括了输入几何数据的图元拓扑类型(点/线/三角形)以及渲染所用的着色器。状态的类型有两种:动态状态和静态状态。流水线状态可以用来创建流水线对象(图形或者计算),后者对于性能的优化来说至关重要。因此,我们不希望反复不断地进行创建,而是经过一次创建之后可以反复地使用它们。

Vulkan允许用户使用流水线对象与流水线缓冲对象(Pipeline Cache Object,PCO)和流水线布局一起,来进行状态的控制:

  • 流水线对象(pipeline object):流水线的创建是非常耗费资源的。它包含了着色器的重编译、资源的绑定、渲染通道(render pass)、帧缓存的管理,以及其他相关操作。流水线对象的数量可以成百上千,因此,每个不同的状态组合都可以保存到一个独立的流水线对象当中。
  • PCO:流水线的创建是非常耗费资源的,因此当流水线被创建之后,它也可以进行缓冲使用。如果我们需要建立新的流水线,那么驱动将首先做一个近似匹配,然后在基础流水线之上构建新的流水线对象。
    流水线缓冲的实现是不透明的,它的实现细节是驱动完成的,没有公开定义。如果应用程序希望在运行过程中,能够利用这个潜在的对象复用特性,则需要从创建伊始就自行维护这个缓冲区。
  • 流水线布局(pipeline layout):流水线布局提供了流水线中所用的描述符集,其中设置了各种不同的资源关联到着色器的不同方法。不同的流水线对象可以使用相同的流水线布局。
    在流水线管理的阶段,可能会有以下用例发生:
  • 应用程序将着色器编译到SPIR-V格式,然后将它设置给流水线的着色器状态。
  • 描述符帮助我们将资源链接到着色器本身。应用程序从描述符缓冲池中分配了描述符集,并将着色器中的输入和输出资源槽联系起来。
  • 应用程序创建了流水线对象,其中包含了静态状态和动态状态,用来控制不同的硬件设定。流水线最好是从流水线缓冲池中创建的,这样可以获得更好的性能。

4.指令的记录
指令的记录是逐渐构成指令缓存的过程。指令缓存是从指令内存池当中分配而来的。指令池可以用来同时分配多个指令缓存。应用程序定义了指令的开始和结束位置之后,就可以将指令记录到指令缓存当中。下图给出了一个绘制指令缓存的记录过程,正如你所看到的那样,这里包含了很多不同的指令,它们按照自顶向下的顺序逐步实现物体的绘制工作。

Vulkan 编程模型

注意,指令缓存中的指令可能会随着工作的需求而发生变化。上图只是作为演示,它包含了大部分图元绘制操作中最常见的一些步骤。

图中所描述的绘制过程主要如下:

  • 范围(scope):范围定义了指令缓存记录的起始和截止位置。
  • 渲染通道(render pass):这里所定义的用户工作的执行过程可能会直接影响到帧缓存的内容。它可能包含了附件、子通道以及子通道之间的一些依赖关系。附件指的是准备执行绘制的图像表面。在子通道中,作为附件的图像可以用于多重采样的操作。渲染通道同时还负责设置初始的帧缓存状态:可以让它维持之前的状态,或者使用给定的颜色清除帧缓存。与之类似,当渲染通道结束的时候,它所保存的结果也可以被舍弃或者保留。
  • 流水线(pipeline):其中包含了流水线对象所用的各种(动态/静态)状态信息。
  • 描述符(descriptor):它负责将资源信息绑定到流水线。
  • 绑定资源(bind resource):它负责设置顶点缓存、图像等几何相关的信息。
  • 视口(viewport):它定义了绘制表面上可供执行图元渲染的部分矩形。
  • 裁切器(scissor):它定义了一个矩形空间区域,并舍弃这个区域之外的所有绘制信息。
  • 绘制(drawing):绘制指令将设置几何体的缓存属性,例如开始索引、总计数值等。

指令缓存的创建是一项耗时耗力的工作。它可以被看作是对性能影响最大的一项操作。如果某一项工作在多帧之间会被反复执行,那么对应的指令缓存也可以被反复使用。我们也可以不做重新记录,直接重新提交指令缓存。此外,我们也可以通过多线程的方式同步生成多个指令缓存。Vulkan的设计可以很好地支持多线程的特性。指令池的设计确保了多线程环境下不会出现资源互锁的问题。
如图给出了使用多核以及多线程的方式实现可扩展的指令缓存创建模型的过程。该模型在多核环境下可以提供真正的并行实现方法。

Vulkan 编程模型

这里的每个线程都维护了一个独立的指令缓存池,并且从中分配一个或者多个指令缓存,互相之间不存在冲突或者资源互锁的问题。

5.队列的提交
当指令缓存构建完成后,我们就可以将它们提交到队列中处理。Vulkan向应用程序暴露了不同类型的队列接口,例如图形、DMA/传输,或者计算队列。队列的选择和提交非常依赖于工作本身的性质。例如,图像相关的任务是必须提交给图像队列的。与此类似,对于计算相关的操作,最好的选择肯定是传递给计算队列。工作的提交是通过异步的方式执行的。多个指令缓存可以被压送到独立的兼容队列里,从而实现并行的执行。应用程序需要负责指令缓存中的各种同步,以及队列之间的同步操作,甚至还有宿主机和设备之间的同步操作。

队列的提交需要执行下面的操作:

  • 从交换链中获取当前图像,决定下一帧绘制所用的表面。
  • 如需要,在这里执行各种同步的机制,例如信号量、栅栏等。
  • 收集指令缓存,并且发布到对应的设备队列中,准备处理。
  • 请求将输出设备中已经渲染完毕的图像显示出来。

酷客网相关文章:

赞(1)

评论 抢沙发

评论前必须登录!