Vulkan 应用开发指南

978-7-115-50680-1
作者: [美]格拉汉姆·塞勒斯(Graham Sellers) 约翰·克赛尼希(John Kessenich)
译者: 李晓波 等
编辑: 张涛

图书目录:

详情

本书系统地介绍下一代OpenGL规范Vulkan,它的目标以及构建其API的关键概念,揭示了Vulkan的独特性和卓越的功能。主要内容包括:内存和资源、队列和命令、数据的移动、图像的展示、着色器和管线、图形管线对象、绘制命令、几何体的处理、片段的处理、同步、数据的回读以及多渲染通道等。 本书合适图形程序开发人员、熟悉图形和计算API的程序员阅读,也可供对VUlkan感兴趣的专业人士阅读。

图书摘要

版权信息

书名:Vulkan 应用开发指南

ISBN:978-7-115-50680-1

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。

著    [美] 格拉汉姆•塞勒斯(Graham Sellers)

     [美] 约翰•克赛尼希(John Kessenich)

译    李晓波 等

责任编辑 谢晓芳

人民邮电出版社出版发行  北京市丰台区成寿寺路11号

邮编 100164  电子邮件 315@ptpress.com.cn

网址 http://www.ptpress.com.cn

读者服务热线:(010)81055410

反盗版热线:(010)81055315


Authorized translation from the English language edition, entitled Vulkan Programming Guide, ISBN: 978-0-13-446454-1 by Graham Sellers, John Kessenich, published by Pearson Education, Inc. Copyright © 2017 Pearson Education, Inc.

All rights reserved. No part of this book may be reproduced or transmitted in any form or by any means, electronic or mechanical, including photocopying, recording or by any information storage retrieval system, without permission from Pearson Education, Inc.

CHINESE SIMPLIFIED language edition published by POSTS AND TELECOMMUNICATIONS PRESS, Copyright © 2019.

本书中文简体版由Pearson Education, Inc. 授权人民邮电出版社出版。未经出版者书面许可,不得以任何方式或任何手段复制和抄袭本书内容。

本书封面贴有Pearson Education(培生教育出版集团)激光防伪标签,无标签者不得销售。

版权所有,侵权必究。


本书系统地介绍下一代OpenGL规范Vulkan,揭示了Vulkan的独特性和卓越的功能。本书主要内容包括:内存和资源、队列和命令、数据的移动、图像的展示、着色器和管线、图形管线对象、绘制命令、几何体的处理、片段的处理、同步、数据的回读以及多渲染通道等。

本书适合图形程序开发人员、熟悉图形和计算API的程序员阅读,也可供对Vulkan感兴趣的专业人士阅读。


不知不觉中,我已经在3D游戏引擎研发领域摸爬滚打了十多年。其间曾带领团队自主研发过多款大型3D引擎,也深入研究过几乎所有常用的商用和开源引擎,如CryEngine、Unreal Engine 4、Unity、Ogre3D、Cocos2d-x、Urho3D等。这些引擎都使用OpenGL和Direct3D这两种传统的图形API进行实时渲染。这些年来,这两个图形API不停地更新换代,支持的功能也越来越多,但是它们的基本设计理念并没有改变。

从2013年开始,AMD公司和EA DICE合作开发了新一代的图形API—Mantle,用于取代OpenGL和Direct3D,Mantle有非常巨大的性能提升,尤其是在CPU方面。之所以有如此突出的优点,是因为这种API完全不同于传统的API,主要特点有:“薄”驱动降低了API验证和处理的性能开销,充分利用了多核CPU的性能,以及显式的API调用。

先讲“薄”驱动带来的性能提升。由于图形API的应用场景差别非常大,支持传统图形API的驱动程序需要做非常多的验证工作,因此导致了API调用需要消耗很多时钟周期。在新一代图形API里,一个新的理念是应用程序的开发者更清楚实际的需求,因此将很多原本由驱动程序完成的工作转移到了程序员身上,于是驱动程序里的API验证和操作大幅减少,从而使得驱动程序更“薄”。根据官方实测,单单这一项就能将性能提升9倍。

另外,目前CPU的发展现状是,主频的提升基本上裹足不前,支持更多的内核是未来的发展方向。因此,多线程能够使得应用程序大幅提升性能。尽管传统图形API里也有多线程的概念,但都仅限于一些特殊的应用场景,如资源异步加载。虽然通过切换上下文也能同时在多个线程里调用图形API,但是由于切换成本过大,反而会使性能大幅下降。为了提升每帧的绘制调用次数,商用引擎都会实现一种称为“渲染线程”的技术,原理就是,将图形API的调用从主线程转移到另外一个线程,这个线程只负责提交渲染命令,由此得名“渲染线程”。这种技术可以大幅提升每帧的绘制调用次数,但是理论上峰值也只能提升一倍(其他条件不变,并且性能瓶颈位于CPU上)。新一代图形API支持 “命令缓冲区”的概念,应用程序可以创建多个“命令缓冲区”。每一帧中,每个线程都向对应的“命令缓冲区”中提交渲染命令,最后将这几个“命令缓冲区”一起提交。理论上,绘制调用的次数可以随着CPU的内核数量线性增长。对于个人电脑(Personal Computer,PC)和主机来说,很多应用程序的设计目的就是要“榨干硬件的每一个时钟周期”,这样就能够渲染更多的物体,提升场景的真实感。而对于移动端来说,能耗和发热问题使得在这种平台上不能像PC和主机那样长时间内使所有的CPU内核都满负荷运行,因为时间一长手机就会发热,从而导致硬件降频,帧率下降。经过实测发现,如果将一个线程里执行的任务分配给多个线程,就能够有效地缓解移动端由于发热而导致的降频。

最后,新一代图形API里,程序员拥有了更多的控制权,从而赋予了程序员根据具体应用场景对算法进行深度优化的能力。另外,很多过程也变得更加透明。虽然这增加了使用难度,但是非常值得。例如,把内存管理权限转交给了程序员,这样就不用再根据标志位猜测驱动内部是怎么实现的,也能根据实际的应用场景中是否申请了大量的小块内存、内存申请的频率等边界条件进行深度优化。

2015年,Mantle官方停止了更新。随后,基于这种设计理念(Vulkan直接继承自Mantle)诞生了3种新一代的图形API——Vulkan、Metal和Direct3D 12。遗憾的是,后两者分别只支持iOS/Mac OS和Windows平台。作为一名“老”引擎程序员,我深知这意味着什么。引擎支持多个图形API会导致开发中大量的资源花在横向移植上,而不是花在优化算法这样的技术深度上,这对提升用户的体验是无益的。另外,目前支持多个图形API的引擎都会有一个针对各种图形API实现的抽象层,这样势必会增加性能开销。Vulkan是个跨平台的图形API,支持所有常用的平台,包括Windows系统、Linux系统、Android系统和iOS/Mac OS。这无疑是图形引擎程序员的福音,真正做到了“一次编写,到处运行”。目前主流的游戏引擎都已经支持Vulkan。

尽管Vulkan有诸多优点,但是学习曲线相对于传统图形API陡了很多,本书的出版恰逢其时。它是由Vulkan规范的制定者编写的。我也是首先通过本书英文版开始了解Vulkan的,并且业余时间也在尝试着翻译部分章节,希望能对国内的技术发展有所贡献。之后,我有幸认识了人民邮电出版社的张涛老师,便开始了本书的翻译工作。

翻译本书既让我激动,又让我倍感压力。首先,Vulkan是一门全新的API,出现了很多新的概念。原书作者尽管参与了Vulkan规范的制定,但仍然承认自己也还在学习中。另外,很多新的概念都是首次提出的,没有可以参考的相关中文资料,需要自己精确地翻译成中文,并且还要遵循中国人的思维习惯,因此在翻译过程中很多专业术语都经过了反复推敲。

翻译过程中,我一直战战兢兢,如履薄冰,生怕自己的翻译错误误导了读者。所以经过深思熟虑,打算借助技术社区的力量。幸运的是,翻译过程中遇到了很多志同道合的朋友,他们很乐意一起为中国的技术发展添砖加瓦。在此,首先要感谢的是参与了初稿翻译的朋友们,他们分别是邱龙云、马百川、梁跃、卢云庚、王冠群(排名按照贡献度)。然而,由于每个人的翻译水平、对Vulkan的理解程度、用语习惯都不一样,我又对初稿进行了3次大规模的审校,甚至部分章节又重新进行了翻译。此外,还要感谢王伟亮参与了终稿的审校。

最后,感谢妻子的默默奉献,为了帮助我尽快完成本书,她揽下了所有的家务,使我可以心无旁骛地将所有业余时间都花在这上面。也要感谢儿子尧尧,他的调皮捣蛋竟成了这个过程中最好的调味剂。

本书尽管经过了多遍审校,但是由于水平有限,难免有纰漏,诚恳地希望读者能够指出。

李晓波


李晓波,2007年毕业于北京理工大学,获得硕士学位,研究方向是“虚拟现实在化工场景动态搭建中的应用”。毕业后一直从事大型3D引擎的自主研发工作,带领团队研发过多款3D游戏引擎,并获得软件著作权。所研发的引擎已经应用于多款大型3D MMO客户端游戏中。也深入研究过几乎所有常用的商用和开源引擎,包括CryEngine、Unreal Engine 4、Unity、Ogre3D、Cocos2d-x、Urho3D等。两年前有感于游戏行业开发低效的现状,曾创立了北京疯狂引擎科技有限公司,专注于引擎技术服务,个人网站是CrazyEngine网站。业余时间密切关注VR、AR、MR的发展趋势,同时对技术培训方向也有兴趣,正在制作一些技术专题的教学视频。目前就职于北京一家中型手机游戏公司,负责研究Unity引擎源码,为公司的几个在研项目提供技术支持。


这是一本关于Vulkan的书。Vulkan是一种应用程序编程接口(Application Programming Interface,API),用于控制图形处理单元等设备。虽然逻辑上Vulkan继承自OpenGL,但它与OpenGL在形式上完全不同。经验丰富的从业者会注意到,Vulkan使用起来非常麻烦。你需要编写很多应用程序代码才能让Vulkan做一些有用的事情,更不用说炫酷的事情了。OpenGL驱动程序所做的许多事情现在都是Vulkan应用程序编写者的责任了。这些事情包括同步、调度、内存管理等。因此,你会发现本书中有很多专门讨论这些主题的内容。当然,它们不仅适用于Vulkan,还适用于一般主题。

本书的目标读者是熟悉其他图形和计算API的有经验的程序员。因此,书中对许多与图形相关的主题在没有深入介绍的情况下进行了讨论,有一些前向引用、代码示例是不完整的,仅进行局部说明,而不是你可以输入的完整程序。然而,本书网站上提供的示例代码是完整的,并经过了测试,可以作为一个很好的参考。

Vulkan旨在用作大型复杂图形和计算应用程序与图形硬件之间的接口。以前由驱动程序实现的API(如OpenGL)所承担的许多功能和职责现在由应用程序承担。复杂的游戏引擎、大型渲染组件和商业中间件非常适合实现这些API的功能;它们比驱动有更多关于其特定行为的信息。Vulkan不适合简单的测试应用程序,它还不是讲授图形概念的辅助手段。

本书前几章介绍了Vulkan和构建API的一些基本概念。随着对Vulkan系统的深入探讨,本书将讨论更多高级主题,最终产生一个更复杂的渲染系统,展示Vulkan的一些独特方面并讨论其功能。

第1章简要介绍了Vulkan及其基础概念。该章讲述了创建Vulkan对象的基础知识,并展示Vulkan系统入门的基础知识。

第2章介绍了Vulkan的内存系统,这也许是该接口最基础的部分。该章展示了如何分配内存,这些内存由Vulkan设备以及在应用程序内运行的Vulkan驱动程序和系统组件使用。

第3章介绍了命令缓冲区并讨论了向其中提交命令缓冲区的队列。该章展示了Vulkan进程如何工作,以及如何为应用程序构建要发送到设备执行的命令包。

第4章介绍了几个Vulkan命令,这些命令都专注于移动数据。该章使用第3章讨论的概念来构建命令缓冲区,这些缓冲区可以复制和格式化存储在资源与内存(第2章介绍过)里的数据。

第5章讲述了如何将应用程序生成的图像显示到屏幕上。展示(presentation)是用于与窗口系统交互的术语,它是特定于平台的,因此该章深入研究了一些特定于平台的主题。

第6章介绍了Vulkan使用的二进制着色语言SPIR-V。该章还介绍了管线对象,展示了如何使用SPIR-V着色器构建一个管线,并介绍了计算管线(在Vulkan中可用于完成计算工作)。

第7章介绍了图形管道,其中包括使用Vulkan渲染图元所需的所有配置。

第8章讨论了Vulkan中可用的各种绘图命令,包括索引和非索引绘制、实例化与间接命令。该章展示了如何将数据导入图形管线以及如何绘制更复杂的几何图形。

第9章深入讲解了Vulkan图形管线的前半部分,以及曲面细分和几何着色器阶段。该章展示了这些阶段可以完成的一些更高级的操作,并涵盖了直到光栅化阶段的管线。

第10章讲述了光栅化期间和之后发生的所有事情,这些工作用于将几何图形转换为可以向用户显示的像素流。

第11章介绍了Vulkan应用程序可用的各种同步原语,包括栅栏、事件和信号量。这些共同构成了有效利用Vulkan并行性的应用程序的基础。

第12章讨论了将Vulkan中的数据读入应用程序所涉及的问题。该章展示了如何按照时序安排Vulkan设备执行的操作、如何收集有关Vulkan设备操作的统计信息,以及如何将Vulkan生成的数据回读到应用程序中。

最后,第13章重新讨论了前面介绍的一些主题,将各个方面联系在一起以生成更高级的应用程序——使用复杂的多通道体系结构和多个队列进行处理的延迟渲染应用程序。

附录A包含了Vulkan应用程序可用的命令缓冲区构建函数表,提供了查看其属性的快速参考。

Vulkan是一个庞大和复杂的新系统。在一本书中涵盖API的全部细节是非常困难的。除了本书之外,还鼓励读者彻底阅读Vulkan规范,以及阅读其他有关使用异构计算系统和计算机图形(使用其他API)的图书。这些材料将为本书所涉及的数学和其他概念提供一个良好的基础。

本书配套的示例代码可从vulkanprogrammingguide网站获取。有其他图形API使用经验的用户可能会觉得Vulkan非常复杂,这主要是因为本来由驱动程序承担的许多责任已委托给应用程序了。但是,在许多情况下,简单的示例代码就可以很好地完成工作。因此,我们创建了一个简单的应用程序框架,该框架处理所有示例和应用程序中通用的大部分功能。这并不意味着本书是关于如何使用该应用程序框架的教程,只是为了保持示例代码简洁。

当然,当在整本书中讨论特定的Vulkan功能时,将包括代码片段,其中许多代码实际上可能来自本书的示例框架(而不是任何特定示例)。本书讨论的一些功能可能在代码包中没有示例。对于一些主要与大规模应用相关的高级功能尤其如此。这里没有简短的Vulkan示例。在许多情况下,单个示例程序演示了许多功能的用法。每个示例使用的功能都列在该示例的readme文件中。同样,本书中的示例和代码清单之间没有一对一的对应关系。

LunarG 的官方Vulkan SDK可从LunarG网站获取。在撰写本书时,SDK版本是1.0.22。较新版本的SDK可以兼容旧版本,因此建议用户在尝试编译和运行示例应用程序之前获取最新版本的SDK。SDK还附带了一些自己的示例,建议运行这些示例以验证SDK和驱动程序是否已正确安装。

除了Vulkan SDK之外,你还需要安装CMake,以便为示例创建构建环境。你还需要一个最新的编译器。代码示例使用了C++ 11的几个特性,并且严重依赖于C++标准库来处理线程和同步原语。众所周知,这些功能在各种编译器运行时的早期版本中都存在问题,因此请确保编译器是最新的。我们已经在Windows系统上使用Microsoft Visual Studio 2015,并在Linux系统上使用GCC 5.3进行了测试。这些示例已在64位Windows 7、Windows 10和Ubuntu 16.10系统上进行了测试,最近的驱动程序来自AMD、Intel和NVIDIA。

值得注意的是,Vulkan是一个跨平台、跨供应商和跨设备的系统。其中许多示例应该适用于Android和其他移动平台。我们希望将来可以将示例移植到尽可能多的平台上,非常感谢读者的帮助和贡献。

Vulkan是一项新技术。在撰写本书时,该规范刚在几周前开始使用。虽然作者和撰稿人参与了Vulkan规范的创建,但它庞大而复杂,并且有很多贡献者。书中的一些代码没有经过全面的测试,虽然我们相信它正确,但也可能包含错误。当我们将示例放在一起时,可用的Vulkan实现仍然存在Bug,验证层没有捕获尽可能多的错误,并且规范本身存在漏洞和不清楚的部分。和读者一样,我们仍然在学习Vulkan,所以尽管本书出于技术准确性进行过编辑,但是仍建议读者通过访问vulkanprogrammingguide网站来查看任何更新。

在InformIT网站上注册账号,以便在下载和更新可用时方便地访问。要开始注册,请转至InformIT网站并创建账户。输入英文原书ISBN(9780134464541),然后单击Submit按钮。一旦完成该过程,你将在Registered Products下找到任何可用的资源。


首先,我要感谢Vulkan工作组的成员。经过不知疲倦和极长时间的工作,我们创作了本书,相信它会成为未来几年中计算机图形和计算加速坚实的基础。我尤其想要肯定AMD的同行在开发最初的Mantle规范中的贡献,而Vulkan源于此。

我要感谢本书的审校者Dan Ginsburg和Chris“Xenon”Hanson,感谢他们提供的宝贵意见,没有这些反馈,本书肯定会包含更多错误和遗漏。我还要感谢我的同事Mais Alnasser,他提供了很好的反馈,并进一步提升了本书的质量。还要感谢AMD的Vulkan团队的其他人,他们的工作使我能够在公众可以访问Vulkan之前测试大部分示例代码。

英文原书封面图片由Agoro Design的Dominic Agoro-Ombaka在短时间内制作。感谢他在这么紧的时间里制作了封面。

非常感谢编辑Laura Lewin以及Addison-Wesley团队的其他成员。他们允许我反复调整时间表,延期交稿,以随性的方式工作,对他们来说,这个过程通常是痛苦的。感谢他们对我如此信任。

最后,我要感谢我的家人——我的妻子Chris和我的孩子Jeremy和Emily。“爸爸,你还在写你的书吗?”已成为我们家中最常听到的“咏叹调”。我感谢他们的耐心、爱和支持,有了这些我才能在过去的几个月里整理出了一本新书。

格拉汉姆·塞勒斯(Graham Sellers)


本书由异步社区出品,社区(https://www.epubit.com/)为您提供相关后续服务。

作者和编辑尽最大努力来确保书中内容的准确性,但难免会存在疏漏。欢迎您将发现的问题反馈给我们,帮助我们提升图书的质量。

当您发现错误时,请登录异步社区,按书名搜索,进入本书页面,单击“提交勘误”,输入勘误信息,单击“提交”按钮即可。本书的作者和编辑会对您提交的勘误进行审核,确认并接受后,您将获赠异步社区的100积分。积分可用于在异步社区兑换优惠券、样书或奖品。

我们的联系邮箱是contact@epubit.com.cn。

如果您对本书有任何疑问或建议,请您发邮件给我们,并请在邮件标题中注明本书书名,以便我们更高效地做出反馈。

如果您有兴趣出版图书、录制教学视频,或者参与图书翻译、技术审校等工作,可以发邮件给我们;有意出版图书的作者也可以到异步社区在线提交投稿(直接访问www.epubit.com/ selfpublish/submission即可)。

如果您是学校、培训机构或企业,想批量购买本书或异步社区出版的其他图书,也可以发邮件给我们。

如果您在网上发现有针对异步社区出品图书的各种形式的盗版行为,包括对图书全部或部分内容的非授权传播,请您将怀疑有侵权行为的链接发邮件给我们。您的这一举动是对作者权益的保护,也是我们持续为您提供有价值的内容的动力之源。

“异步社区”是人民邮电出版社旗下IT专业图书社区,致力于出版精品IT技术图书和相关学习产品,为作译者提供优质出版服务。异步社区创办于2015年8月,提供大量精品IT技术图书和电子书,以及高品质技术文章和视频课程。更多详情请访问异步社区官网https://www.epubit.com。

“异步图书”是由异步社区编辑团队策划出版的精品IT专业图书的品牌,依托于人民邮电出版社近30年的计算机图书出版积累和专业编辑团队,相关图书在封面上印有异步图书的LOGO。异步图书的出版领域包括软件开发、大数据、AI、测试、前端、网络技术等。

异步社区

微信服务号


在本章,你将学到:

本章将介绍并解释Vulkan是什么。我们会介绍API背后的基本概念,包括初始化、对象生命周期、Vulkan实例以及逻辑和物理设备。在本章的最后,我们会完成一个简单的Vulkan应用程序,这个程序可以初始化Vulkan系统,查找可用的Vulkan设备并显示其属性和功能,最后彻底地关闭程序。

Vulkan是一个用于图形和计算设备的编程接口。Vulkan设备通常由一个处理器和一定数量的固定功能硬件模块组成,用于加速图形和计算操作。通常,设备中的处理器是高度线程化的,所以在极大程度上Vulkan里的计算模型是基于并行计算的。Vulkan还可以访问运行应用程序的主处理器上的共享或非共享内存。Vulkan也会给开发人员提供这个内存。

Vulkan是个显式的API,也就是说,几乎所有的事情你都需要亲自负责。驱动程序是一个软件,用于接收API调用传递过来的指令和数据,并将它们进行转换,使得硬件可以理解。在老的API(例如OpenGL)里,驱动程序会跟踪大量对象的状态,自动管理内存和同步,以及在程序运行时检查错误。这对开发人员非常友好,但是在应用程序经过调试并且正确运行时,会消耗宝贵的CPU性能。Vulkan解决这个问题的方式是,将状态跟踪、同步和内存管理交给了应用程序开发人员,同时将正确性检查交给各个层进行代理,而要想使用这些层必须手动启用。这些层在正常情况下不会在应用程序里执行。

由于这些原因,Vulkan难以使用,并且在一定程度上很不稳定。你需要做大量的工作来保证Vulkan运行正常,并且API的错误使用经常会导致图形错乱甚至程序崩溃,而在传统的图形API里你通常会提前收到用于帮助解决问题的错误消息。以此为代价,Vulkan提供了对设备的更多控制、清晰的线程模型以及比传统API高得多的性能。

另外,Vulkan不仅仅被设计成图形API,它还用作异构设备,例如图形处理单元(Graphics Processing Unit,GPU)、数字信号处理器(Digital Signal Processor,DSP)和固定功能硬件。功能可以粗略地划分为几类。Vulkan的当前版本定义了传输类别——用于复制数据;计算类别——用于运行着色器进行计算工作;图形类别——包括光栅化、图元装配、混合、深度和模板测试,以及图形程序员所熟悉的其他功能。

Vulkan设备对每个分类的支持都是可选的,甚至可以根本不支持图形。因此,将图像显示到适配器设备上的API(这个过程叫作展示)不但是可选择的功能,而且是扩展功能,而不是核心API。

Vulkan包含了一个层级化的功能结构,从顶层开始是实例,实例聚集了所有支持Vulkan的设备。每个设备提供了一个或者多个队列,这些队列执行应用程序请求的工作。

Vulkan实例是一个软件概念,在逻辑上将应用程序的状态与其他应用程序或者运行在应用程序环境里的库分开。系统里的物理设备表示为实例的成员变量,每个都有一定的功能,包括一组可用的队列。

物理设备通常表示一个单独的硬件或者互相连接的一组硬件。在任何系统里,都有一些数量固定的物理设备,除非这个系统支持重新配置,例如热插拔。由实例创建的逻辑设备是一个与物理设备相关的软件概念,表示与某个特定物理设备相关的预定资源,其中包括了物理设备上可用队列的一个子集。可以通过创建多个逻辑设备来表示一个物理设备,应用程序花大部分时间与逻辑设备交互。

图1.1展示了这个层级关系。图1.1中,应用程序创建了两个Vulkan实例。系统里的3个物理设备能够被这两个实例使用。经过枚举,应用程序在第一个物理设备上创建了一个逻辑设备,在第二个物理设备创建了两个逻辑设备,在第三个物理设备上创建了一个逻辑设备。每个逻辑设备启用了对应物理设备队列的不同子集。在实际开发中,大多数Vulkan应用程序不会这么复杂,而会针对系统里的某个物理设备只创建一个逻辑设备,并且使用一个实例。图1.1仅仅用来展示Vulkan的复杂性。

图1.1 Vulkan里关于实例、设备和队列的层级关系

后面的小节将讨论如何创建Vulkan实例,如何查询系统里的物理设备,并将一个逻辑设备关联到某个物理设备上,最后获取设备提供的队列句柄。

Vulkan可以被看作应用程序的子系统。一旦应用程序连接了Vulkan库并初始化,Vulkan就会追踪一些状态。因为Vulkan并不向应用程序引入任何全局状态,所以所有追踪的状态必须存储在你提供的一个对象里。这就是实例对象,由VkInstance对象来表示。为了构建这个对象,我们会调用第一个Vulkan函数vkCreateInstance(),其原型如下。

VkResult vkCreateInstance (
    const VkInstanceCreateInfo*         pCreateInfo,
    const VkAllocationCallbacks*        pAllocator,
    VkInstance*                         pInstance);

该声明是个典型的Vulkan函数:把多个参数传入Vulkan,函数通常接收结构体的指针。这里,pCreateInfo是指向结构体VkInstanceCreateInfo的实例的指针。这个结构体包含了用来描述新的Vulkan实例的参数,其定义如下。

typedef struct VkInstanceCreateInfo {
    VkStructureType             sType;
    const void*                 pNext;
    VkInstanceCreateFlags       flags;
    const VkApplicationInfo*    pApplicationInfo; 
    uint32_t                    enabledLayerCount;
    const char* const*          ppEnabledLayerNames;
    uint32_t                    enabledExtensionCount;
    const char* const*          ppEnabledExtensionNames;
} VkInstanceCreateInfo;

几乎每一个用于向API传递参数的Vulkan结构体的第一个成员都是字段sType,该字段告诉Vulkan这个结构体的类型是什么。核心API以及任何扩展里的每个结构体都有一个指定的结构体标签。通过检查这个标签,Vulkan工具、层和驱动可以确定结构体的类型,用于验证以及在扩展里使用。另外,字段pNext允许将一个相连的结构体链表传入函数。这样在一个扩展中,允许对参数集进行扩展,而不用将整个核心结构体替换掉。因为这里使用了核心的实例创建结构体,将字段sType设置为VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO,并且将pNext设置为nullptr。

字段flags留待将来使用,应该设置为0。下一个字段pApplicationInfo是个可选的指针,指向另一个描述应用程序的结构体。可以将它设置为nullptr,但是推荐填充为有用的信息。pApplicationInfo指向结构体VkApplicationInfo的一个实例,其定义如下。

typedef struct VkApplicationInfo {
    VkStructureType    sType;
    const void*        pNext;
    const char*        pApplicationName;
    uint32_t           applicationVersion;
    const char*        pEngineName;
    uint32_t           engineVersion;
    uint32_t           apiVersion;
} VkApplicationInfo;

我们再一次看到了字段sType和pNext。SType 应该设置为VK_STRUCTURE_TYPE_APPLICATION_INFO,并且可以将pNext设置为nullptr。pApplicationName是个指针,指向以nul为结尾的字符串[1],这个字符串用于包含应用程序的名字。applicationVersion是应用程序的版本号。这样就允许工具和驱动决定如何对待应用程序,而不用猜测[2]哪个应用程序正在运行。同样,pEngineName与engineVersion也分别包含了引擎或者中间件(应用程序基于此构建)的名字和版本号。

最后,apiVersion包含了应用程序期望运行的Vulkan API的版本号。这个应该设置为你期望应用程序运行所需的Vulkan的绝对最小版本号——并不是你安装的头文件中的版本号。这样允许更多设备和平台运行应用程序,即使并不能更新它们的Vulkan实现。

回到结构体VkInstanceCreateInfo,接下来是字段enabledLayerCount和ppEnabledLayerNames。这两个分别是你想激活的实例层的个数以及名字。层用于拦截Vulkan的API调用,提供日志、性能分析、调试或者其他特性。如果不需要层,只需要将enabledLayerCount设置为0,将ppEnabledLayerNames设置为nullptr。同样,enabledExtensionCount是你想激活的扩展的个数[3],ppEnabledExtensionNames是名字列表。如果我们不想使用任何的扩展,同样可以将这些字段分别设置为0和nullptr。

最后,回到函数vkCreateInstance(),参数pAllocator是个指向主机内存分配器的指针,该分配器由应用程序提供,用于管理Vulkan系统使用的主机内存。将这个参数设置为nullptr会导致Vulkan系统使用它内置的分配器。在这里先这样设置。应用程序托管的主机内存将会在第2章中讲解。

如果函数vkCreateInstance()成功,会返回VK_SUCCESS,并且会将新实例的句柄放置在变量pInstance里。句柄是用于引用对象的值。Vulkan句柄总是64位宽,与主机系统的位数无关。一旦有了Vulkan实例的句柄,就可以用它调用实例函数了。

一旦有了实例,就可以查找系统里安装的与Vulkan兼容的设备。Vulkan有两种设备:物理设备和逻辑设备。物理设备通常是系统的一部分——显卡、加速器、数字信号处理器或者其他的组件。系统里有固定数量的物理设备,每个物理设备都有自己的一组固定的功能。

逻辑设备是物理设备的软件抽象,以应用程序指定的方式配置。逻辑设备是应用程序花费大部分时间处理的对象。但是在创建逻辑设备之前,必须查找连接的物理设备。需要调用函数vkEnumeratePhysicalDevices(),其原型如下。

VkResult vkEnumeratePhysicalDevices (
    VkInstance                           instance,
    uint32_t*                            pPhysicalDeviceCount,
    VkPhysicalDevice*                    pPhysicalDevices);

函数vkEnumeratePhysicalDevices()的第一个参数instance是之前创建的实例。下一个参数pPhysicalDeviceCount是一个指向无符号整型变量的指针,同时作为输入和输出。作为输出,Vulkan将系统里的物理设备数量写入该指针变量。作为输入,它会初始化为应用程序能够处理的设备的最大数量。参数pPhysicalDevices是个指向VkPhysicalDevice句柄数组的指针。

如果你只想知道系统里有多少个设备,将pPhysicalDevices设置为nullptr,这样Vulkan将忽视pPhysicalDeviceCount的初始值,将它重写为支持的设备的数量。可以调用vkEnumerate PhysicalDevices()两次,动态调整VkPhysicalDevice数组的大小:第一次仅将pPhysicalDevices设置为nullptr(尽管pPhysicalDeviceCount仍然必须是个有效的指针),第二次将pPhysicalDevices设置为一个数组(数组的大小已经调整为第一次调用返回的物理设备数量)。

如果调用成功,函数vkEnumeratePhysicalDevices()返回VK_SUCCESS,并且将识别出来的物理设备数量存储进pPhysicalDeviceCount中,还将它们的句柄存储进pPhysicalDevices中。代码清单1.1展示了一个例子:构造结构体VkApplicationInfo和VkInstanceCreateInfo,创建Vulkan实例,查询支持设备的数量,并最终查询物理设备的句柄。这是例子框架里面的vkapp::init的简化版本。

代码清单1.1 创建Vulkan实例

VkResult vkapp::init()
{
    VkResult result = VK_SUCCESS;
    VkApplicationInfo appInfo = { };
    VkInstanceCreateInfo instanceCreateInfo = { };

    // 通用的应用程序信息结构体
    appInfo.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO;
    appInfo.pApplicationName = "Application";
    appInfo.applicationVersion = 1;
    appInfo.apiVersion = VK_MAKE_VERSION(1, 0, 0);

    // 创建实例
    instanceCreateInfo.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
    instanceCreateInfo.pApplicationInfo = &appInfo;

    result = vkCreateInstance(&instanceCreateInfo, nullptr, &m_instance);

    if (result == VK_SUCCESS) 
    {
        // 首先判断系统里有多少个设备
        uint32_t physicalDeviceCount = 0;
        vkEnumeratePhysicalDevices(m_instance, &physicalDeviceCount, nullptr);

        if (result == VK_SUCCESS)
        {
            // 调整设备数组的大小,并获取物理设备的句柄
            m_physicalDevices.resize(physicalDeviceCount);
            vkEnumeratePhysicalDevices(m_instance,
                                       &physicalDeviceCount,
                                       &m_physicalDevices[0]); 
        }
    }
    return result;
}

物理设备句柄用于查询设备的功能,并最终用于创建逻辑设备。第一次执行的查询是vkGet PhysicalDeviceProperties(),该函数会填充描述物理设备所有属性的结构体。其原型如下。

void vkGetPhysicalDeviceProperties (
    VkPhysicalDevice                    physicalDevice,
    VkPhysicalDeviceProperties*         pProperties);

当调用vkGetPhysicalDeviceProperties()时,向参数physicalDevice传递vkEnumeratePhysical Devices()返回的句柄之一,向参数pProperties传递一个指向结构体VkPhysicalDeviceProperties实例的指针。VkPhysicalDeviceProperties是个大结构体,包含了大量描述物理设备属性的字段。其定义如下。

typedef struct VkPhysicalDeviceProperties {
    uint32_t                            apiVersion;
    uint32_t                            driverVersion;
    uint32_t                            vendorID;
    uint32_t                            deviceID;
    VkPhysicalDeviceType                deviceType;
    char                                deviceName
                                            [VK_MAX_PHYSICAL_DEVICE_NAME_SIZE];
    uint8_t                             pipelineCacheUUID[VK_UUID_SIZE];
    VkPhysicalDeviceLimits              limits;
    VkPhysicalDeviceSparseProperties    sparseProperties;
} VkPhysicalDeviceProperties;

字段apiVersion包含了设备支持的Vulkan的最高版本,字段driverVersion包含了用于控制设备的驱动的版本号。这是硬件生产商特定的,所以对比不同的生产商的驱动版本没有任何意义。字段vendorID与deviceID标识了生产商和设备,并且通常是PCI生产商和设备标识符[4]

字段deviceName包含了可读字符串来命名设备。字段pipelineCacheUUID用于管线缓存,这会在第6章中讲到。

除了刚刚列出的属性之外,结构体VkPhysicalDeviceProperties内嵌了VkPhysicalDeviceLimits和VkPhysicalDeviceSparseProperties,包含了物理设备的最大和最小限制,以及和稀疏纹理有关的属性。这两个结构体里有大量信息,这些字段会在讨论相关特性时介绍,在此不再详述。

除了核心特性(有些有更高的限制或约束)之外,Vulkan还可能有一些物理设备支持的可选特性。如果设备宣传支持某个特性,它必须激活(非常像扩展)。但是一旦激活,这个特性就变成了API的“一等公民”,就像任何核心特性一样。为了判定物理设备支持哪些特性,调用vkGetPhysicalDeviceFeatures()。其原型如下。

void vkGetPhysicalDeviceFeatures (
    VkPhysicalDevice                 physicalDevice,
    VkPhysicalDeviceFeatures*        pFeatures);

结构体vkPhysicalDeviceFeatures也非常大,并且Vulkan支持的每一个可选特性都有一个布尔类型的字段。字段太多,就不在此详细罗列了,但是本章最后展示的例子会读取特性集并输出其内容。

在许多情况下,Vulkan设备要么是一个独立于主机处理器之外的一块物理硬件,要么工作方式非常不同,以独有的方式访问内存。Vulkan里的设备内存是指,设备能够访问到并且用作纹理和其他数据的后备存储器的内存。内存可以分为几类,每一类都有一套属性,例如缓存标志位以及主机和设备之间的一致性行为。每种类型的内存都由设备的某个堆(可能会有多个堆)进行支持。

为了查询堆配置以及设备支持的内存类型,需要调用以下代码。

void vkGetPhysicalDeviceMemoryProperties (
    VkPhysicalDevice                         physicalDevice,
    VkPhysicalDeviceMemoryProperties*        pMemoryProperties);

查询到的内存组织信息会存储进结构体 VkPhysicalDeviceMemoryProperties中,地址通过pMemoryProperties传入。结构体VkPhysicalDeviceMemoryProperties包含了关于设备的堆以及其支持的内存类型的属性。该结构体的定义如下。

typedef struct VkPhysicalDeviceMemoryProperties {
    uint32_t        memoryTypeCount;
    VkMemoryType    memoryTypes[VK_MAX_MEMORY_TYPES]; 
    uint32_t        memoryHeapCount;
    VkMemoryHeap    memoryHeaps[VK_MAX_MEMORY_HEAPS];
} VkPhysicalDeviceMemoryProperties;

内存类型数量包含在字段memoryTypeCount里。可能报告的内存类型的最大数量是VK_MAX_MEMORY_TYPES定义的值,这个宏定义为32。数组memoryTypes包含memoryTypeCount个结构体VkMemoryType对象,每个对象都描述了一种内存类型。VkMemoryType的定义如下。

typedef struct VkMemoryType {
    VkMemoryPropertyFlags    propertyFlags;
    uint32_t                 heapIndex;
} VkMemoryType;

这是个简单的结构体,只包含了一套标志位以及内存类型的堆栈索引。字段flags描述了内存的类型,并由VkMemoryPropertyFlagBits类型的标志位组合而成。标志位的含义如下。

每种内存类型都指定了从哪个堆上使用空间,这由结构体VkMemoryType里的字段heapIndex来标识。这个字段是数组memoryHeaps (在调用vkGetPhysicalDeviceMemoryProperties()返回的结构体VkPhysicalDeviceMemoryProperties里面)的索引。数组memoryHeaps里面的每一个元素描述了设备的一个内存堆。结构体的定义如下。

typedef struct VkMemoryHeap {
    VkDeviceSize         size;
    VkMemoryHeapFlags    flags;
} VkMemoryHeap;

同样,这也是个简单的结构体,包含了堆的大小(单位是字节)以及描述这个堆的标识符。在Vulkan 1.0里,唯一定义的标识符是VK_MEMORY_HEAP_DEVICE_LOCAL_BIT。如果定义了这个标识符,堆对于设备来说就是本地的。这对应于以类似方式命名的用于描述内存类型的标识符。

Vulkan设备执行提交给队列的工作。每个设备都有一个或者多个队列,每个队列都从属于设备的某个队列族。一个队列族是一组拥有相同功能同时又能并行运行的队列。队列族的数量、每个族的功能以及每个族拥有的队列数量都是物理设备的属性。为了查询设备的队列族,调用vkGetPhysicalDeviceQueueFamilyProperties(),其原型如下。

void vkGetPhysicalDeviceQueueFamilyProperties (
    VkPhysicalDevice                       physicalDevice,
    uint32_t*                              pQueueFamilyPropertyCount,
    VkQueueFamilyProperties*               pQueueFamilyProperties);

vkGetPhysicalDeviceQueueFamilyProperties()的运行方式在一定程度上和vkEnumeratePhysical Devices()类似,需要调用前者两次。第一次,将nullptr传递给pQueueFamilyProperties,并给pQueueFamilyPropertyCount传递一个指针,指向表示设备支持的队列族数量的变量。可以使用该值调整VkQueueFamilyProperties类型的数组的大小。接下来,在第二次调用中,将该数组传入pQueueFamilyProperties,Vulkan将会用队列的属性填充该数组。VkQueueFamilyProperties的定义如下。

typedef struct VkQueueFamilyProperties {
    VkQueueFlags    queueFlags;
    uint32_t        queueCount;
    uint32_t        timestampValidBits;
    VkExtent3D      minImageTransferGranularity;
} VkQueueFamilyProperties;

该结构体里的第一个字段是queueFlags,描述了队列的所有功能。这个字段由VkQueueFlagBits类型的标志位的组合组成,其含义如下。

字段queueCount表示族里的队列数量,该值可能是1。如果设备支持具有相同基础功能的多个队列,该值也可能更高。

字段timestampValidBits表示当从队列里取时间戳时,多少位有效。如果这个值设置为0,那么队列不支持时间戳。如果不是0,那么会保证最少支持36位。如果设备的结构体VkPhysicalDeviceLimits里的字段timestampComputeAndGraphics是VK_TRUE,那么所有支持VK_QUEUE_GRAPHICS_BIT或者VK_QUEUE_COMPUTE_BIT的队列都能保证支持36位的时间戳。这种情况下,无须检查每一个队列。

最后,字段minImageTimestampGranularity指定了队列传输图像时支持多少单位(如果有的话)。

注意,有可能出现这种情形,设备报告多个明显拥有相同属性的队列族。一个族里的所有队列实质上都等同。不同族里的队列可能拥有不同的内部功能,而这些不能在Vulkan API里轻易表达。由于这个原因,具体实现可能选择将类似的队列作为不同族的成员。这对资源如何在队列间共享施加了更多限制,这可能允许具体实现接纳这些不同。

代码清单1.2展示了如何查询物理设备的内存属性和队列族属性。需要在创建逻辑设备(在下一节会讲到)之前获取队列族的属性。

代码清单1.2 查询物理设备的属性

uint32_t queueFamilyPropertyCount;
std::vector<VkQueueFamilyProperties> queueFamilyProperties;
VkPhysicalDeviceMemoryProperties physicalDeviceMemoryProperties;

//获取物理设备的内存属性
vkGetPhysicalDeviceMemoryProperties( m_physicalDevices[deviceIndex],
                                     &physicalDeviceMemoryProperties);

//首先查询物理设备支持的队列族的数量
vkGetPhysicalDeviceQueueFamilyProperties( m_physicalDevices[0],
                                          &queueFamilyPropertyCount,
                                          nullptr);

//为队列属性结构体分配足够的空间
queueFamilyProperties.resize(queueFamilyPropertyCount);

//现在查询所有队列族的实际属性
vkGetPhysicalDeviceQueueFamilyProperties( m_physicalDevices[0], 
                                          &queueFamilyPropertyCount, 
                                          queueFamilyProperties.data());

在枚举完系统里的所有物理设备之后,应用程序应该选择一个设备,并且针对该设备创建逻辑设备。逻辑设备代表处于初始化状态的设备。在创建逻辑设备时,可以选择可选特性,开启需要的扩展,等等。创建逻辑设备需要调用vkCreateDevice(),其原型如下。

VkResult vkCreateDevice (    
    VkPhysicalDevice                 physicalDevice,
    const VkDeviceCreateInfo*        pCreateInfo,
    const VkAllocationCallbacks*     pAllocator,
    VkDevice*                        pDevice);

把与逻辑设备相对应的物理设备传给physicalDevice,把关于新的逻辑对象的信息传给结构体VkDeviceCreateInfo的实例pCreateInfo。VkDeviceCreateInfo的定义如下。

typedef struct VkDeviceCreateInfo {
    VkStructureType                    sType;
    const void*                        pNext;
    VkDeviceCreateFlags                flags;
    uint32_t                           queueCreateInfoCount;
    const VkDeviceQueueCreateInfo*     pQueueCreateInfos;
    uint32_t                           enabledLayerCount;
    const char* const*                 ppEnabledLayerNames;
    uint32_t                           enabledExtensionCount;
    const char* const*                 ppEnabledExtensionNames;
    const VkPhysicalDeviceFeatures*    pEnabledFeatures;
} VkDeviceCreateInfo;

字段sType应该设置为VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO。通常,除非你希望使用扩展,否则pNext应该设置为nullptr。Vulkan当前版本没有为字段flags定义标志位,所以将这个字段设置为0。

接下来是队列创建信息。pQueueCreateInfos是指向结构体VkDeviceQueueCreateInfo的数组的指针,每个结构体VkDeviceQueueCreateInfo的对象允许描述一个或者多个队列。数组里的结构体数量由queueCreateInfoCount给定。VkDeviceQueueCreateInfo的定义如下。

typedef struct VkDeviceQueueCreateInfo {
    VkStructureType             sType;
    const void*                 pNext;
    VkDeviceQueueCreateFlags    flags;
    uint32_t                    queueFamilyIndex;
    uint32_t                    queueCount;
    const float*                pQueuePriorities;
} VkDeviceQueueCreateInfo;

字段sType设置成VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO。Vulkan当前版本没有为字段flags定义标志位,所以将这个字段设置为0。字段queueFamilyIndex指定了你希望创建的队列所属的族,这是个索引值,与调用vkGetPhysicalDeviceQueueFamilyProperties()返回的队列族的数组对应。为了在这个族里创建队列,将queueCount设置为你希望创建的队列个数。当然,设备在你选择的族中支持的队列数量必须不小于这个值。

字段pQueuePriorities是个可选的指针,指向浮点数数组,表示提交给每个队列的工作的相对优先级。这些数字是个归一化的数字,取值范围是0.0~1.0。给高优先级的队列会分配更多的处理资源或者更频繁地调度它们。将pQueuePriorities设置为nullptr等同于为所有的队列都指定相同的默认优先级。

请求的队列按照优先级排序,并且给它们指定了与设备相关的相对优先级。一个队列能够表示的离散的优先级数量是设备特定的参数。这个参数从结构体VkPhysicalDeviceLimits(调用vkGetPhysicalDeviceProperties()的返回值)里的字段discreteQueuePriorities得到。例如,如果设备只支持高低两种优先级的工作负载,这个字段就是2。所有设备最少支持两个离散的优先级。然而,如果设备支持任意的优先级,这个字段的数值就会非常大。不管discreteQueuePriorities的数值有多大,队列的相对优先级仍然是浮点数。

回到结构体VkDeviceCreateInfo,字段enabledLayerCount、ppEnabledLayerNames、enabled ExtensionCount与ppEnabledExtensionNames用于激活层和扩展。本章后面会讲到这两个主题。现在将enabledLayerCount和enabledExtensionCount设置为0,将ppEnabledLayerNames和ppEnabed ExtensionNames设置为nullptr。

VkDeviceCreateInfo的最后一个字段是pEnabledFeatures,这是个指向结构体VkPhysical DeviceFeatures的实例的指针,这个实例指明了哪些可选扩展是应用程序希望使用的。如果你不想使用任何可选的特性,只需要将它设置为nullptr。当然,这种方式下Vulkan就会相当受限,大量有意思的功能就不能使用了。

为了判断某个设备支持哪些可选的特性,像之前讨论的那样调用vkGetPhysicalDeviceFeatures()即可。vkGetPhysicalDeviceFeatures()将设备支持的特性组写入你传入结构体VkPhysicalDeviceFeatures的实例。查询物理设备的特性并将结构体VkPhysicalDeviceFeatures原封不动地传给vkCreateDevice(),你会激活设备支持的所有可选特性,同时也不会请求设备不支持的特性。

然而,激活所有支持的特性会带来性能影响。对于有些特性,Vulkan具体实现可能需要分配额外的内存,跟踪额外的状态,以不同的方式配置硬件,或者执行其他影响应用程序性能的操作。所以,激活不会使用的特性不是个好主意。你应该查询设备支持的特性,然后激活应用程序需要的特性。

代码清单1.3展示了一个简单的例子,它查询设备支持的特性并设置应用程序需要的功能列表。此处需要支持曲面细分和几何着色器,如果设备支持,就激活多次间接绘制(multidraw indirect),代码接下来使用第一个队列的单一实例创建设备。

代码清单1.3 创建一个逻辑设备

VkResult result;
VkPhysicalDeviceFeatures supportedFeatures; 
VkPhysicalDeviceFeatures requiredFeatures = {};

vkGetPhysicalDeviceFeatures( m_physicalDevices[0],
                             &supportedFeatures);

requiredFeatures.multiDrawIndirect    = supportedFeatures.multiDrawIndirect;
requiredFeatures.tessellationShader   = VK_TRUE;
requiredFeatures.geometryShader       = VK_TRUE;

const VkDeviceQueueCreateInfo deviceQueueCreateInfo =
{
    VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO,   // sType
    nullptr,                                      // pNext
    0,                                            // flags
    0,                                            // queueFamilyIndex
    1,                                            // queueCount
    nullptr                                       // pQueuePriorities
};

const VkDeviceCreateInfo deviceCreateInfo = 
{
    VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO,         // sType
    nullptr,                                      // pNext

    0,                                       // flags
    1,                                       // queueCreateInfoCount
    &deviceQueueCreateInfo,                  // pQueueCreateInfos
    0,                                       // enabledLayerCount
    nullptr,                                 // ppEnabledLayerNames
    0,                                       // enabledExtensionCount
    nullptr,                                 // ppEnabledExtensionNames
    &requiredFeatures                        // pEnabledFeatures
};

result = vkCreateDevice( m_physicalDevices[0], 
                         &deviceCreateInfo,
                         nullptr,
                         &m_logicalDevice);

在代码清单1.3运行成功并创建逻辑设备之后,启用的特性集合就存储在了变量requiredFeatures里。这可以留待以后用,选择使用某个特性的代码可以检查这个特性是否成功激活并优雅地回退。

事实上,Vulkan里面的所有东西都表示为对象,这些对象靠句柄引用。句柄可以分为两大类:可调度对象和不可调度对象。在极大程度上,这与应用程序无关,仅仅影响API的构造以及系统级别的组件,例如Vulkan加载器和层如何与这些对象互操作。

可调度对象内部包含了一个调度表,其实就是函数表,在应用程序调用Vulkan时,各种组件据此判断执行哪一部分代码。这些类型的对象通常是重量级的概念,目前有实例(VkInstance)、物理设备(VkPhysicalDevice)、逻辑设备(VkDevice)、命令缓冲区(VkCommandBuffer)和队列(VkQueue)。其他剩余的对象都可以被视为不可调度对象。

任何Vulkan函数的第一个参数总是个可调度对象,唯一的例外是创建和初始化实例的相关函数。

Vulkan提供两种内存:主机内存和设备内存。通常,Vulkan API创建的对象需要一定数量的主机内存。Vulkan实现在这里存储对象的状态并实现这个API所需的数据。资源对象(例如缓冲区和图像)需要一定数量的设备内存。这就是用于存储资源里数据的内存。

应用程序有可能为Vulkan具体的实现管理主机内存,但是要求应用程序管理设备内存。因此,需要创建设备内存管理子系统。可以查询创建的每个资源,得到用于支持它的内存的数量和类型。应用程序分配正确数量的内存并在使用资源对象前将它附加在这个对象上。

对于高级API,例如OpenGL,这个功能由驱动程序代替应用程序执行。然而,有的应用程序需要大量的小资源,有的应用程序需要少量非常大的资源。有些应用程序在执行期间创建和销毁资源,而有的在初始化时创建所有的资源,直到程序结束才释放。

这些情况下的分配策略可能相当不同,不存在万全之策。因为OpenGL驱动无法预测应用程序的行为,所以必须调整分配策略,以适应你的使用方式。另一方面,作为应用程序的开发者,你完全知道应用程序的行为。可以将资源分为长期和短期两组。可以将一起使用的资源放入几个池式分配的内存里。你可以决定应用程序使用哪种分配策略。

需要特别注意的是,每次动态内存分配都会在系统上产生开销。因此,尽量少分配对象是非常重要的。推荐做法是,设备内存分配器要分配大块的内存。大量小的资源可以放置在少数几个设备内存块里面。关于设备内存分配器的例子会在第2章中讨论,到时会讨论内存分配里的很多细节。

对多线程应用程序的支持是Vulkan设计中不可或缺的一部分。Vulkan通常会假设应用程序能够保证两个线程会在同一个时间修改同一个对象,这称为外部同步。在Vulkan里性能至上的部分(例如构建命令缓冲区)中,绝大部分Vulkan命令根本没有提供同步功能。

为了具体定义各种Vulkan命令中和线程相关的请求,把防止主机同步访问的每一个参数标识为外部同步。在某些情况下,把对象的句柄或者其他的数据内嵌到数据结构体里,包括进数组里,或者通过间接方式传入指令中。那些参数也必须在外部同步。

这么做的目的是Vulkan实现从来不需要在内部使用互斥量或者其他的同步原语来保护数据结构体。这意味着多线程程序很少由于跨线程引起卡顿或者阻塞。

除了在跨线程使用共享对象时要求主机同步访问之外,Vulkan还包含了若干高级特性,专门用来允许多线程执行任务时互不阻塞。这些高级特性如下。

当你正在编写一个非常简单的单线程应用程序时,创建用于分配对象的内存池就显得冗余了。然而,随着应用程序使用的线程不断增多,为了提高性能,这些对象就必不可少了。

在本书剩下的篇幅中,在讲解命令时,和多线程有关的额外需求都会明确指出来。

计算机图形学和大多数异构计算应用程序都严重地依赖数学。大多数Vulkan设备都是基于极其强大的计算处理器的。在本书写作时,即使是很普通的移动处理器也提供了每秒几十亿次浮点运算(GFLOPS)的数据处理能力,而高端台式机和工作站的处理器又提供每秒几万亿次浮点运算(TFLOPS)的数据处理能力。因此,有趣的应用程序构建在数学密集型的着色器之上。另外,Vulkan处理管线中的一些固定功能构建在“硬连接”到设备和规范的数学概念之上。

在图形程序中最基本的“积木”之一就是向量。不管它代表位置、方向、颜色或者其他量,向量在图形学著作中会从头到尾使用到。向量的一种常用形式是齐次向量,这也是个向量,只不过比它所表示的数值多一个维度。这些向量用于存储投影坐标。用任何标量乘以一个齐次向量会产生一个新的向量,代表了相同的投影坐标。要投影一个点向量,需要每一个元素都除以最后一个元素,这样会产生具有xyz和1.0(如果是4个元素的向量)这类形式的向量。

如果要将一个向量从一个坐标空间变换到另一个,需要将这个向量乘以一个矩阵。因为3D空间里的点由具有4个元素的齐次向量表示,所以变换矩阵就应该是4×4的矩阵。

3D空间里的点由齐次向量表示,按照惯例,里面的4个元素分别是xyzw。对于一个点来说,成员w一般来说最开始是1.0,与投影变换矩阵相乘以后就改变了。在除以w之后,这个点就经历了所有的变换,完成了投影变换。如果变换矩阵里没有投影变换矩阵,w仍然是1.0,除以1.0对向量来说没有任何影响。如果向量经过透视变换,w就不等于1.0了,但是使用这个透视变换矩阵除以向量以后,w就由变成1.0了。

同时,3D空间里的方向也由齐次向量来表示,只是w是0.0。如果用正确构造的4×4投影变换矩阵乘以方向向量,w值仍是0.0,这样不会对其他元素产生影响。只需要丢弃额外的元素,你就能像4D齐次3D点向量那样,让3D方向向量经历同样的变换,使它经过同样的旋转、缩放和其他的变换。

Vulkan通过将端点或者拐角表示成3D空间里的点,来表示基本图元,例如线和三角形。这些基本单位称为顶点。输入Vulkan系统的3D坐标系空间(表示为w元素是1.0的齐次向量)里的顶点坐标,这些顶点坐标是相对于当前对象的原点的数值。这个坐标空间称为对象空间或者模型空间。

一般情况下,管线里的第一个着色器会将这个顶点变换到观察空间中,也就是相对于观察者的位置。这个变换操作是通过用一个变换矩阵乘以这个顶点的位置向量实现的。这个矩阵通常称为对象-视图变换矩阵,或者模型-视图变换矩阵。

有时候,需要顶点的绝对坐标,例如查找某个顶点相对于其他对象的距离。这个全局空间称为世界空间,是顶点位置相对于全局原点的位置。

从观察坐标系出来后,把顶点位置变换到裁剪空间。这是Vulkan中几何处理部分的最后一个空间,也是当把顶点推送进3D应用程序使用的投影空间时,这些顶点变换进的空间。把这个空间称为裁剪空间是因为在这个空间里大多数实现都执行裁剪操作,也就是渲染的可见区域之外的图元部分都会被移除。

从裁剪空间出来后,顶点位置通过除以w归一化。这样就产生了一个新的坐标空间,叫作标准化设备坐标(NDC)。而这个操作通常称为透视除法。在这个空间里,在xy两个方向上坐标系上的可见部分是−1.0~1.0,z方向上是0.0~1.0。这个区域之外的任何东西都会在透视除法之前被剔除掉。

最终,顶点的标准化设备坐标由视口变换矩阵进行变换,这个变换矩阵描述了NDC如何映射到正在被渲染的窗口或者图像中。

尽管Vulkan的核心API的设计规范相当丰富,但绝不是包罗万象的。有些功能是可选的,而更多的是以层(修改或者增强了现有的行为)和扩展(增加了Vulkan的新功能)的形式使用的。两种增强机制在下面会讲到。

层是Vulkan中的一种特性,允许修改它的行为。通常,层完全或者部分拦截Vulkan,并增加新的功能,例如日志、追踪、诊断、性能分析等。层可以添加到实例层面,这样,它会影响整个Vulkan实例,也有可能影响由实例创建的每个设备。或者,层可以添加到设备层面中,这样,它仅仅会影响激活这个层的设备。

为了查询系统里的实例可用的层,调用vkEnumerateInstanceLayerProperties(),其原型如下。

VkResult vkEnumerateInstanceLayerProperties (
    uint32_t*                              pPropertyCount,
    VkLayerProperties*                     pProperties);

如果pProperties是nullptr,那么pPropertyCount应该指向一个变量,用于接收Vulkan可用的层的数量。如果pProperties不是nullptr,那么它应该指向结构体VkLayerProperties类型的数组,会向这个数组填充关于系统里注册的层的信息。这种情况下,pPropertyCount指向的变量的初始值是pProperties 指向的数组的长度,并且这个变量会被重写成数组里由指令重写的条目数。

数组pProperties 里的每个元素都是结构体VkLayerProperties的实例,其定义如下。

typedef struct VkLayerProperties {
    char        layerName[VK_MAX_EXTENSION_NAME_SIZE];
    uint32_t    specVersion;
    uint32_t    implementationVersion;
    char        description[VK_MAX_DESCRIPTION_SIZE];
} VkLayerProperties;

每一个层都有个正式的名字,存储在结构体VkLayerProperties里的成员layerName中。每个层的规范都可能不断改进,进一步明晰,或者添加新功能,层实现的版本号存储在specVersion中。

随着规范不断改进,具体实现也需要不断改进。具体实现的版本号存储在结构体VkLayer Properties的字段implementationVersion里。这样就允许改进性能,修正Bug,实现更丰富的可选特性集,等等。应用程序作者可能识别出某个层的特定实现,并选择使用它,只要这个实现的版本号超过了某个版本(例如,后一个版本有个已知的严重Bug需要修复)。

最终,描述层的可读字符串存储在description中。这个字段的唯一目的是输出日志,或者在用户界面展示,仅仅用作提供信息。

代码清单1.4演示了如何查询Vulkan系统支持的实例层。

代码清单1.4 查询实例层

uint32_t numInstanceLayers = 0;
std::vector<VkLayerProperties> instanceLayerProperties;

//查询实例层
vkEnumerateInstanceLayerProperties( &numInstanceExtensions,
                                    nullptr);

//如果有支持的层,查询它们的属性
if (numInstanceLayers != 0)
{
    instanceLayerProperties.resize(numInstanceLayers);
    vkEnumerateInstanceLayerProperties( nullptr,
                                        &numInstanceLayers,
                                        instanceLayerProperties.data());
}

如前所述,不但可以在实例层面注入层,而且可以应用在设备层面应用层。为了检查哪些层是设备可用的,调用vkEnumerateDeviceLayerProperties(),其原型如下。

VkResult vkEnumerateDeviceLayerProperties (
    VkPhysicalDevice                      physicalDevice,
    uint32_t*                             pPropertyCount,
    VkLayerProperties*                    pProperties);

因为系统里的每个物理设备可用的层可能不一样,所以每个物理设备可能报告出一套不同的层。需要查询可用层的物理设备通过physicalDevice传入。传入vkEnumerateDeviceLayerProperties()的参数pPropertyCount和pProperties的行为与传入vkEnumerateInstanceLayerProperties()的相似。设备层也由结构体VkLayerProperties的实例描述。

为了在实例层面激活某个层,需要将其名字包含在结构体VkInstanceCreateInfo的字段ppEnabledLayerNames里,这个结构体用于创建实例。同样,为了在创建对应系统里的某个物理设备的逻辑设备时激活某个层,需要将这个层的名字包含在结构体VkDeviceCreateInfo的成员ppEnabledLayerNames里,这个结构体用于创建设备。

官方SDK包含若干个层,大部分与调试、参数验证和日志有关。具体内容如下。

除此之外,把大量不同的层分到单个更大的层中,这个层名叫VK_LAYER_LUNARG_standard_validation,这样就很容易开启了。本书的应用程序框架在调试模式下编译时激活了这个层,而在发布模式下关闭了所有的层。

对于任何跨平台的开放式API(例如Vulkan),扩展都是最根本的特性。这些扩展允许实现者不断试验、创新并且最终推动技术进步。有用的特性最初作为扩展出现,经过实践证明后,最终变成API的未来版本。然而,扩展并不是没有开销的。有些扩展可能要求具体实现跟踪额外的状态,在命令缓冲区构建时进行额外的检查,或者即使扩展没有直接使用,也会带来性能损失。因此,扩展在使用前必须被应用程序显式启用。这意味着,应用程序如果不使用某个扩展就不需要为此付出增加性能开销和提高复杂性的代价。这也意味着,不会出现意外使用某个扩展的特性,这可以改善可移植性。

扩展可以分为两类:实例扩展和设备扩展。实例扩展用于在某个平台上整体增强Vulkan系统。这种扩展或者通过设备无关的层提供,或者只是每个设备都暴露出来并提升进实例的扩展。设备扩展用于扩展系统里一个或者多个设备的能力,但是这种能力没必要每个设备都具备。

每个扩展都可以定义新的函数、类型、结构体、枚举,等等。一旦激活,就可以认为这个扩展是API的一部分,对应用程序可用。实例和设备扩展必须在创建Vlukan实例与设备时激活。这导致了“鸡和蛋”的悖论:在初始化Vulkan实例之前我们怎么知道哪些扩展可用?

Vulkan实例创建之前,只有少数的函数可用,查询支持的实例扩展是其中一个。通过调用函数vkEnumerateInstanceExtensionProperties()来执行这个操作,其原型如下。

VkResult vkEnumerateInstanceExtensionProperties (
    const char*                            pLayerName,
    uint32_t*                              pPropertyCount,
    VkExtensionProperties*                 pProperties);

字段pLayerName是可能提供扩展的层的名字,目前将这个字段设置为nullptr。pPropertyCount指向一个变量,用于存储从Vulkan查询到的实例扩展的数量,pProperties是个指向结构体VkExtensionProperties类型的数组的指针,会向这个数组中填充支持的扩展的信息。如果pProperties是nullptr,那么pPropertyCount指向的变量的初始值就会被忽略,并重写为支持的实例扩展的数量。

如果pProperties不是nullptr,那么数组里的条目数量就是pPropertyCount指向的变量的值,此时,数组里的条目会被填充为支持的扩展的信息。pPropertyCount指向的变量会重写为实际填充到pProperties 的条目的数量。

为了正确查询所有支持的实例扩展,调用vkEnumerateInstanceExtensionProperties()两次。第一次调用时,将pProperties设置为nullptr,以获取支持的实例扩展的数量。接着正确调整接收扩展属性的数组的大小,并再次调用vkEnumerateInstanceExtensionProperties(),这一次用pProperties传入数组的地址。代码清单1.5展示了如何操作。

代码清单1.5 查询实例扩展

uint32_t numInstanceExtensions = 0;
std::vector<VkExtensionProperties> instanceExtensionProperties;

//查询实例扩展
vkEnumerateInstanceExtensionProperties( nullptr,
                                        &numInstanceExtensions,
                                        nullptr);

//如果有支持的扩展,查询它们的属性
if (numInstanceExtensions != 0)
{
    instanceExtensionProperties.resize(numInstanceExtensions);
    vkEnumerateInstanceExtensionProperties( nullptr,
                                            &numInstanceExtensions,
                                            instanceExtensionProperties.data());
}

在代码清单1.5执行后,instanceExtensionProperties就包含了实例支持的扩展列表。VkExtension Properties类型的数组的每个元素描述了一个扩展。VkExtensionProperties的定义如下。

typedef struct VkExtensionProperties { 
    char        extensionName[VK_MAX_EXTENSION_NAME_SIZE];
    uint32_t    specVersion;
} VkExtensionProperties;

结构体VkExtensionProperties仅仅包含扩展名和版本号。扩展可能随着新的修订版的推出增加新的功能。字段specVersion允许在扩展中增加新的小功能,而无须创建新的扩展。扩展的名字存储在extensionName里面。

就像你之前看到的,当创建Vulkan实例时,结构体VkInstanceCreateInfo有一个名叫ppEnabled ExtensionNames的成员,这个指针指向一个用于命名需要激活的扩展的字符串数组。如果某个平台上的Vulkan系统支持某个扩展,这个扩展就会包含在vkEnumerateInstanceExtensionProperties()返回的数组里,然后它的名字就可以通过结构体VkInstanceCreateInfo里的字段ppEnabledExtension Names传递给vkCreateInstance()。

查询支持的设备扩展是个相似的过程,需要调用函数vkEnumerateDeviceExtensionProperties(),其原型如下。

VkResult vkEnumerateDeviceExtensionProperties (
    VkPhysicalDevice                   physicalDevice,
    const char*                        pLayerName,
    uint32_t*                          pPropertyCount,
    VkExtensionProperties*             pProperties);

vkEnumerateDeviceExtensionProperties()的原型和vkEnumerateInstanceExtensionProperties()几乎一样,只是多了一个参数physicalDevice。参数physicalDevice是需要查询扩展的设备的句柄。就像vkEnumerateInstanceExtensionProperties()一样,如果pProperties是nullptr,vkEnumerateDevice ExtensionProperties()将pPropertyCount重写成支持的扩展的数量;如果pProprties不是nullptr,就用支持的扩展的信息填充这个数组。结构体VkExtensionProperties同时用于实例扩展和设备扩展。

当创建逻辑设备时,结构体VkDeviceCreateInfo里的字段ppEnabledExtensionNames可能包含一个指针,指向vkEnumerateDeviceExtensionProperties()返回的字符串中的一个。

有些扩展以可以调用的额外入口点的形式提供了新的功能。这些以函数指针的形式提供,这些指针必须在扩展激活后从实例或者设备中查询。实例函数对整个实例有效。如果某个扩展扩充了实例层面的功能,你应该使用实例层面的函数指针访问新特性。

为了获取实例层面的函数指针,调用vkGetInstanceProcAddr(),其原型如下。

PFN_vkVoidFunction vkGetInstanceProcAddr (
    VkInstance                              instance,
    const char*                             pName);

参数instance是需要获取函数指针的实例的句柄。如果应用程序使用了多个Vulkan实例,那么这个指令返回的函数指针只对引用的实例所拥有的对象有效。函数名通过pName传入,这是个以nul结尾的UTF-8类型的字符串。如果识别了函数名并且激活了这个扩展,vkGetInstance ProcAddr()的返回值是一个函数指针,可以在应用程序里调用。

PFN_vkVoidFunction是个函数指针定义,其声明如下。

VKAPI_ATTR void VKAPI_CALL vkVoidFunction(void);

Vulkan里没有这种特定签名的函数,扩展也不太可能引入这样的函数。绝大部分情况下,需要在使用前将生成的函数指针类型强制转换为有正确签名的函数指针。

实例层面的函数指针对这个实例所拥有的所有对象都有效——假如创建这些对象(或者设备本身,如果函数在这个设备上调度)的设备支持这个扩展,并且这个设备激活了这个扩展。由于每个设备可能在不同的Vulkan驱动里实现,因此实例函数指针必须通过一个间接层登录正确的模块进行调度。因为管理这个间接层可能引起额外开销,所以为了避免这个开销,你可以获取一个特定于设备的函数指针,这样可以直接进入正确的驱动。

为了获取设备层面的函数指针,调用vkGetDeviceProcAddr(),其原型如下。

PFN_vkVoidFunction vkGetDeviceProcAddr (
    VkDevice                             device,
    const char*                          pName);

使用函数指针的设备通过参数device传入。需要查询的函数的名字需要使用pName传入,这是个以nul 结尾的UTF-8类型的字符串。返回的函数指针只在参数device指定的设备上有效。device必须指向支持这个扩展(提供了这个新函数)的设备,并且这个扩展已经激活。

vkGetDeviceProcAddr()返回的函数指针特定于参数device。即使同样的物理设备使用同样的参数创建出了多个逻辑设备,你也只能在查询这个函数指针的逻辑设备上使用该指针。

在程序结束之前,你需要自己负责清理干净。在许多情况下,操作系统会在应用程序结束时清理已经创建的资源。然而,应用程序和代码同时结束的情景并不经常出现。比如你正在写一个大型应用程序的组件,应用程序可能结束了使用Vulkan实现的渲染和计算操作,但是并没有完全退出。

在清除时,通常来说,较好的做法如下。

逻辑设备很可能是初始化应用程序时创建的最后一个对象(除了运行时使用的对象之外)。在销毁设备之前,需要保证它没有正在执行来自应用程序的任何工作。为了达到这个目的,调用vkDeviceWaitIdle(),其原型如下。

VkResult vkDeviceWaitIdle (
    VkDevice                        device);

把设备的句柄传入device。当vkDeviceWaitIdle()返回时,所有提交给设备的工作都保证已经完成了——当然,除非同时你继续向设备提交工作。需要保证其他可能向设备提交工作的线程已经终止了。

一旦确认了设备处于空闲状态,就可以安全地销毁它了。这需要调用vkDestroyDevice(),其原型如下。

void vkDestroyDevice (
    VkDevice                            device,
    const VkAllocationCallbacks*        pAllocator);

把需要销毁的设备的句柄传递给参数device,并且访问该设备需要在外部同步。需要注意的是,其他指令对设备的访问都不需要外部同步。然而,应用程序需要保证当访问该设备的其他指令正在另一个线程里执行时,这个设备不要销毁。

pAllocator应该指向一个分配的结构体,该结构体需要与创建设备的结构体兼容。一旦设备对象被销毁了,就不能继续向它提交指令了。进一步说,设备句柄就不可能再作为任何函数的参数了,包括其他将设备句柄作为第一个参数的对象销毁方法。这是应该按照创建对象的时间逆序来销毁对象的另一个原因。

一旦与Vulkan实例相关联的所有设备都销毁了,销毁实例就安全了。这是通过调用函数vkDestroyInstance()实现的,其原型如下。

void vkDestroyInstance (
    VkInstance                          instance,
    const VkAllocationCallbacks*        pAllocator);

将需要销毁的实例的句柄传给instance,与vkDestroyDevice()一样,与创建实例使用的分配结构体相兼容的结构体的指针应该传递给pAllocator。如果传递给vkCreateInstance()的参数pAllocator是nullptr,那么传递给vkDestroyInstance()的参数pAllocator也应该是这样。

需要注意的是,物理设备不用销毁。物理设备并不像逻辑设备那样由一个专用的创建函数来创建。相反,物理设备通过调用vkEnumeratePhysicalDevices()来获取,并且属于实例。因此,当实例销毁后,和每个物理设备相关的实例资源也都销毁了。

本章介绍了Vulkan。你已看到了Vulkan状态整体上如何包含在一个实例里。实例提供了访问物理设备的权限,每个物理设备提供了一些用于执行工作的队列。本章还演示了如何根据物理设备创建逻辑设备,如何扩展Vulkan,如何判断实例,设备能用哪些扩展,以及如何启用这些扩展。最后还演示了如何彻底地关闭Vulkan系统,操作顺序依次是等待设备完成应用程序提交,销毁设备句柄,销毁实例句柄。

[1] 是的,确实是nul。字面量为零的ASCII字符被官方称为NUL。现在,不要再告诉我应该改成NULL。这是个指针,不是字符的名字。

[2] 对于一个程序来说是最好的,但在另一个程序中就未必如此。另外,程序是由人编写的,人在写代码时就会有Bug。为了完全优化,或者消除应用程序的Bug,驱动有时候会使用可执行文件的名字,甚至使用应用程序的行为来猜测正在哪个应用程序上运行,并相应地改变行为。虽然并不完美,但这个新的机制至少消除了猜测。

[3] 和OpenGL一样,Vulkan支持将扩展作为API的中心部分。然而,在OpenGL里,我们会创建一个运行上下文,查询支持的扩展,然后开始使用它们。这意味着,驱动需要假设应用程序可能在任何时候突然开始使用某个扩展,并随时准备好。另外,驱动不可能知道你正在查找哪些扩展,这一点更加重了这个过程的困难程度。在Vulkan里,要求应用程序选择性地加入扩展,并显式地启用它们。这允许驱动关闭没有使用的扩展,这也使得应用程序突然开始使用本没有打算启用的扩展中的部分功能变得更加困难。

[4] 并没有关于PCI厂商或者设备标识符的官方的中央版本库。PCI SIG(可从pcisig网站获取)将厂商标识符指定给了它的成员,这些成员又将设备标识符指定给了它们的产品。人和机器同时可读的清单可从pcidatabase网站获取。


在本章,你将学到:

内存是几乎所有计算机系统(也包括Vulkan)做任何操作的基础。在Vulkan里,内存基本上有两种类型:主机内存和设备内存。设备内存必须支持Vulkan能操作的所有资源,应用程序需要负责管理内存。此外,内存也用于在主机端存储数据。Vulkan提供了让应用程序管理内存的机会。在本章中,你将学到管理Vulkan使用的内存的各种机制。

当Vulkan创建新对象时,它可能需要内存来存储与对象相关的数据。此时,它使用主机内存,该内存是CPU可以访问的常规内存,例如,可能是通过malloc或者new调用返回的内存。然而,除了常规的分配器之外,Vulkan有一些特殊的内存分配需求。最值得注意的是,预期分配的内存要正确地对齐。这是因为一些高性能CPU指令在对齐的内存地址上才能发挥最大作用。只有存储在CPU端的数据结构是对齐的,Vulkan才可以使用这些高性能指令,提供显著的性能优势。

由于上述需求,Vulkan实现将使用高级内存分配器。然而,针对某些(甚至是所有)操作,它还为应用程序提供了替换默认分配器的机会。这是通过指定多数设备创建函数的pAllocator参数来实现的。例如,重新回顾一遍vkCreateInstance()函数,它可能是你的应用程序第一个调用的函数。其原型如下。

VkResult vkCreateInstance (
    const VkInstanceCreateInfo*      pCreateInfo,
    const VkAllocationCallbacks*     pAllocator,
    VkInstance*                      pInstance);

pAllocator参数是一个指向VkAllocationCallbacks类型数据的指针。直到目前,一直设置pAllocator为nullptr,这告诉Vulkan去使用它内部提供的默认内存分配器,而不是应用程序提供的内存分配器。VkAllocationCallbacks数据结构封装了提供的自定义内存分配器。这个数据结构的定义如下。

typedef struct VkAllocationCallbacks {
    void*                                     pUserData;
    PFN_vkAllocationFunction                  pfnAllocation;
    PFN_vkReallocationFunction                pfnReallocation;
    PFN_vkFreeFunction                        pfnFree;
    PFN_vkInternalAllocationNotification      pfnInternalAllocation;
    PFN_vkInternalFreeNotification            pfnInternalFree;
} VkAllocationCallbacks;

通过看VkAllocationCallbacks的定义你可以知道,它基本上是一些函数指针的集合和一个void*类型的指针pUserData,该指针供应用程序使用。pUserData可以指向任何位置,Vulkan不会解引用它。事实上,pUserData甚至不需要是一个指针。可以在pUserData中放任何东西,只要它适配一个指针大小的blob。Vulkan对pUserData所做的唯一事情,就是将它传回VkAllocationCallback其余成员指向的回调函数。

PfnAllocation、pfnReallocation和pfnFree用于普通的、对象级别的内存管理。把它们定义为指向与以下声明匹配的函数的指针。

void* VKAPI_CALL Allocation(
    void*                                pUserData,
    size_t                               size,
    size_t                               alignment,
    VkSystemAllocationScope              allocationScope);

void* VKAPI_CALL Reallocation(
    void*                                pUserData,
    void*                                pOriginal
    size_t                               size,
    size_t                               alignment,
    VkSystemAllocationScope              allocationScope);

void VKAPI_CALL Free(
    void*                                pUserData,
    void*                                pMemory);

注意,这3个函数以一个pUserData作为第一个参数,这和VkAllocationCallbacks数据结构体里的pUserData是同一个指针。如果应用程序使用数据结构来管理内存,这是放置它们的地址的地方。合理的方式是,用一个C++类实现内存分配器(假设你在使用C++),并且把这个类的this指针放进pUserData中。

Allocation函数负责新的内存分配。size参数指定了分配多少字节。Alignment参数指定了以多少字节进行内存对齐,这是一个经常被忽视的参数。简单地将这个函数挂载到一个原生的分配器上(比如malloc)看起来很诱人,但是如果你这么做,会发现它工作一段时间后,就会神秘地崩溃。如果你提供了自己的内存分配器,就必须重视对齐参数。

最后一个参数allocationScope告诉应用程序内存分配的范围和生命周期是什么样的。它是VkSystemAllocationScope值中的某一个,有如下定义。

pfnInternalAllocation和pfnInternalFree函数指针指向了代替Vulkan自带分配器的替换函数。它们和pfnAllocation和pfnInternalFree的函数签名相同,唯一的不同是pfnInternalAllocation不返回值,且pfnInternalFree不应该真的释放内存。这些函数仅仅用于通知,这样应用程序可以跟踪Vulkan的内存使用量。这些函数的原型如下。

void VKAPI_CALL InternalAllocationNotification(

    void*                                      pUserData,
    size_t                                     size,
    VkInternalAllocationType                   allocationType,
    VkSystemAllocationScope                    allocationScope);

void VKAPI_CALL InternalFreeNotification(
    void*                                      pUserData,
    size_t                                     size,
    VkInternalAllocationType                   allocationType,
    VkSystemAllocationScope                    allocationScope);

对于pfnInternalAllocation和pfnInternalFree提供的信息,除了输出日志和跟踪应用程序的内存使用量之外,其他都做不了。这些函数指针是可选的,但如果指定了一个,另外一个也必须指定。如果你不想用,将它们都设置为nullptr即可。

代码清单2.1展示了一个例子,演示了如何声明一个能够用作分配器的C++类,该分配器映射到Vulkan中的回调函数。因为Vulkan使用的这些回调函数是C函数裸指针,所以这些回调函数被声明为类静态成员函数,而实际的实现函数被声明为非静态成员函数。

代码清单2.1 声明一个内存分配器类

class allocator
{
public:
    // 运算符,允许这个类的一个实例用作结构体VkAllocationCallbacks
    inline operator VkAllocationCallbacks() const
    {
        VkAllocationCallbacks result;

        result.pUserData = (void*)this;
        result.pfnAllocation = &Allocation;
        result.pfnReallocation = &Reallocation;
        result.pfnFree = &Free;
        result.pfnInternalAllocation = nullptr;
        result.pfnInternalFree = nullptr;

        return result;
    };

private:
    // 将分配器回调函数声明为静态成员函数
    static void* VKAPI_CALL Allocation(
        void*                   pUserData,
        size_t                  size,
        size_t                  alignment,
        VkSystemAllocationScope allocationScope);

    static void* VKAPI_CALL Reallocation(
        void*                   pUserData,
        void*                   pOriginal,
        size_t                  size,
        size_t                  alignment,
        VkSystemAllocationScope allocationScope);

    static void VKAPI_CALL Free(
        void*                   pUserData,
        void*                   pMemory);

    // 现在,声明非静态成员函数,这实际上会执行分配操作
    void* Allocation(
        size_t                  size,
        size_t                  alignment,
        VkSystemAllocationScope allocationScope);

    void* Reallocation(
        void*                   pOriginal,
        size_t                  size,
        size_t                  alignment,
        VkSystemAllocationScope allocationScope);

    void Free(
        void*                   pMemory);
};

代码清单2.2 展示了这个类的一个示例实现。它把Vulkan的内存分配函数映射到符合POSIX标准的aligned_malloc函数。注意,这个分配器很可能并不会比大多数Vulkan实现在内部使用的分配器更好,这只是作为一个例子,用于演示如何将回调函数挂载在自己的代码上。

代码清单2.2 实现一个内存分配器类

void* allocator::Allocation(
    size_t                                 size,
    size_t                                 alignment,
    VkSystemAllocationScope                allocationScope)
{
    return aligned_malloc(size, alignment);
}

void* VKAPI_CALL allocator::Allocation(
    void*                                  pUserData,
    size_t                                 size,
    size_t                                 alignment,
    VkSystemAllocationScope                allocationScope)
{
    return static_cast<allocator*>(pUserData)->Allocation(size,
                                                          alignment,
                                                          allocationScope);
}

void* allocator::Reallocation(
    void*                                  pOriginal,
    size_t                                 size,
    size_t                                 alignment,
    VkSystemAllocationScope                allocationScope)
{
    return aligned_realloc(pOriginal, size, alignment);
}

void* VKAPI_CALL allocator::Reallocation(
    void*                                  pUserData,
    void*                                  pOriginal,
    size_t                                 size,
    size_t                                 alignment,
    VkSystemAllocationScope                allocationScope)
{
    return static_cast<allocator*>(pUserData)->Reallocation(pOriginal,
                                                            size,
                                                            alignment,
                                                            allocationScope);
}

void allocator::Free(
    void*                                        pMemory)
{
    aligned_free(pMemory);
}

void VKAPI_CALL allocator::Free(
    void*                                        pUserData,
    void*                                        pMemory)
{
    return static_cast<allocator*>(pUserData)->Free(pMemory);
}

在代码清单2.2中我们可以看到,静态成员函数简单地把参数pUserData的类型强制转换回该类的一个实例,并调用对应的非静态成员函数。因为非静态和静态函数在同一个编译单元内,非静态函数很有可能被内联进了静态函数,所以这种实现是很高效的。

Vulkan在数据上进行操作,与之相比,其他东西的重要性皆次之。数据存储在资源中,而资源存放在内存中。Vulkan有两种基本的资源:缓冲区和图像。一方面,缓冲区是一个简单且连续的块状数据,可以用来存储任何东西——数据结构、原生数组,甚至图像数据,你应当选择如何使用它。另一方面,图像是结构化的,拥有类型和格式信息,可以是多维的,自己也可组建数组,支持对它进行高级的读写操作。

两种类型的资源都是通过两个步骤构造的:首先创建资源自身,然后在内存中备份资源。这么做的原因是允许应用程序自己来管理内存。内存管理比较复杂,由驱动来保证永远运行正常会非常困难。因此,应该是由应用程序来做内存管理,而不是驱动。例如,如果应用程序使用数量很少但数据量很大的资源,并且长时间持有它们,那么就可以在其使用的内存分配器里使用一种管理策略。而有的程序可能需要不断地创建并销毁小数据量的资源,这时可以实现另外一种管理策略。

尽管图像是更加复杂的结构体,但是创建它们的过程和缓冲区类似。本节先讲解缓冲区的创建,后讲解图像。

缓冲区是Vulkan中最简单但使用非常广泛的资源类型。它通常用来存储线性的结构化的或非结构化的数据,在内存中可以有格式,或者只是原生的字节数据。当后面讨论到这些话题时,会讲到缓冲区对象的各种使用方式。要创建缓冲区对象,需要调用vkCreateBuffer(),其原型如下。

VkResult vkCreateBuffer (
    VkDevice                          device,
    const VkBufferCreateInfo*         pCreateInfo,
    const VkAllocationCallbacks*      pAllocator,
    VkBuffer*                         pBuffer);

如同Vulkan中的大多数函数,它有许多参数,把这些参数打包进一个结构体中,通过指针传到Vulkan。这里,参数pCreateInfo是指向结构体VkBufferCreateInfo的一个实例的指针,它的定义如下。

typedef struct VkBufferCreateInfo {
    VkStructureType        sType;
    const void*            pNext;
    VkBufferCreateFlags    flags;
    VkDeviceSize           size;
    VkBufferUsageFlags     usage;
    VkSharingMode          sharingMode;
    uint32_t               queueFamilyIndexCount;
    const uint32_t*        pQueueFamilyIndices;
} VkBufferCreateInfo;

sType应当设置为VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO,除非你想要使用扩展,否则pNext应设置为nullptr。flags告诉Vulkan有关缓冲区的属性信息。在当前的Vulkan版本中,字段flags可用的已定义的全部标志位都和稀疏缓冲区相关,这将在本章稍后部分讲解。当前,flags 设置为0。

字段size设定了缓冲区的大小,以字节为单位。usage告诉Vulkan你如何使用缓冲区,它只能设置为VkBufferUsageFlagBits这个枚举类型的某一个值。在某些架构中,缓冲区的使用方式会影响到创建的过程。当前定义的设定值有下面几个,接下来几节将会讨论这些内容。

VkBufferCreateInfo的字段sharingMode表示缓冲区在设备支持的多个缓冲区队列中如何使用。因为Vulkan并行地执行多个操作,所以一些Vulkan实现需要知道缓冲区由几个命令使用。当设置sharingMode为VK_SHARING_MODE_EXCLUSIVE时,表明缓冲区只会被一个队列使用。当设置为VK_SHARING_MODE_CONCURRENT时,表示你计划在多个队列中同时使用这个缓冲区。使用VK_SHARING_MODE_CONCURRENT可能会导致在一些系统上效率不高,所以除非你的确需要才设置为这个值。

如果你真的将sharingMode设置为VK_SHARING_MODE_CONCURRENT,你需要告诉Vulkan哪些队列将使用这个缓冲区。这通过设置VkBufferCreateInfo的字段pQueueFamilyIndices来完成,这是一个指向队列族数组的指针。queueFamilyIndexCount是数组的长度——将要使用这个缓冲区的队列族的个数。当sharingMode设置为VK_SHARING_MODE_EXCLUSIVE时,queueFamilyCount和pQueueFamilies都会被忽略。

代码清单2.3演示了如何创建一个1MB大小的缓冲区对象,它不仅可读写,还会每次只被一个队列族使用。

代码清单2.3 创建一个缓冲区对象

static const VkBufferCreateInfo bufferCreateInfo =
{
    VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO, nullptr,
    0,
    1024 * 1024,
    VK_BUFFER_USAGE_TRANSFER_SRC_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT,
    VK_SHARING_MODE_EXCLUSIVE,
    0, nullptr
};

VkBuffer buffer = VK_NULL_HANDLE; 

vkCreateBuffer(device, &bufferCreateInfo, &buffer);

代码清单2.3运行后,就创建了一个新的VkBuffer的句柄,并且放在变量buffer中。这个缓冲区还不能正常使用,因为它首先需要用内存进行支持,这个操作将在2.3节中讲到。

缓冲区是相对简单的资源类型,存放的数据没有格式的概念,图像和缓冲区视图(我们将会简短地介绍)包含与它们的内容相关的信息。部分信息描述了资源中数据的格式。当在管线的特定部分中使用某些格式时,对这些格式有特殊的要求或者限制。例如,有些格式可读但是不可写,这在压缩格式中很常见。

为了确定各种格式的属性和支持级别,可以调用vkGetPhysicalDeviceFormatProperties(),其原型如下。

void vkGetPhysicalDeviceFormatProperties (
    VkPhysicalDevice                         physicalDevice,
    VkFormat                                 format,
    VkFormatProperties*                      pFormatProperties);

因为对特定格式的支持是物理设备的属性,而非逻辑设备的,所以用physicalDevice来指定物理设备的句柄。如果应用程序必须要求支持某些格式,可以在创建逻辑设备之前做检查,并且在应用程序启动时拒用特定的物理设备。要检查是否支持的格式用format指定。如果设备能识别格式,它将把支持级别写进pFormatProperties指向的结构体VkFormatProperties的实例里。VkFormatProperties的定义如下。

typedef struct VkFormatProperties {
    VkFormatFeatureFlags    linearTilingFeatures;
    VkFormatFeatureFlags    optimalTilingFeatures;
    VkFormatFeatureFlags    bufferFeatures;
} VkFormatProperties;

VkFormatProperties的3个字段都是位域,值由枚举VkFormatFeatureFlagBits中的某些值构成。图像可以是两种基本平铺模式之一:线性的,图像数据在内存中线性地排列,先按行,再按照列排列;最优化的,图像数据以最优方案排列,可以最高效地利用显卡内存子系统。字段linearTilingFeatures表示对图像线性平铺格式的支持级别,optimalTilingFeatures表示对图像优化平铺格式的支持级别,bufferFeatures表示这种格式在缓冲区里使用时支持的级别。

在这些字段里可用的各种枚举值定义如下。

许多格式开启了多个格式支持标志位。实际上,强制支持许多格式。在Vulkan技术规范文档中,有一个“必须支持的”完整格式列表。如果一种格式在此列表中,那么就没必要测试是否支持。然而,出于完整性,规范里希望各种Vulkan实现精确地报告所有支持格式的功能,甚至是必须支持的格式。

vkGetPhysicalDeviceFormatProperties()函数只会返回一个粗糙的结果集,告诉我们一种格式是否可用在所有的特定场景中。尤其对图像来说,一种特定格式和它在图像的支持级别上的效果之间的相互影响更加复杂。因此,当用于图像时,为了更多地获取对某种格式的支持情况的信息,可以调用vkGetPhysicalDeviceImageFormatProperties(),其原型如下。

VkResult vkGetPhysicalDeviceImageFormatProperties (
    VkPhysicalDevice                       physicalDevice,
    VkFormat                               format,
    VkImageType                            type,
    VkImageTiling                          tiling,
    VkImageUsageFlags                      usage,
    VkImageCreateFlags                     flags,
    VkImageFormatProperties*               pImageFormatProperties);

与vkGetPhysicalDeviceFormatProperties()类似,vkGetPhysicalDeviceImageFormatProperties()以一个VkPhysicalDevice类型的句柄作为第一个参数,报告物理设备而非逻辑设备对某个格式的支持结果。查询的这个格式通过参数format传递。

要询问的图像类型通过type指定。它应当是图像类型中的某一个:VK_IMAGE_TYPE_1D、VK_IMAGE_TYPE_2D或者VK_IMAGE_TYPE_3D。不同的图像类型也许有不同的限制条件和增强功能。图像的平铺模式是通过参数tiling指定的,值为VK_IMAGE_TILING_LINEAR或者VK_IMAGE_TILING_OPTIMAL,分别表示线性或者最优平铺。

图像的用途是通过参数usage指定的。这个位域表明图像将如何使用。图像的各种用法将在本章稍后讲到。字段flags应当设置为在创建图像时使用的相同值。

如果Vulkan 实现识别和支持这种格式,那么会把支持级别写入 pImageFormatProperties指向的结构体VkImageFormatProperties中。VkImageFormatProperties的定义如下。

typedef struct VkImageFormatProperties {
    VkExtent3D            maxExtent;
    uint32_t              maxMipLevels;
    uint32_t              maxArrayLayers;
    VkSampleCountFlags    sampleCounts;
    VkDeviceSize          maxResourceSize;
} VkImageFormatProperties;

VkImageFormatProperties的成员extent报告了某个格式的图像在创建时的最大尺寸。例如,每个像素占位更少的格式比占位更多的格式可以支持创建更大的图像。extent是结构体VkExtent3D的一个实例,其定义如下。

typedef struct VkExtent3D {
    uint32_t    width;
    uint32_t    height;
    uint32_t    depth;
} VkExtent3D;

对于指定格式的图像,以及其他传入vkGetPhysicalDeviceImageFormatProperties()的参数,字段maxMipLevels报告了可以支持的最大mipmap层级数。多数情况下,如果这个图像支持mipmaps,maxMipLevels报告的值是log2 (max (extent.x, extent.y, extent.z));如果不支持,报告1。

字段maxArrayLayers报告了图像支持的数组层的最大数量。同样,如果支持数组,这个可能会是一个非常大的数字;如果不支持,值为1。

如果图像格式支持多重采样,那么支持的采样数通过sampleCounts获得。这是一个位域,每一位都表示是否支持对应的采样数量。如果设置n位,那么这种格式就支持2n次采样。如果完全支持这种格式,这个位域中至少有一位是会设置的。几乎不可能出现下述情况:一种格式支持多重采样,但是不支持每个像素单次采样。

最后,字段maxResourceSize指定了这种格式的资源的最大尺寸(以字节为单位)。此字段不要和maxExtent混淆了。maxExtent表示支持的每一个维度的最大值。比如,如果某个Vulkan实现表示可以支持每层有16 384×16 384像素并且包含2048 层的图像,每个像素包含128位,那么以每个维度的最大值来创建图像,将会产生8TB的数据。该Vulkan实现几乎不会真的支持创建8TB的图像。然而,它可能很好地支持创建8×8×2048的阵列图像,或者16 384×16 284的非阵列图像,两者都不会占用太多的内存。

图像比缓冲区更加复杂,因为它们是多维的,有独特的布局和格式信息,可作为过滤、混合、深度或者模板测试等复杂操作的源或者目标。可以用vkCreateImage()函数创建图像,原型如下。

VkResult vkCreateImage (
    VkDevice                         device,
    const VkImageCreateInfo*         pCreateInfo,
    const VkAllocationCallbacks*     pAllocator,
    VkImage*                         pImage);

用来创建图像的设备通过参数device传入。同样,图像的相关信息通过一个数据结构体传入,地址通过参数pCreateInfo传入。它是指向结构体VkImageCreateInfo的一个实例的指针,定义如下。

typedef struct VkImageCreateInfo {
    VkStructureType          sType;
    const void*              pNext;
    VkImageCreateFlags       flags;
    VkImageType              imageType;
    VkFormat                 format;
    VkExtent3D               extent;
    uint32_t                 mipLevels;
    uint32_t                 arrayLayers;
    VkSampleCountFlagBits    samples;
    VkImageTiling            tiling;
    VkImageUsageFlags        usage;
    VkSharingMode            sharingMode;
    uint32_t                 queueFamilyIndexCount;
    const uint32_t*          pQueueFamilyIndices;
    VkImageLayout            initialLayout;
} VkImageCreateInfo;

你可以看到,这是一个比VkBufferCreateInfo明显复杂的结构体。常见的字段sType和pNext在最前面,这与其他多数Vulkan结构体类似。字段sType应当设置为VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO。

字段flags包含描述图像部分属性信息的标志位。枚举类型VkImageCreateFlagBits有多个定义。前面 3 个——VK_IMAGE_CREATE_SPARSE_BINDING_BIT、VK_IMAGE_CREATE_SPARSE_RESIDENCY_BIT和VK_IMAGE_CREATE_SPARSE_ALIASED_BIT——是用来控制稀疏图像的,在本章稍后讲解。

如果设置为VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT,那么可以为图像创建具有不同格式的视图。图像视图实质上是一种特殊的图像,它可以和父图像共享数据与布局,但是可以重写参数,例如格式。这就允许图像的数据同时以不同的方式解读。使用图像视图,就可以为同一份数据创建两个不同的版本。图像视图将在本章稍后讲解。如果设置为VK_IMAGE_CREATE_CUBE_COMPATIBLE_BIT,那么可以创建立方纹理视图。立方纹理视图将在本章稍后讲解。

字段imageType 指定了你想创建的图像的类型。图像类型实质上就是图像的维度,可选值有VK_IMAGE_TYPE_1D、VK_IMAGE_TYPE_2D或者VK_IMAGE_TYPE_3D,分别表示1D、2D或者3D图像。

图像也有格式,它描述了像素数据是如何在内存里存放的,并且是如何被Vulkan解释的。图像的格式是通过formats指定的,而且必须是VkFormat 类型的枚举值。Vulkan支持很大数量的格式——在这里无法列出。我们在本书中使用某些作为例子,并讲解它们如何工作。关于剩下的格式,请参考Vulkan规范文档。

图像的extent是指以像素为单位的大小,它通过结构体VkImageCreateInfo的字段extent指定。它是结构体VkExtent3D的一个实例,有width、height和depth三个成员。它们应当分别设置为目标图像的宽度、高度和深度。对于1D图像,height应设置为1。对于1D和2D图像,depth需要设置为1。Vulkan使用显式的数组大小,通过arrayLayers指定,而不是默认把下一个更大尺寸当作数组的个数。

能够创建的图像的最大尺寸依赖于每个GPU设备。要获取这个最大尺寸,可调用vkGetPhysical DeviceFeatures()并且检查内置结构体VkPhysicalDeviceLimits的字段maxImageDimension1D、maxImageDimension2D和maxImageDimension3D。maxImageDimension1D是一维图像的最大宽度,maxImageDimension2D是二维图像的最大边长,maxImageDimension3D是三维图像的最大边长。同样,字段maxImageArrayLayers包含了阵列图像里的最大层数。如果图像是立方图,maxImageDimensionCube存储了立方体的最大边长。

maxImageDimension1D、maxImageDimension2D和 maxImageDimensionCube 都能保证不小于4096纹素,且maxImageDimensionCube和maxImageArrayLayers保证不小于256纹素。如果想要创建的图像比这些尺寸小,那么就无须检查硬件的特性。进一步来讲,Vulkan实现一般都会支持远高于最低标准的规格。所以,将更大的图像尺寸作为硬性要求可能是合理的,而不用为低端设备创建回滚通道。

mipLevels指定了mipmap层级的个数。mipmap是使用一套依次降低分辨率的预过滤图像的过程,这是为了在降采样图像时提升图像质量。这些图片以金字塔的形式组成了mipmap的各个层级(见图2.1)。

图2.1 mipmap图像的布局

在一个mipmap纹理中,基础层级是号码最小的层级(通常是层级0),并且拥有纹理的尺寸。后继层级的大小依次只有上一层的一半,直到某个维度的大小变为1纹素。从mipmap纹理采样将在第6章中讲解。

同样地,samples指定了采样的次数。这个字段与其他字段不大相似。它必须是VkSample CountFlagBits这个枚举中的某个值,实际上定义为用于位域的标志位。然而,在现在的Vulkan中,只定义了2n次采样,这意味着它们是“单热”值。所以,一位的枚举值就可以正常工作了。

余下的几个字段描述了图像将会如何使用。首先是平铺模式,通过字段tiling指定。这是一个VkImageTiling枚举类型的变量,只有VK_IMAGE_TILING_LINEAR和VK_IMAGE_TILING_OPTIMAL这两个选项。线性平铺表示图像数据从左到右、从上到下地存放,如果映射到底层内存并且通过CPU写入,它将形成线性的图像。同时,优化的平铺是Vulkan使用的不透明表示方式,用于在内存中放置数据,以提高设备上内存子系统的效率。一般来说,需要选择这个选项,除非打算用CPU来映射和操作图像数据。对于绝大多数操作,优化平铺会比线性平铺表现得明显更好,而且对于一些操作和格式可能不支持线性平铺,这取决于Vulkan的具体实现。

字段usage是位域变量,描述了图像在哪里使用。这和VkBufferCreateInfo的usage类似。这里的usage由枚举值VkImageUsageFlags组成,成员如下。

sharingMode 与本章前面提到的VkBufferCreateInfo结构的同名成员在功能上是相同的。若设置为VK_SHARING_MODE_EXCLUSIVE,这幅图像在某个时刻只能被一个队列使用。若设置为VK_SHARING_MODE_CONCURRENT,那么该图像可以同时被多个队列访问。同样,当sharingMode 设置为VK_SHARING_MODE_CONCURRENT时,queueFamilyIndexCount和pQueueFamilyIndices提供相近的功能。

最后,图像有布局,在某种程度上布局指定了在任意时刻图像将会如何使用。字段initialLayout决定了图像以哪种布局创建。VkImageLayout这个枚举类型定义了可用的布局方式,它们如下。

图像可从一个布局转移到另外一个,相关章节将会讲到不同的布局。然而,最初,创建的图像只能是VK_IMAGE_LAYOUT_UNDEFINED或者VK_IMAGE_LAYOUT_PREINITIALIZED布局。当你在内存中有数据并且迅速绑定到图像资源时,才使用VK_IMAGE_LAYOUT_PREINITIALIZED。当你计划在使用前把资源转移到另外一个布局时,应当使用VK_IMAGE_LAYOUT_UNDEFINED。任何时候,当图像被移出VK_IMAGE_LAYOUT_UNDEFINED布局时,几乎没有性能消耗。

改变图像布局的机制也称为“管线屏障”,或简称“屏障”。屏障不仅用作改变资源布局,还可以用来同步Vulkan管线的不同阶段(甚至一个GPU设备上同时运行的队列)对该资源的访问。因此,管线屏障相当复杂,正确地使用它并不简单。管线屏障将在第4章中深入讲解。

代码清单2.4展示了创建一个简单的图像资源的例子。

代码清单2.4 创建图像对象

VkImage image = VK_NULL_HANDLE;
VkResult result = VK_SUCCESS;

static const 
VkImageCreateInfo imageCreateInfo =
{
    VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO,       // sType
    nullptr,                                   // pNext
    0,                                         // flags
    VK_IMAGE_TYPE_2D,                          // imageType
    VK_FORMAT_R8G8B8A8_UNORM,                  // format
    { 1024, 1024, 1 },                         // extent
    10,                                        // mipLevels
    1,                                         // arrayLayers
    VK_SAMPLE_COUNT_1_BIT,                     // samples
    VK_IMAGE_TILING_OPTIMAL,                   // tiling
    VK_IMAGE_USAGE_SAMPLED_BIT,                // usage
    VK_SHARING_MODE_EXCLUSIVE,                 // sharingMode
    0,                                         // queueFamilyIndexCount
    nullptr,                                   // pQueueFamilyIndices
    VK_IMAGE_LAYOUT_UNDEFINED                  // initialLayout
};

result = vkCreateImage(device, &imageCreateInfo, nullptr, &image);

在代码清单2.4中创建的图像是一幅1024×1024纹素、单采样、VK_FORMAT_R8G8B8A8_UNORM格式和处于最优化平铺方式的2D图像。代码以未定义的布局创建了它,这表示可以把它转移到另外一种布局,并用数据填充它。因为这幅图像将会作为着色器中的一个纹理,所以设置usage为VK_IMAGE_USAGE_SAMPLED_BIT。在这个简单的应用程序中,因为只使用单个队列,所以设置共享模式为独占的。

1.线性图像

如之前章节讨论过的,各种资源都有两种平铺模式:VK_IMAGE_TILING_LINEAR 和VK_IMAGE_TILING_OPTIMAL。VK_IMAGE_TILING_OPTIMAL是一种不透明、实现方式各异的布局,用于提高设备内存子系统对图像的读写效率。然而,VK_IMAGE_TILING_LINEAR是一种透明的数据布局方式,用于足够直观地排列图像。在图像内部,像素以从左到右、从上到下的方式布局。因此,可以映射用于备份资源的内存,以允许主机直接读写内存。

如果想让CPU访问底层图像数据,除了图像的宽度、高度、深度和像素格式之外,还有其他几个信息是必需的:行间距,图像内每一行开始之间的距离(以字节为单位);阵列间距,不同阵列层之间的距离;深度间距,深度切片之间的距离。当然,阵列间距和深度间距分别只适用于阵列与3D图像,行距只适用于2D或3D图像。

图像通常是由几个子资源组成的。一些格式有多个层面(aspect),层面是类似于深度图像的深度或者模板分量的一种分量。也认为mipmap层级和阵列的层是独立的资源。图像里不同的子资源的布局可能是不同的,因此有不同的布局信息。这个信息可调用vkGetImageSubresourceLayout()来查询,其原型如下。

void vkGetImageSubresourceLayout (
    VkDevice                             device,
    VkImage                              image,
    const VkImageSubresource*            pSubresource,
    VkSubresourceLayout*                 pLayout);

被查询图像所在的设备通过device参数传递,查询的图像通过image参数传递。子资源的信息通过一个VkImageSubresource结构体类型的指针pSubresource传递。该结构体的定义如下。

typedef struct VkImageSubresource {
    VkImageAspectFlags    aspectMask;
    uint32_t              mipLevel;
    uint32_t              arrayLayer;
} VkImageSubresource;

要通过布局查询的一个或者多个层面是通过aspectMask参数指定的。对于彩色图像,这个值应该是VK_IMAGE_ASPECT_COLOR_BIT。对于深度、模板或者深度-模板图像,它应该是VK_IMAGE_ASPECT_DEPTH_BIT和VK_IMAGE_ASPECT_STENCIL_BIT再结合其他的值。mipmap层级通过参数mipLevel返回,阵列的层通过arrayLayer指定。正常情况下,应该设置arrayLayer为0,因为图像的参数在多层之间是不会改变的。

当vkGetImageSubresourceLayout()返回时,它已经将子资源的布局参数写入了一个VkSubresource Layout类型的数据中,由pLayout指针指明。VkSubresourceLayout的定义如下。

typedef struct VkSubresourceLayout {
    VkDeviceSize    offset;
    VkDeviceSize    size;
    VkDeviceSize    rowPitch;
    VkDeviceSize    arrayPitch;
    VkDeviceSize    depthPitch;
} VkSubresourceLayout;

被请求的子资源消耗的内存区的大小通过size返回,资源中的偏移量(即子资源的开始位置)是通过offset返回的。字段rowPitch、arrayPitch和depthPitch包含行间距、阵列间距与深度间距。不管图像的像素格式是什么,这些字段的单位都是字节。在一行内的像素总是高度压缩的。图2.2展示了这些参数如何表示一张图像的内存布局。这张图里,有效的图像数据位于灰色网格区域,空白空间表示了图像周边的内边距。

图2.2 线性平铺图像的内存布局

假设一张图像的内存布局是线性平铺模式的,有可能简单地计算图像内每一个像素的内存地址。载入图片数据到一个线性平铺图像只是简单将图片的扫描线加载到内存的正确位置。对于许多纹素格式和图像尺寸来说,极有可能图像的行数据在内存中是紧密排列的。也就是说,VkSubresourceLayout的字段rowPitch等于子资源的宽度。在这种情况下,许多图片载入库能够直接把图片载入一个图像的映射内存。

2.非线性编码

你也许已经注意到了,一些Vulkan图像的格式名包含SRGB。这指的是sRGB颜色编码,这是一种非线性编码,它使用了一条伽马曲线来逼近CRT编码。虽然CRT现在已经完全过时了,但是sRGB编码仍广泛地应用于纹理和图片数据。

因为CRT产生的光线能量与用来产生电子束(用于激发荧光粉)的电量之间的关系是非线性的,所以反向映射必须应用在颜色信号上,以便当亮度数值线性提高时,光线的输出也能线性地提高。CRT产生的光量近似于下述公式:

Lout = Vin γ

NTSC电视标准系统(在北美、南美和部分亚洲地区常见)中γ的标准值是2.2。同时,SECAM和PAL系统(在欧洲、非洲、澳大利亚、亚洲其他地区常见)中,γ的标准值是2.8。

sRGB曲线试图通过对内存中的线性数据应用伽马校正来对此进行补偿。标准的sRGB变换函数并不是标准的伽马曲线,而由一条短的线性直线和一条伽马校正曲线组成。这个函数把数据从线性空间映射到sRGB空间。

if (cl >= 1.0)
{
    cs = 1.0;
}
else if (cl <= 0.0)
{
    cs = 0.0;
}
else if (cl < 0.0031308)
{
    cs = 12.92 * cl;
}
else
{
    cs = 1.055 * pow(cl, 0.41666) - 0.055;
}

从sRGB空间到线性空间,是由下面的变换完成的。

if (cs >= 1.0)
{
    cl = 1.0;
}
else if (cs <= 0.0)
{
    cl = 0.0;
}
else if (cs <= 0.04045)
{
    cl = cs / 12.92;
}
else
{
    cl = pow((cs + 0.0555) / 1.055), 2.4)
}

在两个代码片段中,cs是sRGB颜色空间的数值,cl是线性的数值。图2.3对比了简单的伽马(这里γ = 2.2)曲线和标准的sRGB转换函数。你可以在图中看到,sRGB校正的曲线(上面的)和简单的指数曲线(下面的)几乎是一样的。但是,Vulkan实现有望用官方定义来实现sRGB,如果你需要手动在着色器中做变换工作,你也许可以使用一个简单的指数函数——它不会积累多少误差。

图2.3 sRGB的伽马曲线(上)和简单的指数曲线(下)

当以sRGB格式渲染一张图像时,着色器产生的线性值在写入图像前需要转换为sRGB编码。当从一张sRGB图片中读取数据时,在返回给着色器之前,纹素从sRGB格式转换回线性空间。

混合和插值总是在线性空间进行的,这样从帧缓冲区读出的数据首先从sRGB空间转换到线性空间,然后在线性空间和源数据混合,最终在写入帧缓冲区前转换回sRGB编码。

在sRGB空间进行渲染可以在颜色比较暗时提供更多的精度,减少带状瑕疵,提供更丰富的颜色。然而,为了获得最佳的图片质量,需要引入高动态范围渲染,最好选择浮点类型的颜色格式并在线性空间渲染,在显示前尽量晚地变换到sRGB空间。

3.压缩图像的格式

图像资源可能是应用程序中使用设备内存最多的。因此,Vulkan提供了压缩多种图像格式的功能。图像压缩为应用程序提供了如下两个显著的好处。

当前Vulkan中定义的各种压缩图像格式称为“块状压缩格式”。把图片里的纹素压缩到多个正方形区域或者矩形区域里,这些区域可以独立解压,不受其他区域的影响。所有的压缩格式都是有损耗的,压缩率也不及JPEG,或者甚至不及PNG等格式。然而,在硬件里解压非常快,实现起来也很廉价,对纹素的随机访问也相当简单。

对各种压缩格式的支持也是可选的,但是要求所有的Vulkan实现都最少支持一个格式族。可以调用vkGetPhysicalDeviceProperties()返回一个结构体VkPhysicalDeviceFeatures,通过查询这个结构体里的各种字段来判断支持哪个压缩格式族。

如果textureCompressionBC是VK_TRUE,那么设备就支持块状压缩格式(也称为BC格式)。BC格式族包含以下几种。

如果结构体VkPhysicalDeviceFeatures的成员textureCompressionETC2为VK_TRUE,那么设备就支持ETC格式(包含ETC2、EAC)。这个格式族包含如下格式。

最后一个族是ASTC族。如果结构体VkPhysicalDeviceFeatures的成员textureCompressionASTC_LDR为VK_TRUE,那么设备就支持ASTC格式。你也许已经注意到了,对于BC和ETC格式族中所有的格式,压缩块尺寸固定在4×4纹素,但是根据格式、纹素格式和存储压缩数据需要使用到的位数是变化的。

每块占用的位数总是128,这是ASTC格式的不同之处,且所有ASTC格式都有4个通道。然而,每个压缩块的纹素个数是变动的。支持4×4、5×4、5×5、6×5、6×6、8×5、8×6、8×8、10×5、10×6、10×8、10×10、12×10和12×12的压缩块尺寸。

ASTC格式的符号名字是VK_FORMAT_ASTC_{N}×{M}_{encoding}_BLOCK格式的,{N}与{M}代表压缩块的宽度和高度,{encoding}是UNORM 或 SRGB(取决于数据是线性的,还是sRGB非线性编码的)。例如,VK_FORMAT_ASTC_8x6_SRGB_BLOCK就是SRGB ASTC压缩格式,包含8×6的压缩块,以及sRGB编码的数据。

对于所有的格式(包括SRGB),只有RGB通道是非线性编码的。A通道总是以线性编码存储数据的。

缓冲区和图像是Vulkan支持的两种主要的资源类型。除了创建这两种资源类型,也可以在已有的资源之上创建视图,以便分割它们,重新解释它们的内容,或者用作其他的目的。缓冲区的视图(表示缓冲区对象的一个子区间)称为缓冲区视图(buffer view)。图像的视图(可以以不同格式展示,或者表示为另外一个图像的子资源)称为图像视图(image view)。

在创建缓冲区或者图像视图之前,需要给父对象绑定内存。

1.缓冲区视图

缓冲区视图用于以一种特定格式来解读某个缓冲区内的数据。因为缓冲区内原生数据被视为连续的纹素,所以这也称为“纹素缓冲区视图”。纹素缓冲区视图可以被着色器直接访问,Vulkan将会自动地把缓冲区内的纹素转换为着色器可用的格式。使用这个功能的一个例子就是在顶点渲染器内通过直接读取纹素缓冲区获取顶点的属性,而不是使用一个顶点缓冲区。虽然有更多的限制,但是确实允许随机访问缓冲区里的数据。

可调用vkCreateBufferView()来创建缓冲区视图,其原型如下。

VkResult vkCreateBufferView (
    VkDevice                              device,
    const VkBufferViewCreateInfo*         pCreateInfo,
    const VkAllocationCallbacks*          pAllocator,
    VkBufferView*                         pView);

device传入将要创建新视图的设备,这个设备应该和创建了缓冲区(该缓冲区正要创建视图)的那个设备一样。新视图的其他参数通过结构体VkBufferViewCreateInfo的一个实例传入,该结构体的定义如下。

typedef struct VkBufferViewCreateInfo {
    VkStructureType            sType;
    const void*                pNext;
    VkBufferViewCreateFlags    flags;
    VkBuffer                   buffer;
    VkFormat                   format;
    VkDeviceSize               offset;
    VkDeviceSize               range;
} VkBufferViewCreateInfo;      

VkBufferViewCreateInfo的字段sType应当设置为VK_STRUCTURE_TYPE_BUFFER_VIEW_CREATE_INFO,pNext应设置为nullptr。字段flags被保留,应设置为0。父缓冲区通过buffer参数指定。新的视图就像是从一个“窗口”去看父缓冲区,从offset 字节开始,范围为range 字节。当作为纹素缓冲区绑定时,缓冲区内的数据就被解释为一系列连续的纹素,格式通过参数format指定。

通过调用vkGetPhysicalDeviceProperties()获取结构体VkPhysicalDeviceLimits,检查它的字段maxTexelBufferElements,确定一个纹素缓冲区能够存储的纹素的最大数量。如果缓冲区将作为纹素缓冲区使用,那么参数range除以format里 1 纹素大小之后的商必须不大于这个上限。因为Vulkan标准保证maxTexelBufferElements至少为65 536,所以,如果你正在创建的视图不超过这个值,就不需要查询这个限制大小了。

在创建父缓冲区时,必须在用于创建缓冲区的结构体VkBufferCreateInfo里的字段usage指定标识位VK_BUFFER_USAGE_UNIFORM_TEXEL_BUFFER_BIT 或者VK_BUFFER_USAGE_STORAGE_TEXEL_BUFFER_BIT。指定的格式必须支持VK_FORMAT_FEATURE_UNIFORM_TEXEL_BUFFER_BIT、VK_FORMAT_FEATURE_STORAGE_TEXEL_BUFFER_BIT或者VK_FORMAT_FEATURE_STORAGE_TEXEL_BUFFER_ATOMIC_BIT,这些标志位是通过vkGetPhysical DeviceFormatProperties()获取的。

成功之后,vkCreateBufferView()把新创建的缓冲区视图的句柄放在pView指向的变量中。如果pAllocator不是nullptr,那么结构体VkAllocationCallbacks里指定的分配回调函数用于为新对象分配任何所需的主机内存。

2.图像视图

在许多情况下,图像资源并不能直接使用,因为需要的信息比它自身包含的信息更多。比如,你不能把图像资源直接作为帧缓冲区的一个附件使用,也不能把图像绑定到一个描述符集,以便在着色器中对它采样。为了满足这些附加的条件,你必须创建图像视图,它实质上就是对父图像资源的引用附加上一系列属性。

图像视图也允许把已存在图像的一部分或者全部看作不同的格式。尽管父图像的阵列层(array layer)或者mip层级的一个子集可能也包含在视图里,但是图像视图必须和父图像有相同的尺寸。父图像和视图的格式也必须兼容,这通常意味着即使它们数据格式完全不同,甚至图像内通道的数量不同,它们的每个像素也都有相同的位数。

可调用vkCreateImageView()在已存在的图像上创建新的视图,原型如下。

VkResult vkCreateImageView (
    VkDevice                             device,
    const VkImageViewCreateInfo*         pCreateInfo,
    const VkAllocationCallbacks*         pAllocator,
    VkImageView*                         pView);

拥有父图像和用于创建新视图的设备通过参数device指定。用来创建新视图的其他参数通过结构体VkImageViewCreateInfo的一个实例来传递,指向这个实例的指针通过pCreateInfo传入。VkImageViewCreateInfo的定义如下。

typedef struct VkImageViewCreateInfo {
    VkStructureType            sType;
    const void*                pNext;
    VkImageViewCreateFlags     flags;
    VkImage                    image;
    VkImageViewType            viewType;
    VkFormat                   format;
    VkComponentMapping         components;
    VkImageSubresourceRange    subresourceRange;
} VkImageViewCreateInfo;

VkImageViewCreateInfo的字段sType应设置为VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO,pNext应设置为nullptr。flags留待以后使用,应设置为0。

需要创建视图的父图像通过参数image指定。需要创建的视图类型通过viewType指定。视图类型必须和父图像类型兼容,而且是VkImageViewType枚举类型的一个成员,数量比创建父图像时的VkImageType枚举要多。图像视图类型的定义如下。

注意,所有的图像基本上都被视为阵列图像,即使它们只有一层。然而,仍然可能为引用图像里的某一层的父图像创建非数组视图。

新视图的格式通过format指定。它必须要和父图像的格式兼容。通常,如果两种格式的每个像素有相同的位数,那么就认为它们是兼容的。如果一种或者两种格式是块状压缩图像格式,那么至少需要符合下面一项。

通过为一张压缩图像创建非压缩视图,就可以访问原生、非压缩的数据,让一些操作变得可能,例如,在着色器里面向图像写入压缩数据,或者在应用程序中直接解释压缩数据。注意,因为所有的块状压缩格式以64位或者128位的质量来对块进行编码,所以不存在非压缩的单通道64位或者128位的图像格式。为了把压缩格式图像当作非压缩格式,需要选择每纹素有相同位数的非压缩格式,然后在着色器里把不同图像通道中的数据位聚合在一起,从压缩数据中提取各个字段。

一个视图中的分量排列顺序可能与父图像不同。例如,这就允许从BGRA图像创建RGBA视图。这种重映射是通过使用VkComponentMapping的实例指定的,其原型如下。

typedef struct VkComponentMapping {
    VkComponentSwizzle    r;
    VkComponentSwizzle    g;
    VkComponentSwizzle    b;
    VkComponentSwizzle    a;
} VkComponentMapping;

VkComponentMapping的每一个成员指定父图像的数据源,该数据源用于填充从子视图获取到的纹素。它们都是枚举VkComponentSwizzle的成员,其成员如下。

子图像可以是父图像的一个子集。这个子集使用内置结构体VkImageSubresourceRange来指定。其定义如下。

typedef struct VkImageSubresourceRange {
    VkImageAspectFlags    aspectMask;
    uint32_t              baseMipLevel;
    uint32_t              levelCount;
    uint32_t              baseArrayLayer;
    uint32_t              layerCount;
} VkImageSubresourceRange;

字段aspectMask是一个位域,由VkImageAspectFlagBits枚举类型的成员组成,指定了哪些层面受屏障影响。即使数据本身有可能是交织在一起的,或者是有关联的,一些图像类型也有多个逻辑部分。一个例子就是深度-模板图像,它既有深度分量也有模板分量。这两个分量都可以当作一幅独立的图像,这些子图像就称为层面。aspectMask可用的标志位如下。

当给父图像创建新视图时,这个视图只能指代父图像的一个层面。也许,这个功能最常用的场景就是给深度-模板格式的图像创建深度视图或者模板视图。

为了创建一个新的图像视图,它只对应父mip链的一个子集,使用baseMipLevel和levelCount字段来指定视图从mip链的哪个位置开始,包含多少个mip层级。如果父图像并没有mipmap,这些字段应该分别设置为0和1。

同样地,为了给父图像的多个阵列层的一个子集创建一个图像视图,使用字段baseArrayLayer和layerCount来分别指定起始层与层数。还有,如果父图像并不是阵列图像,那么baseArrayLayer应该设置为0,layerCount应设置为1。

3.图像阵列

已定义的图像类型(VkImageType)仅包含VK_IMAGE_TYPE_1D、VK_IMAGE_TYPE_2D或VK_IMAGE_TYPE_3D,分别用来创建1D、2D和3D图像。然而,除了xyz 维度有大小之外,所有的图像都有层数,包含在结构体VkImageCreateInfo的字段arrayLayers中。

图像可以聚合为阵列,阵列图像的每一个元素称为一层。阵列图像允许多个图像组合成单个对象,从同一个阵列图像的多层进行采样的效率通常比从多个松散的阵列对象进行采样要高。因为所有的Vulkan图像都有一个字段layerCount,所以从技术上可以认为它们都是阵列图像。然而,在实际中,仅仅将layerCount大于1的图像称作阵列图像。

当从图像创建视图时,会显式地标记视图为阵列或者非阵列。非阵列视图只隐式地包含一层,而阵列视图有多层。从非阵列视图采样的效率比从阵列图像的一层采样要高,因为设备需要执行更少的间接寻址和参数查找。

从概念上讲,1D阵列纹理和2D纹理是不同的,2D阵列纹理和3D纹理也是不同的。主要的区别是,在2D纹理的y方向上和3D纹理的z方向上可以进行线性过滤,而在一个阵列图像的多个层上不能执行过滤。注意,VkImageViewType并不包含3D阵列图像视图类型,而且大多数Vulkan实现不允许创建字段arrayLayers大于1的3D图像。

除了图像阵列之外,立方纹理是一种特殊的图像,它允许把阵列图像的6个层解释为立方体的各个侧面。假想站在立方体形状的房间中心,房间有四面墙,还有地面和天花板。左边和右边是X轴的负方向与正方向,后面和前面分别是Z轴的负方向与正方向,地板和天花板是Y轴的负方向与正方向。这些面经常标记为−X、+X、−Y、+Y、−Z和+Z面。这些就是立方图的6个面,6个连续的阵列层以上述顺序进行解释。

立方图使用3D坐标进行采样。这个坐标为一个从立方纹理的中心指向外的向量,且在立方图中被采样的点就是和立方体的面相交的点。再次把你自己放到立方体形状的房间内并假想你有一支激光笔。当你把激光笔指向一个方向时,在墙上或者天花板上的光斑就是当立方图被采样时的位置。

图2.4 立方图的构造

图2.4 形象地展示了这一点。你可以在图中看到,立方图是由来自父纹理的6个连续的元素构造出来的。为了创建立方纹理视图,首先创建一个2D阵列图像,它至少有6个面。结构体VkImageCreateInfo的字段imageType应设置为VK_IMAGE_TYPE_2D,arrayLayers应至少设置为6。注意,父阵列的层数并不需要一定是6的倍数,只是它至少要有6层。

父图像的结构体VkImageCreateInfo的字段flags必须要有VK_IMAGE_CREATE_CUBE_COMPATIBLE_BIT标志位,而且图像必须是正方形的(因为立方体的面是正方形的)。

下一步,创建2D阵列的视图,但不是创建图像的一个普通的2D(阵列)视图,而是创建一个立方图的视图。为此,用来创建视图的VkImageViewCreateInfo的字段viewType需要设置为VK_IMAGE_VIEW_TYPE_CUBE。在嵌套的字段subresourceRange中,字段baseArrayLayer和layerCount用于确定在阵列中立方图从哪里开始。为了创建单独一个立方体,layerCount应当设置为6。

阵列的第一个元素(字段baseArrayLayer指定的索引位置)就是−X面,接下来的5个层依次变成了+X、−Y、+Y、−Z和+Z面。

立方图也可以组成它们自己的阵列。这很简单,方法是拼接6的倍数的面,6个一组形成一个独立的立方体。为了创建一个立方图阵列图像,设置结构体VkImageViewCreateInfo的字段viewType为VK_IMAGE_VIEW_TYPE_CUBE_ARRAY,设置layerCount为6的倍数。因此,阵列中立方体的个数就是layerCount除以6。父图像的层数必须不少于立方纹理视图引用的层数。

当把数据放到立方图或者立方图阵列图像中,它就是一个阵列图像。每一个阵列层连续地存放在一起,诸如vkCmdCopyBufferToImage()(将在第4章中讲到)之类的命令可以用来向图像写入数据。图像可以作为颜色附件绑定,并进行渲染。如果使用分层渲染,甚至可以在一个绘制命令中向一个立方图的多面写入数据。

当用完缓冲区、图像和其他资源后,彻底地销毁它们是很重要的。在销毁一个资源之前,必须保证没有在使用它,没有待处理的工作会需要访问它。一旦确认了这些,就可以调用适当的销毁函数来销毁资源了。调用vkDestroyBuffer()来销毁缓冲区资源,其原型如下。

void vkDestroyBuffer (
    VkDevice                         device,
    VkBuffer                         buffer,
   const VkAllocationCallbacks*     pAllocator);

拥有该缓冲区对象的设备通过参数device指定,缓冲区对象的句柄通过参数buffer指定。如果使用了主机内存分配器创建缓冲区对象,那么pAllocator 就应该指向兼容的内存分配器;否则,pAllocator应设置为nullptr。

注意,销毁一个缓冲区对象会让其他建立在此缓冲区对象之上的视图失效。视图对象本身也必须显式地销毁,但是,访问一个销毁的视图对象是不合法的。可调用vkDestroyBufferView()来销毁缓冲区视图,其原型如下。

void vkDestroyBufferView (
    VkDevice                             device,
    VkBufferView                         bufferView,
    const VkAllocationCallbacks*         pAllocator);

同理,device就是拥有视图的设备的句柄,bufferView就是需要销毁的视图的句柄。pAllocator应该指向一个主机端的内存分配器,这个分配器要和用来创建视图的那个分配器兼容,或者如果在创建视图时没有使用分配器,pAllocator就应设置为nullptr。

销毁图像的过程和销毁缓冲区相同。可调用vkDestroyImage()函数来销毁一个图像对象,其原型如下。

void vkDestroyImage (
    VkDevice                         device,
    VkImage                          image,
    const VkAllocationCallbacks*     pAllocator);

参数device是拥有待销毁图像的设备,image就是指向该图像的句柄。另外,如果在创建原来的图像时使用了主机内存分配器,那么pAllocator应该指向与那个分配器兼容的分配器;否则,pAllocator应该设置为nullptr。

和缓冲区一样,销毁图像也会让其上的视图失效。访问一个销毁的视图资源是不合法的,对这些视图唯一可以做的事就是销毁它们。可调用vkDestroyImageView()来销毁图像视图,其原型如下。

void vkDestroyImageView (
    VkDevice                         device,
    VkImageView                      imageView,
    const VkAllocationCallbacks*     pAllocator);

你可以猜到,参数device就是拥有需要销毁的视图的设备,参数imageView就是那个视图的句柄。和其他提到的销毁函数一样,pAllocator是一个和创建视图时使用的内存分配器兼容的分配器的指针,或者如果没有使用分配器,就把pAllocator设置为nullptr。

当Vulkan设备操作数据时,数据必须存储在设备内存中。这是GPU设备可以访问的内存。Vulkan系统有4个类别的内存。某些系统或许只有其中的一个子集,有的系统只有两个。给定一个主机(应用程序在其上运行的处理器)和设备(执行Vulkan命令的处理器),它们都有各自的物理存储器。另外,每个处理器附带的物理存储器的某部分区域可以被系统里的另外一个或者两个处理器访问。

某些情况下,共享内存的可见区域可能会相当小。而另外一些情况下,也许只有一块物理存储器,被主机和设备共享。图2.5展示了主机和设备在物理上分离的内存中的映射。

图2.5 主机和设备内存

可以被GPU访问的内存称为设备内存(device memory),即使这些内存以物理方式连接在主机端也如此。在这种情况下,它是主机端的设备内存。这与主机端内存有区别,主机内存又称为系统内存,是可以通过malloc和new操作获取的普通内存。设备内存也可以通过映射被主机访问。

一方面,典型的独立GPU通常是插在PCI-Express插槽中的,它有一定容量的专用内存,是插在电路板上的。这个存储器的一部分只可被设备访问,一部分通过某种窗口形式可以被主机访问。另外,GPU可以访问一些甚至全部的主机系统内存。这些内存池对主机来说就是堆,通过各种内存类型,内存映射进这些堆。

另一方面,典型的嵌入式GPU——比如,那些安装在嵌入式系统、手机,甚至笔记本电脑中的GPU——会与主机处理器共享内存控制器和子系统。这种情况下,很有可能对主系统内存的访问是一致的,而且设备会暴露更少的堆——也许只有一个。可认为这是“统一内存架构”。

设备内存分配用VkDeviceMemory对象来表示,它通过vkAllocateMemory()创建,其原型如下。

VkResult vkAllocateMemory (
    VkDevice                        device,
    const VkMemoryAllocateInfo*     pAllocateInfo,
    const VkAllocationCallbacks*    pAllocator,
    VkDeviceMemory*                 pMemory);

device参数指定了从哪个设备分配内存。pAllocateInfo描述了新分配的内存对象,如果分配成功,pMemory将指向新分配的内存。pAllocateInfo指向结构体VkMemoryAllocateInfo的一个实例,其原型如下。

typedef struct VkMemoryAllocateInfo {
    VkStructureType    sType;
    const void*        pNext;
    VkDeviceSize       allocationSize;
    uint32_t           memoryTypeIndex;
} VkMemoryAllocateInfo;

这个结构体很简单,仅包含用于分配的内存大小和内存类型。除非使用了扩展,并且需要更多的内存分配信息,否则sType应当设置为VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, pNext应当设置为nullptr。allocationSize指定了需要分配的内存的大小,以字节为单位。内存的类型通过memoryTypeIndex指定,这是内存类型数组的索引(可调用vkGetPhysicalDeviceMemory Properties()获取,第1章已介绍过)。

一旦你完成了设备内存分配,它就可以用来存储缓冲区、图像等资源。Vulkan也许会把内存另做他用,比如其他类型的设备对象、内部分配和数据结构体、临时存储。这些分配活动由Vulkan驱动管理,不同Vulkan实现之间的要求差别可能会较大。

当不再使用这些内存时,就需要释放它们。为此,可以调用vkFreeMemory(),其原型如下。

void vkFreeMemory (
    VkDevice                           device,
    VkDeviceMemory                     memory,
    const VkAllocationCallbacks*       pAllocator);

vkFreeMemory()直接从内存里取出内存对象。在释放之前,需要保证在设备上没有队列正在使用该内存。Vulkan不会跟踪内存对象的使用情况。如果设备试图访问已经释放的内存,结果是不可知的,这无疑会导致应用程序崩溃。

进一步来说,对内存的访问必须要在外部保持同步。当一块内存被其他线程的命令访问时,尝试释放它将产生不可知的结果,并且可能导致程序崩溃。

在某些平台上,单个进程内也许有内存分配次数的上限。如果你尝试分配的次数超出限制,分配将会失败。这个上限可以通过调用vkGetPhysicalDeviceProperties()函数并检查返回的结构体VkPhysicalDeviceLimits的字段maxMemoryAllocationCount获知。Vulkan标准保证的最小值是4096,一些平台或许高得多。尽管这个值看起来很小,但是这么做的意图就是让你单次尽量分配大的内存块,然后从这个大的内存块分配小的内存块,在单次分配的内存中,尽量多放入资源。只要内存允许,资源创建的数量是没有上限的。

通常,当从堆中分配内存时,分配到的内存被永久赋予返回的一个VkDeviceMemory对象,直到调用vkFreeMemory()销毁了这个对象。在一些情况下,你(或者甚至是Vulkan实现)并不知道对于某些操作来说需要多少内存,或者是否需要内存。

尤其是,对于渲染时用于存储中间数据的图像来说更是如此。当创建图像时,如果结构体VkImageCreateInfo里面包含VK_IMAGE_USAGE_TRANSIENT_ATTACHMENT_BIT,Vulkan就知道这个图像的数据生存周期很短,因此,有可能根本没有必要写入设备内存。

在这种情况下,可以要求Vulkan在分配内存时使用延迟分配的方式,把真正的分配推迟到Vulkan可以判断出真的需要使用物理存储空间的时候。若有这种需求,需要把内存类型设置为VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT。选择其他的内存类型也是可以让程序正常运行的,但是总是事先就分配好了内存,不管你使用与否。

如果要知道内存是否已经在物理设备上分配了,以及多少备用内存已经为这个内存对象分配好了,可以调用vkGetDeviceMemoryCommitment(),其原型如下。

void vkGetDeviceMemoryCommitment (
    VkDevice                           device,
    VkDeviceMemory                     memory,
    VkDeviceSize*                      pCommittedMemoryInBytes);

拥有内存分配的设备通过参数device传入,需要查询的内存分配通过参数memory传入。pCommittedMemoryInBytes是一个指向某个变量的指针,该变量将会被重写为实际为这个内存对象分配的字节数。这个提交总是来自和内存类型(用于分配内存对象)相关联的堆。

对于那些不包含K_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT的内存对象,或者最终完全提交的内存对象,vkGetDeviceMemoryCommitment()将总是返回整个内存对象的大小。vkGetDeviceMemoryCommitment()返回的提交的大小最多只能做参考用。很多时候,这个信息是过时的,而且你无法修改这个值。

如本章前面所述,设备内存分为几个区域。纯设备内存只能被设备访问。然而,有几个区域是可以同时被主机端和设备端访问的。主机端就是处理器,主应用程序在其上运行。另外,可以让Vulkan返回一个指向从主机可访问区域分配的内存的指针,这叫作内存映射。

为了把设备内存映射到主机的地址空间,需要映射的内存对象必须从堆属性含有VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT标志位的堆中分配。假设需要这么做,映射内存来获取一个主机可用的指针是通过调用vkMapMemory()来实现的,其原型如下。

VkResult vkMapMemory (
    VkDevice                   device,
    VkDeviceMemory             memory,
    VkDeviceSize               offset,
    VkDeviceSize               size,
    VkMemoryMapFlags           flags,
    void**                     ppData);

拥有待映射的内存对象的设备通过参数device传递,内存对象的句柄通过参数memory传递。一定要在外部同步访问这个内存对象。若需要映射一个内存对象的一部分,需要通过参数offset来确定起始位置,通过参数size来指定区域的大小。如果要映射整个内存对象,直接设置offset为0,size为VK_WHOLE_SIZE即可。设置offset为非零值且size为VK_WHOLE_SIZE将会从内存对象的offset位置到结尾做映射。offset、size的单位都是字节。不应当使映射区域超出内存对象的界限。

flags参数是留待以后使用的,当前应当设置为0。

如果vkMapMemory()调用成功,一个指向映射区域的指针就写入ppData。在应用程序中,这个指针可以转换为任意的类型,并且在解引用后可以直接读写设备内存。Vulkan保证,当从vkMapMemory()返回的指针减去offset时,所得数值是设备内存映射的最小对齐值的倍数。

这个值是通过调用vkGetPhysicalDeviceProperties()函数返回的结构体VkPhysicalDeviceLimits的成员minMemoryMapAlignment获取的。它肯定至少是64字节的,但是也可能是大于64的2的幂值。在一些CPU架构中,可以通过让数据对齐和指令对齐得到更高的运行效率。例如,为达到此目的,minMemoryMapAlignment经常和缓存行大小匹配,或者和机器中最大寄存器的自然对齐相匹配。如果传入未对齐的指针,一些主机CPU指令会出错。因此,可以检查minMemoryMap Alignment一次,来决定是否使用优化过的函数(要求传入对齐的地址),或者使用可处理未对齐指针的备用函数(需要承受性能损失)。

当用完映射指针后,需要调用vkUnmapMemory()解除对它的映射,该函数的原型如下。

void vkUnmapMemory (
    VkDevice                 device,
    VkDeviceMemory           memory);

拥有内存对象的设备通过参数device传入,需要解除映射的内存对象通过参数memory传入。和vkMapMemory()一样,对内存对象的访问需要在外部同步。

对于同一个内存对象,不能同时做多次映射。也就是说,不能对一个内存对象多次调用vkMapMemory()来映射不同的内存区域,不管这些内存是否有重叠部分。在取消映射的时候,范围是不需要的,因为Vulkan知道映射的范围有多大。

一旦解除了对于内存对象的映射,任何指向以前通过调用vkMapMemory()获取的指针是无效的,不应当再使用。即使以相同的参数范围映射相同的内存对象,也不能假设会得到相同的指针。

当把设备内存映射到主机内存地址空间时,该内存实际上就有两个客户,他们可能同时对该内存执行写入操作。对于该映射,在主机和设备端都很有可能有缓存层级。这两个地方的缓存有可能一致,也可能不一致。为了保证主机和设备能看到另一个客户写入的数据的一致性视图,就有必要强制Vulkan把缓存中的数据刷新进内存(该缓存可能包括主机写入了但是对于设备还不可见的数据),或者有必要使主机缓存失效(该缓存可能持有已经被设备覆盖的陈旧的数据)。

设备声明支持的每一种内存类型都有一些属性,其中的一个可能是VK_MEMORY_PROPERTY_HOST_COHERENT_BIT。如果是这种情况,并且有一个映射是对带有这种属性值的区域的映射,那么Vulkan就会保证缓存之间的一致性。在某些情况下,缓存之间会自动保持一致,因为它们要么被主机和设备共享,要么有某种形式的一致性协议来保证它们之间的同步。在其他情况下,Vulkan驱动可能有能力推断出什么时候缓存需要刷新进内存或者失效,进而在幕后进行上述操作。

如果一块映射的内存区域的属性没有设置为VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,那么就需要显式地刷新缓存或者使缓存无效。为了刷新可能包含等待写操作的主机缓存,需要调用vkFlushMappedMemoryRanges(),其原型如下。

VkResult vkFlushMappedMemoryRanges (
    VkDevice                              device,
    uint32_t                              memoryRangeCount,
    const VkMappedMemoryRange*            pMemoryRanges);

拥有内存对象的设备是通过device参数指定的。需要刷新的区域大小是通过参数memoryRange Count指定的,每一个范围的信息通过结构体VkMappedMemoryRange的一个实例传入。一个指向拥有memoryRangeCount个元素的数组的指针是通过pMemoryRanges参数传入的。VkMapped MemoryRange的定义如下。

typedef struct VkMappedMemoryRange {
    VkStructureType    sType;
    const void*        pNext;
    VkDeviceMemory     memory;
    VkDeviceSize       offset;
    VkDeviceSize       size;
} VkMappedMemoryRange;

VkMappedMemoryRange 的字段sType应当设置为VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE,pNext应当设置为nullptr。每一个内存范围都引用了字段memory指定的一个被映射的内存对象,以及一个映射区间(通过offset和size指定)。因为不需要刷新那个对象的整个映射区域,所以offset和size不需要与vkMapMemory()的参数匹配。如果没有映射内存对象,或者没有映射offset和size确定的一个对象的区域,那么刷新操作就不会有任何作用。为了刷新一个内存对象的任何映射区,只要把offset设置为0,把size设置为VK_WHOLE_SIZE即可。

如果主机向映射内存区域写入了数据而且需要设备看到写入的效果,刷新是必需的。然而,如果设备写入内存映射区域且需要主机能够看到写入的信息,就需要在主机端主动地使任何缓存无效,因为这些信息可能是陈旧的。需要调用vkInvalidateMappedMemoryRanges(),其原型如下。

VkResult vkInvalidateMappedMemoryRanges (
    VkDevice                                 device,
    uint32_t                                 memoryRangeCount,
    const VkMappedMemoryRange*               pMemoryRanges);

和vkFlushMappedMemoryRanges()一样,参数device就是拥有该内存对象(其映射区域将设置为无效的)的设备。memoryRangeCount指定了区域的数量,元素个数为memoryRangeCount的VkMappedMemoryRange类型数组通过指针pMemoryRanges传递。VkMappedMemoryRange的各个字段的作用和vkFlushMappedMemoryRanges()一样,区别只是要进行的操作是将映射区域设置为无效的,而不是刷新。

vkFlushMappedMemoryRanges()和vkInvalidateMappedMemoryRanges()只会影响到缓存与主机访问的一致性,而不会影响到设备。无论内存映射是否一致,设备对被映射内存的访问都需要使用屏障来保持同步,本书后面部分将讲到屏障。

Vulkan使用诸如缓冲区、图像这样的资源存储数据之前,内存必须绑定给它们。在内存绑定到资源上之前,你应该决定使用什么类型的内存,以及资源需要多少内存。对于缓冲区和纹理来说,有不同的函数。它们分别是vkGetBufferMemoryRequirements()和vkGetImageMemoryRequirements(),它们的原型如下。

void vkGetBufferMemoryRequirements (
    VkDevice                             device,
    VkBuffer                             buffer,
    VkMemoryRequirements*                pMemoryRequirements);

void vkGetImageMemoryRequirements (
    VkDevice                          device,
    VkImage                           image,
    VkMemoryRequirements*             pMemoryRequirements);

这两个函数唯一的不同点是vkGetBufferMemoryRequirements()以一个缓冲区对象的句柄为参数,而vkGetImageMemoryRequirements()以一个图像对象的句柄为参数。两个函数都通过一个VkMemoryRequirements类型的指针pMemoryRequirements来返回内存要求的信息。VkMemory Requirements的定义如下。

typedef struct VkMemoryRequirements {
    VkDeviceSize    size;
    VkDeviceSize    alignment;
    uint32_t        memoryTypeBits;
} VkMemoryRequirements;

资源所需内存量放在字段size中,对象进行内存对齐的信息放在字段alignment中。当你把内存绑定到一个对象(马上会讲解到)时,你需要保证从内存起始位置的偏移量符合资源内存对齐的要求,而且在内存对象里有足够的空间来存储对象。

把字段memoryTypeBits填充成资源所能够绑定的所有内存类型。从最低有效位开始,对于每一种资源可用的类型,都会打开一位。如果你对内存没有特别的要求,只需要找到最低有效位,并且用它的索引来选择内存类型,随后用作传入vkAllocateMemory()的分配信息里的字段memoryTypeIndex。如果你有特别的内存要求或者偏好——比如你想能够映射内存或者希望它位于主机上,你需要找到一种类型,其中包含那些位,并且资源也支持这种类型。

代码清单2.5展示了为一个图像资源选择内存类型的正确算法。

代码清单2.5 为图像选择一种内存类型

uint32_t application::chooseHeapFromFlags(
    const VkMemoryRequirements& memoryRequirements,
    VkMemoryPropertyFlags requiredFlags,
    VkMemoryPropertyFlags preferredFlags)
{
    VkPhysicalDeviceMemoryProperties deviceMemoryProperties;

    vkGetPhysicalDeviceMemoryProperties(m_physicalDevices[0],
                                        &deviceMemoryProperties);

    uint32_t selectedType = ~0u;
    uint32_t memoryType;

    for (memoryType = 0; memoryType < 32; ++memoryType)
    {
        if (memoryRequirements.memoryTypeBits & (1 << memoryType))
        {
            const VkMemoryType& type = 
                deviceMemoryProperties.memoryTypes[memoryType];

            // 如果和我想要的属性完全匹配,就选择这个
            if ((type.propertyFlags & preferredFlags) == preferredFlags)
            {
                selectedType = memoryType;
                break;
            }
        }
    }

    if (selectedType != ~0u)
    {
        for (memoryType = 0; memoryType < 32; ++memoryType)
        {
            if (memoryRequirements.memoryTypeBits & (1 << memoryType))
            {
                const VkMemoryType& type =
                    deviceMemoryProperties.memoryTypes[memoryType];

                // 如果有所有需要的属性,则执行
                if ((type.propertyFlags & requiredFlags) == requiredFlags)
                {
                    selectedType = memoryType;
                    break;
                }
            }
        }
    }

    return selectedType;
}

代码清单 2.5展示的算法针对某个对象的内存需求、一套硬性要求和一套需求偏好选择了一种内存类型。它遍历了设备能够支持的内存类型,并且检查了每一个偏好标识符集合。如果有一个内存类型包含所有偏好的标志位,那么就立即返回这个内存类型。如果设备内存类型中没有一个和偏好标志位匹配,那么再遍历一遍,这次返回满足所有硬性需求的第一个类型。

只要为资源选择了内存类型,就可以把一个内存对象的一部分绑定到资源上,方法是对于缓冲区对象调用vkBindBufferMemory(),对于图像对象调用vkBindImageMemory()。它们的原型如下。

VkResult vkBindBufferMemory (
    VkDevice                          device,
    VkBuffer                          buffer,
    VkDeviceMemory                    memory,
    VkDeviceSize                      memoryOffset);

VkResult vkBindImageMemory (
    VkDevice                          device,
    VkImage                           image,
    VkDeviceMemory                    memory,
    VkDeviceSize                      memoryOffset);

这两个函数基本上是一样的,唯一的不同是vkBindBufferMemory()接受一个VkBuffer类型的句柄,而vkBindImageMemory()接受一个VkImage类型的句柄。这两种情况中,设备必须拥有该资源和内存对象。内存对象的句柄通过参数memory传递,这也是调用vkAllocateMemory()分配的内存的句柄。

通过vkBindBufferMemory()和vkBindImageMemory()分别对缓冲区与图像进行访问,这必须在外部保持同步。一旦内存绑定到一个资源对象上,这个内存绑定就不能再次改变了。如果两个线程尝试同时执行vkBindBufferMemory()或者vkBindImageMemory(),那么哪一个有效和哪一个无效就受制于竞态条件了。即使解决了竞态条件问题,也无法产生一个合法的命令序列,所以这种情况需要避免。

参数memoryOffset指定了在内存对象中资源存在的位置。对象占用的内存量是由对象所需的空间大小决定的,是通过调用vkGetBufferMemoryRequirements()或vkGetImageMemoryRequirements()获悉的。

相对于为每一个资源创建新的内存分配,强烈推荐创建一个有少量大内存分配块的池,并在不同的位置放入多个资源。在内存中重叠两个资源也是可以的。通常情况下,这样不好定义数据的别名,但是如果你可以保证两个资源不会同时使用,这也是应用程序减少内存使用量的好方法。

在本书配套的代码中有一个内存分配器的例子。

稀疏资源是一种特殊的资源,可以在内存中只存储一部分,可以在创建以后更改内存存储,甚至在应用程序使用过以后也能改变。稀疏资源在使用前也必须要绑定到内存,即使这个绑定可以改变。另外,图像或者缓冲区可支持稀疏存储,这样就允许图像的一部分根本不用存储到内存中。

为了创建稀疏图像,可设置结构体VkImageCreateInfo的字段flags为VK_IMAGE_CREATE_SPARSE_BINDING_BIT。同样,为了创建稀疏缓冲区,可设置结构体VkBufferCreateInfo的flags为VK_BUFFER_CREATE_SPARSE_BINDING_BIT。

如果创建的图像带有VK_IMAGE_CREATE_SPARSE_BINDING_BIT标志位,应用程序应该调用vkGetImageSparseMemoryRequirements()来查询图像所需要的附加条件。其原型如下。

void vkGetImageSparseMemoryRequirements (
    VkDevice                               device,
    VkImage                                image,
    uint32_t*                              pSparseMemoryRequirementCount,
    VkSparseImageMemoryRequirements*       pSparseMemoryRequirements);

拥有图像的设备通过device传递,需要查询其限制条件的图像通过参数image传递。参数pSparseMemoryRequirements是一个VkSparseImageMemoryRequirements类型的数组,将填充成该图像的限制条件。

如果pSparseMemoryRequirements为nullptr,那么忽略pSparseMemoryRequirementCount指向的变量的初始值,并把它重写为图像限制条件的个数。如果pSparseMemoryRequirements不为nullptr,那么pSparseMemoryRequirementCount指向的变量的初始值就是数组pSparseMemoryRequirements的元素个数,并被重写为写入数组的真实限制条件的个数。

VkSparseImageMemoryRequirements的定义如下。

typedef struct VkSparseImageMemoryRequirements {
    VkSparseImageFormatProperties    formatProperties;
    uint32_t                         imageMipTailFirstLod;
    VkDeviceSize                     imageMipTailSize;
    VkDeviceSize                     imageMipTailOffset;
    VkDeviceSize                     imageMipTailStride;
} VkSparseImageMemoryRequirements;

VkSparseImageMemoryRequirements的第一个字段是结构体VkSparseImageFormatProperties的一个实例,它提供了一些通用信息,这是根据绑定方式图像在内存中怎么排列的相关信息。

typedef struct VkSparseImageFormatProperties {
    VkImageAspectFlags          aspectMask;
    VkExtent3D                  imageGranularity;
    VkSparseImageFormatFlags    flags;
} VkSparseImageFormatProperties;

VkSparseImageFormatProperties的第一个字段aspectMask是位域,表示属性将要应用到哪个图像层面,通常是图像的所有外观。对于颜色图像,值为VK_IMAGE_ASPECT_COLOR_BIT。对于深度、模板和深度-模板图像,值将是VK_IMAGE_ASPECT_DEPTH_BIT和VK_IMAGE_ASPECT_STENCIL_BIT其中之一,或者两个的组合。

当把内存绑定到稀疏图像上时,它是绑定到多个块上的,而不是一次绑定到整个资源上的。内存必须在特定大小的块中进行绑定,VkSparseImageFormatProperties的字段imageGranularity包含了这个大小。

最后,字段flags包含了一些额外标志位,描述了图像的更多行为。可用的标志位如下。

除非flags中包含了VK_SPARSE_IMAGE_FORMAT_NONSTANDARD_BLOCK_SIZE_BIT,否则imageGranularity中的值和该格式对应的一套标准块尺寸相一致。不同格式的大小(单位是像素)如表2.1所示。

表2.1 稀疏纹理的块尺寸

纹素的大小

2D块形状

3D块形状

8位

256×256

64×32×32

16位

256×128

32×32×32

32位

128×128

32×32×16

64位

128×64

32×16×16

128位

64×64

16×16×16

VkSparseImageMemoryRequirements剩下的字段描述了在mip尾部里,图像使用的格式如何表现。mip尾部是mipmap链的一部分区域,从无法以稀疏方式绑定到内存的第一个层级开始。通常这是比该格式的最小粒度还小的第一层。因为内存必须要以最小粒度来绑定到稀疏资源上,所以mip尾部提供了一个“全有或全无”的绑定机会。只要任何mipmap的尾部绑定到内存,它在尾部的所有层级就都绑定了。

mip尾部从VkSparseImageMemoryRequirements的imageMipTailFirstLod字段指定的层级开始。tail的大小(以字节为单位)存储在imageMipTailSize变量中,它从图像内存绑定区域的imageMipTailOffset指定的位置开始。如果图像对所有阵列层(就像VkSparseImageFormatProperties中的字段aspectMask里面有VK_SPARSE_IMAGE_FORMAT_SINGLE_MIPTAIL_BIT 所指示的那样)都没有mip尾部绑定,那么imageMipTailStride就是每个mip尾部层级的内存绑定的起始位置之间的距离(以字节为单位)。

可调用vkGetPhysicalDeviceSparseImageFormatProperties()来获知特定格式的属性,给定一个格式,会返回一个VkSparseImageFormatProperties类型的数据,用于描述该格式的稀疏图像的限制条件,这样就不用创建一个图像并查询了。vkGetPhysicalDeviceSparseImageFormatProperties()的原型如下。

void vkGetPhysicalDeviceSparseImageFormatProperties (
    VkPhysicalDevice                       physicalDevice,
    VkFormat                               format,
    VkImageType                            type,
    VkSampleCountFlagBits                  samples,
    VkImageUsageFlags                      usage,
    VkImageTiling                          tiling,
    uint32_t*                              pPropertyCount,
    VkSparseImageFormatProperties*         pProperties);

可以看到,vkGetPhysicalDeviceSparseImageFormatProperties()接受的多个参数也是用来构造图像的参数。稀疏图像属性是物理设备的功能,设备的句柄需要通过参数physicalDevice传递。图像的格式通过参数format传递,图像的类型(VK_IMAGE_TYPE_1D、VK_IMAGE_TYPE_2D或者VK_IMAGE_TYPE_3D)通过参数type传递。如果需要多重采样,采样的次数(VkSampleCountFlagBits枚举类型的一个值)通过参数samples传递。

图像的用途通过参数usage传递。这应当是一个位域,其中包含了这种格式的图片如何使用的标志位。注意,也许在某些特定用例里并不支持稀疏图像,所以应当谨慎、准确地设置这个位域,而不是打开每个标志位并期望好的结果。最后,平铺模式通过参数tiling指定。同样,标准的块大小也许只在某些平铺模式下支持。例如,当使用平铺模式时,具体实现不太可能支持标准的(或者甚至是合理的)块尺寸。

和vkGetPhysicalDeviceImageFormatProperties()一样,vkGetPhysicalDeviceSparseImageFormat Properties()可返回属性的一个数组,参数pPropertyCount指向的变量会被格式支持的值所重写。如果pProperties为nullptr,那么pPropertyCount变量的初始值就会被忽略,并会被重写为属性数组的大小。如果pProperties不是nullptr,那么它应该是一个指向VkSparseImageFormatProperties类型数组的指针,用来接受图像的属性。在这种情况下,pPropertyCount的初始值就是数组的元素个数,会重写为数组里实际填充元素的数量。

因为用来存储稀疏图像的内存绑定不能更改,所以即使图像使用完之后,对该图像中绑定属性的更新也需要与那个任务一起放入管线中。和vkBindImageMemory()和vkBindBufferMemory()不同(这两个操作在主机端运行),内存绑定到稀疏资源的操作在队列中执行,允许设备执行它们。绑定内存到稀疏资源的命令是vkQueueBindSparse(),其原型如下。

VkResult vkQueueBindSparse (
    VkQueue                          queue,
    uint32_t                         bindInfoCount,
    const VkBindSparseInfo*          pBindInfo,
    VkFence                          fence);

将执行绑定操作的队列通过参数queue指定。多个绑定操作可以通过调用一次vkQueueBind Sparse()函数完成。操作的次数通过bindInfoCount指定,pBindInfo是指向bindInfoCount大小的VkBindSparseInfo类型的数组,每一个元素都描述了一个绑定。VkBindSparseInfo的定义如下。

typedef struct VkBindSparseInfo {
    VkStructureType                            sType;
    const void*                                pNext;
    uint32_t                                   waitSemaphoreCount;
    const VkSemaphore*                         pWaitSemaphores;
    uint32_t                                   bufferBindCount;
    const VkSparseBufferMemoryBindInfo*        pBufferBinds;
    uint32_t                                   imageOpaqueBindCount;
    const VkSparseImageOpaqueMemoryBindInfo*   pImageOpaqueBinds;
    uint32_t                                   imageBindCount;
    const VkSparseImageMemoryBindInfo*         pImageBinds;
    uint32_t                                   signalSemaphoreCount;
    const VkSemaphore*                         pSignalSemaphores;
} VkBindSparseInfo;

把内存绑定到稀疏资源上实际上表示和其他任务一起放入管线并由设备执行。如第1章所述,任务通过提交到队列得以执行。随着提交到同一个队列的命令的执行,进行绑定。因为vkQueueBindSparse()表现得像一个命令提交,所以VkBindSparseInfo包含的很多字段是和同步相关的。

VkBindSparseInfo的字段sType应设置为VK_STRUCTURE_TYPE_BIND_SPARSE_INFO,pNext应设置为nullptr。和VkSubmitInfo一样,每个稀疏绑定操作在执行之前可以选择性地等待一个或者多个信号量收到信号通知,并等操作完成后向一个或多个信号量发送信号。这允许对稀疏资源的绑定和设备上的其他任务进行同步。

需要等待的信号量的个数由waitSemaphoreCount指定,需要发送的信号量由参数signalSemaphore Count指定。字段pWaitSemaphores是一个指向waitSemaphoreCount个需要等待的信号量的句柄数组的指针,字段pSignalSemaphores是一个指向signalSemaphoreCount个需要发送的信号量的句柄数组的指针。信号量将会在第11章中详细讲解。

每一个绑定操作可以包含对缓冲区和图像的更新。缓冲区绑定更新的数量是通过bufferBind Count指定的,pBufferBinds是一个指向有bufferBindCount个VkSparseBufferMemoryBindInfo类型元素的数组的指针,每一元素都描述一个缓冲区对象绑定操作。VkSparseBufferMemory BindInfo的定义如下。

typedef struct VkSparseBufferMemoryBindInfo {
    VkBuffer                     buffer;
    uint32_t                     bindCount;
    const VkSparseMemoryBind*    pBinds;
} VkSparseBufferMemoryBindInfo;

每一个VkSparseBufferMemoryBindInfo类型的实例包含了将绑定到内存的缓冲区的句柄。内存的多个区域都可以绑定到缓冲区的不同位置。内存区域的大小通过bindCount指定,每一个绑定都通过一个VkSparseMemoryBind类型的数据来描述。VkSparseMemoryBind的定义如下。

typedef struct VkSparseMemoryBind {
    VkDeviceSize               resourceOffset;
    VkDeviceSize               size;
    VkDeviceMemory             memory;
    VkDeviceSize               memoryOffset;
    VkSparseMemoryBindFlags    flags;
} VkSparseMemoryBind;

需要绑定到资源的内存块的尺寸包含在字段size里。在资源或者内存对象里,内存块的偏移量分别存储在resourceOffset和memoryOffset(都是以字节为单位)中。作为绑定的存储源的内存对象通过字段memory指定。当执行绑定后,这个内存块(大小为size,从memory这个对象的memoryOffset位置开始)将会绑定到结构体VkSparseBufferMemoryBindInfo的字段buffer指定的缓冲区。

字段flags包含用来控制绑定过程的额外信息。对于缓冲区资源来说,无须使用标志位。然而,图像资源使用同一个结构体VkSparseMemoryBind来影响直接绑定到图像的内存。这也称为不透明图像内存绑定,这种不透明图像内存绑定也是通过传递结构体VkBindSparseInfo来执行的。VkBindSparseInfo的成员pImageOpaqueBinds指向一个大小为imageOpaqueBindCount的VkSparseImageOpaqueMemoryBindInfo类型的数组,用来定义不透明内存绑定。VkSparseImage OpaqueMemoryBindInfo的定义如下。

typedef struct VkSparseImageOpaqueMemoryBindInfo {
    VkImage                      image;
    uint32_t                     bindCount;
    const VkSparseMemoryBind*    pBinds;
} VkSparseImageOpaqueMemoryBindInfo;

和VkSparseBufferMemoryBindInfo一样,VkSparseImageOpaqueMemoryBindInfo包含了需要绑定内存的图像的句柄,以及一个指向大小为bindCount、类型为VkSparseMemoryBind的数组的指针pBinds。这个和缓冲区内存绑定所用的参数相同。然而,当这个结构体用于图像时,可以在每一个 VkSparseMemoryBind类型对象的字段flags里包含 VK_SPARSE_MEMORY_BIND_METADATA_BIT,以便显式地将内存绑定到和图像相关联的元数据。

当内存以不透明的方式绑定到稀疏图像时,内存的块和图像的纹素没有相互关联。相反,图像的存储被视为内存中一个大的不透明区域,也没有任何关于纹素是如何排列的信息提供给应用程序。然而,只要使用图像时把内存绑定到整个图像,结果仍会是良好定义的,是一致的。这允许稀疏图像可由多个小的内存对象进行存储。例如,这可以简化池分配策略。

为了绑定内存到一个显式的图像区域,可以通过结构体VkBindSparseInfo(需要传递给vkQueueBindSparse())传递一个或者多个结构体VkSparseImageMemoryBindInfo,来执行不透明的图像内存绑定。VkSparseImageMemoryBindInfo的定义如下。

typedef struct VkSparseImageMemoryBindInfo {
    VkImage                           image;
    uint32_t                          bindCount;
    const VkSparseImageMemoryBind*    pBinds;
} VkSparseImageMemoryBindInfo;

同样,VkSparseImageMemoryBindInfo包含了一个字段image,指定了需要绑定内存的图像的句柄,字段bindCount指定执行绑定的次数,字段bindCount是描述绑定信息的数组的指针。然而,这次pBinds指向了大小为bindCount、类型为 VkSparseImageMemoryBind 的数组。VkSparseImage MemoryBind的定义如下。

typedef struct VkSparseImageMemoryBind {
    VkImageSubresource         subresource;
    VkOffset3D                 offset;
    VkExtent3D                 extent;
    VkDeviceMemory             memory;
    VkDeviceSize               memoryOffset;
    VkSparseMemoryBindFlags    flags;
} VkSparseImageMemoryBind;

结构体VkSparseImageMemoryBind包含了更多的信息,该信息是关于内存如何绑定到图像资源的。对于每一次绑定,内存需要绑定的图像子资源通过字段subresource指定,该子资源是VkImageSubresource的一个实例,其定义如下。

typedef struct VkImageSubresource {
    VkImageAspectFlags    aspectMask;
    uint32_t              mipLevel;
    uint32_t              arrayLayer;
} VkImageSubresource;

VkImageSubresource允许你通过aspectMask指定图像的层面(如VK_IMAGE_ASPECT_COLOR_BIT、VK_IMAGE_ASPECT_DEPTH_BIT或者VK_IMAGE_ASPECT_STENCIL_BIT),通过mipLevel指定你想要绑定内存到mipmap指定的层级,arrayLayer来指定内存应该绑定的数组层的位置。对于非阵列图像,arrayLayer应设置为0。

在子资源中,VkSparseImageMemoryBind的字段offset与extend分别定义了要绑定图像的纹素区域的偏移量和大小。这必须对齐到tile尺寸的边界,这个值要么是表2.1所示的标注值,要么是可以从vkGetPhysicalDeviceSparseImageFormatProperties()获取到的各种格式自身的块大小。

同样,提供绑定内存的内存对象通过字段memory指定,内存中的偏移量(即真正的存储所在的位置)通过memoryOffset指定。同样的标志位也可以用在VkSparseImageMemoryBind 的字段flags里。

本章介绍了Vulkan里用到的不同类型的资源,描述了用来存储它们的内存是如何分配的,以及如何关联到它们。本章也讲解了如何通过Vulkan的自定义分配器来管理应用程序内存,如何把资源从一种状态转移到另一种并通过屏障来同步地访问。这让Vulkan的多个管线阶段与主机端能够高效和并行地访问资源。


相关图书

计算机图形学编程(使用OpenGL和C++)(第2版)
计算机图形学编程(使用OpenGL和C++)(第2版)
计算机图形学入门:3D渲染指南
计算机图形学入门:3D渲染指南
从零开始:数字图像处理的编程基础与应用
从零开始:数字图像处理的编程基础与应用
OpenCV图像处理入门与实践
OpenCV图像处理入门与实践
趣味掌控板编程
趣味掌控板编程
OpenGL超级宝典(第7版)
OpenGL超级宝典(第7版)

相关文章

相关课程