第1章3D程序分析方法 3D程序通常由两部分组成: 非着色器部分和着色器(shader)部分。非着色器部分用C/C++/JavaScript等编写,运行在CPU上,负责给GPU传送数据,以及设置GPU的参数和状态。非着色器部分也称作CPU部分。着色器部分用着色语言(shading language)描述,运行在GPU上,也称作GPU部分。着色语言有多种实现: 基于OpenGL的OpenGL着色语言,简称GLSL(OpenGL/OpenGL ES/WebGL/Vulkan都支持GLSL); 基于微软公司DirectX的高级着色语言,简称HLSL; 苹果公司的Metal着色语言,简称MSL。本书讨论WebGL/Vulkan使用的GLSL。GLSL按照功能分为多种。图形相关的有: 处理3D模型的顶点着色器(vertex shader)和几何着色器(geometry shader)、处理光照和纹理的片元着色器(fragment shader)。计算相关的有计算着色器(compute shader),计算着色器目前广泛应用于深度学习。最新的Vulkan标准还增加了任务着色器(task shader)、网格着色器(mesh shader),以及用于光线追踪的多种着色器等。 3D程序的非着色器部分,如果从CPU的角度看,它是普通的程序,可以用程序语言C/C++/JavaScript等来描述。但是如果从GPU的角度看,GPU自身就是一个实现了完整渲染流水线的状态机,非着色器部分只是描述了GPU的输入输出,以及一些GPU参数状态的调整控制命令。所以如果仅从算法和流程的复杂度来看,GPU核心的流程和算法,一部分在GPU流水线本身,一部分在用户提供的顶点着色器和片元着色器里,而不是非着色器部分。 从这一点来说,分析一个3D程序,重点应该分析流水线(本书讨论的投影和纹理映射就属于流水线)和着色器(模型的处理、光照的处理),而不是非着色器部分。基于GL(本书用GL来统称OpenGL/OpenGL ES/WebGL)的程序,其非着色器部分调用的GPU相关接口数量少,分析起来比较容易。但是Vulkan的引入,将原来封装在GL底层的一些功能暴露了出来,同时增加了多线程的支持,因而增加了大量的接口。Vulkan程序的非着色器部分,也比相应的GL实现要复杂很多。针对Vulkan的情况,流水线和着色器依然是理解整个3D程序的重点。不过非着色器部分的代码变得复杂了,也需要做些分析。换句话说,用GL编程的时候,理解GPU的行为就可以了。Vulkan则要求开发者在理解GPU的同时增加一些对CPU编程的理解。 本书重点讨论GPU的流水线和着色器部分。但是针对某些复杂的Vulkan场景的CPU部分(非着色器部分),例如涉及多次渲染的时候,CPU部分的结构会影响到对流水线和着色器的理解,因而也会介绍其结构。本书分析这些示例结构的时候不使用流程图、序列图、类图等常用的分析方法,而是使用了通信专业的输入输出分析方法,即给出程序的输入数据→数据处理过程→输出数据框架,以帮助读者理解3D程序的CPU部分。 为什么选择输入数据输出数据来分析Vulkan程序的CPU部分(当然也可以用于GL的分析)?如果是刚接触3D编程,理解GL、Vulkan是有一定难度的。如果有一定的GL经验,希望通过GL的经验分析Vulkan的CPU部分的源代码,也是有些挑战的。这些挑战来自以下两方面。 (1) 与GL实现的接口不一样,Vulkan提供了更多底层资源操作的接口,同时实现了对多线程的支持。因此在接口数量上比GL要多出很多。哪怕是绘制一个最简单的三角形,代码也比同样绘制三角形的GL程序多很多。 (2) 另一方面,即使有了初步的GL基础,但是GL到Vulkan的接口很难一一对应起来,虽然两者的主要功能是一样的。 综上两点,从接口层面去理解Vulkan CPU部分的源码并不直观。然而,虽然GL、Vulkan接口差别很大,但是两者的输入输出都是类似的: 输入是顶点(以及法线光线等)、MVP矩阵、纹理及坐标; 输出则是帧缓冲(或者绑定到帧缓冲的纹理)。数据的处理都是通过绘图渲染来完成的。复杂一些的过程,譬如延迟渲染或者阴影的计算,由于需要两次渲染过程,有些数据是第一个过程的输出,同时作为第二个过程的输入。如果从输入数据、输出数据以及数据的处理过程来理解分析Vulkan,由于将大量的Vulkan接口按照输入、处理过程、输出分为三类,这种分析方法可以简化Vulkan的分析,同时也是一种适用于其他3D编程接口例如GL的分析方法。具体的分析模型如图11所示。图中白色方框是输入,灰色方框是输出,圆角框是数据处理过程,灰色虚线方框表示这个模块在作为一个过程的输入的同时还作为另一个过程的输出。 图113D程序的输入输出模型 对于Vulkan,纹理资源是通过描述符(descriptor)来表示的。如果将着色器也当作一种输入数据,则通常将着色器作为VkPipeline的一部分传递给GPU。在后文讲解Vulkan例子源码结构的时候,会在结构图里面标注描述符和着色器。至于WebGL的例子,因为结构本身就很简单,就没有对其结构做具体分析。 如果Vulkan程序包括多次绘图或者计算(vkCmdDraw*发起绘图,vkCmdDispatch发起计算),而且两个绘图或者计算过程之间还有资源的共享,例如绘图过程1的输出被当作绘图过程2的输入,那么会在结构图里面加一个箭头示意这里会发生资源共享,所以要留意资源读写时的同步与互斥。 本章将从输入输出的角度来分析3D程序的非着色器部分。读者会发现,如果将输入输出模型应用到本书的示例,理解WebGL,以及更加复杂的Vulkan示例,就会简单很多。 本章分析输入顶点数据和纹理时会使用不同的示例。分析顶点数据和MVP数据使用的是输出一个矩形的两个例子: WebGL/projection/projection_perspective_quad.html和Vulkan/examples/projection_perspective_quad。分析纹理使用的两个例子的输出都是纹理图片: WebGL/texturemapping/projection_perspective_texture_mapping.html和Vulkan/examples/projection_perspective_texture。当然,输出纹理的例子仍然需要输入顶点和MVP。 WebGL 1.0基于OpenGL ES 2.0,WebGL 2.0基于OpenGL ES 3.0。除了WebGL之外,基于Vulkan、Metal、Direct3D的下一代Web 3D编程标准WebGPU目前正在讨论之中。本书的例子以Vulkan为主,部分章节还同时提供了WebGL 1.0的例子,主要是为了对比理解3D模型的通用性。选择WebGL的原因是,做简单的模型验证非常方便。但如果是要设计更加复杂的高性能3D程序,读者需要自己去调查了解WebGL是否满足性能要求。选择Vulkan而不是更接近WebGL的OpenGL,则是因为Vulkan接口更复杂,能够很好地体现基于输入输出的分析方法优势。同时,Vulkan是最新的标准,了解其应用很有必要。 1.1输入顶点数据 顶点数据通常在CPU端描述,然后通过缓冲区传递给GPU。最后在GPU流水线开始的时候,绑定相关的缓冲区,流水线就可以在不同的顶点着色器里面访问顶点数据。 1.1.1描述顶点 WebGL和Vulkan通常以三角形为单位进行渲染,所以四边形其实都是由两个三角形组成的。WebGL的例子,仅指定了顶点的位置,颜色是在其他地方指定的,但是可以在指定顶点位置的同时指定顶点颜色。 WebGL示例在代码里面指定四边形四个顶点的颜色,如程序清单11所示。 程序清单11WebGL的顶点数据 // WebGL/projection/projection_perspective_quad.html var scale = 1.0; var zEye = -0.5; var leftAtAnyZ = left*zEye/-near; var rightAtAnyZ = right*zEye/-near; var bottomAtAnyZ = bottom*zEye/-near; var topAtAnyZ = topp*zEye/-near; vertices = [ leftAtAnyZ*scale, bottomAtAnyZ*scale, zEye, rightAtAnyZ*scale, bottomAtAnyZ*scale, zEye, rightAtAnyZ*scale, topAtAnyZ*scale, zEye, leftAtAnyZ*scale, topAtAnyZ*scale, zEye, ]; Vulkan的例子,准备的数据和WebGL的类似,但是同时指定了顶点的位置和颜色,如程序清单12所示。 程序清单12Vulkan顶点和顶点颜色数据 // Vulkan/examples/projection_perspective_quad/projection_perspective_quad.cpp std::vector vertexBuffer = { { {leftAtAnyZ, bottomAtAnyZ, zEye}, { 1.0f, 0.0f, 0.0f } }, { {rightAtAnyZ, bottomAtAnyZ, zEye}, { 0.0f, 1.0f, 0.0f } }, { {rightAtAnyZ, topAtAnyZ, zEye}, { 0.0f, 0.0f, 1.0f } }, { {leftAtAnyZ, topAtAnyZ, zEye}, { 0.0f, 1.0f, 0.0f } } }; 1.1.2传递顶点 所谓传递顶点数据,就是将CPU创建的顶点数据传递到GPU可见的缓冲区。 对于WebGL,顶点数据通过ARRAY_BUFFER上传给GPU,如程序清单13所示。 程序清单13WebGL上传顶点数据 // WebGL/projection/projection_perspective_texture.html cubeVertexPositionBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, cubeVertexPositionBuffer); // 变量vertices里面就是顶点数据 gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW); 对于Vulkan而言,顶点数据是存储到VkBuffer中的。但是VkBuffer本身没有存储空间,需要通过VkDeviceMemory来存储数据。为VkDeviceMemory申请好存储空间之后,将程序清单12 Vulkan顶点和顶点颜色数据用memcpy复制到VkDeviceMemory里面去。本书大部分例子VkDeviceMemory申请的内存是VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT类型,这类内存要先调用vkMapMemory,CPU才能对它进行读写。 具体VulkanDevice::CreateBuffer的实现,如程序清单14所示。 程序清单14创建并复制数据到VkBuffer // Vulkan/base/VulkanDevice.hpp VkResult createBuffer(VkBufferUsageFlags usageFlags, VkMemoryPropertyFlags memoryPropertyFlags, VkDeviceSize size, VkBuffer* buffer, VkDeviceMemory* memory, void* data = nullptr) { // 调用vkCreateBuffer创建VkBuffer VK_CHECK_RESULT( vkCreateBuffer(logicalDevice, &bufferCreateInfo, nullptr, buffer)); // 为VkDeviceMemory申请存储空间 VK_CHECK_RESULT( vkAllocateMemory(logicalDevice, &memAlloc, nullptr, memory)); if (data != nullptr) { void* mapped; // VkDeviceMemory经过vkMapMemory之后,CPU可以直接对其进行读写了 VK_CHECK_RESULT(vkMapMemory(logicalDevice, *memory, 0, size, 0, &mapped)); // 复制数据到VkDeviceMemory memcpy(mapped, data, size); // 结束后vkUnmapMemory vkUnmapMemory(logicalDevice, *memory); } // 数据写到VkDeviceMemory,要将VkDeviceMemory和VkBuffer绑定起来 VK_CHECK_RESULT(vkBindBufferMemory(logicalDevice, *buffer, *memory, 0)); return VK_SUCCESS; } WebGL和Vulkan数据传递接口的主要差别是,Vulkan提供了一个更底层的存储空间管理对象VkDeviceMemory,WebGL则封装了这部分细节。 1.1.3绑定顶点缓冲区 绑定顶点缓冲区是一种GPU命令,所有GPU命令都是通过绘图命令或者提交命令提交给GPU的。对于WebGL,绘图和提交命令都是gl.Draw*(包括gl.drawArrays和gl.drawElements)。对于Vulkan,绘图命令是vkCmdDraw*(包括vkCmdDraw和vkCmdDrawIndexed),提交命令是vkQueueSubmit。注意: CPU里面调用gl.Draw*或者vkCmdDraw*等,仅仅是向GPU描述CPU的绘图意图,但并不等于GPU会立即去解释执行这些绘图命令,通常将CPU调用gl.Draw*和vkCmdDraw*的过程称为录制(record)GPU命令的过程。 WebGL的绑定如程序清单15所示。 程序清单15WebGL绑定数据缓冲区 // WebGL/projection/projection_perspective_texture.html. gl.bindBuffer(gl.ARRAY_BUFFER, cubeVertexPositionBuffer); // vertexPositionAttribute对应到顶点着色器里面的attribute vec3 aVertexPosition; gl.vertexAttribPointer(shaderProgram.vertexPositionAttribute,cubeVertexPositionBuffer.itemSize, gl.FLOAT, false, 0, 0); Vulkan的绑定必须发生在vkBeginCommandBuffer和vkEndCommandBuffer之间,如程序清单16所示。 程序清单16Vulkan绑定顶点数据相关的缓冲区 vkCmdBindVertexBuffers(drawCmdBuffers[i], VERTEX_BUFFER_BIND_ID, 1, &vertexBuffer.buffer, offsets); 1.2输入MVP数据 MVP数据分别是模型(model)、视图(view)、投影(projection)矩阵。 WebGL可通过第三方库glMatrixglMatrix项目,http://glmatrix.net/。创建变换矩阵,接口是mat4.create()。glMatrix提供了丰富的接口,用来实现各种变换。例如mat4.translate、mat4.rotate、mat4.scale可以用来实现模型视图矩阵的位移、旋转、缩放变换。投影矩阵也可以通过mat4.create来创建,但更简便的方式则是通过mat4.perspective创建透视投影矩阵,mat4.ortho创建正交投影矩阵。矩阵创建好了之后,可以通过gl.uniformMatrix4fv将矩阵数据传递给着色器进行下一步的处理。 Vulkan可以使用封装了模型视图变换、透视投影、正交投影等的第三方库glmOpenGL数学库,https://glm.gtruc.net/。。其中,glm::mat4用于创建模型视图矩阵,glm::translate、glm::rotate、glm::scale则用来实现具体的模型视图变换。创建透视投影矩阵的glm::perspective和创建正交投影矩阵的glm::ortho则将创建矩阵和变换的过程封装到了一个接口。 Vulkan的MVP数据可以通过vkCmdPushConstants在录制GPU命令的时候直接传递。Push Constants是Vulkan提出的一种快速地向GPU提交小规模数据的方法。也可以像顶点索引数据一样,将MVP数据传递给VkBuffer。不过和顶点索引数据不同的是,顶点索引数据传递给VkBuffer之后,可以直接在录制的时候通过vkCmdBindVertexBuffers来绑定。VkBuffer还可以用来存储其他数据,例如MVP数据。但是除了顶点和索引数据之外,存储其他数据的VkBuffer需要先生成一个描述符,例如VkDescriptorBufferInfo,并通过vkUpdateDescriptorSets将这个描述符追加到VkDescriptorSet里面。最后还是在录制的时候,通过vkCmdBindDescriptorSets告诉GPU本次绘图过程会用到这个缓冲区。 所以对于存储到VkBuffer的MVP数据,其使用过程分为以下三步。 (1) 复制数据到VkBuffer。 (2) 为VkBuffer创建一个VkDescriptorBufferInfo,通过vkUpdateDescriptorSets将其追加到VkDescriptorSet。 (3) 在录制GPU命令的过程中,调用vkCmdBindDescriptorSets来告知GPU本次将使用的资源。 1.3输 入 纹 理 纹理可以用来存储从存储设备或者网络读取的图片,也可以绑定到输出缓冲区之后,用作3D过程的输出。这里讨论作为输入的图片纹理。用户将图片纹理传递给GPU可见的缓冲区,GPU则通过采样器从这些缓冲区读取纹理的数据。 纹理包含图像数据,采样器包含影响纹理的采样过程的状态和控制信息,例如纹理的滤波模式(filter mode)、纹理坐标的环绕模式(wrap mode)等都受采样器的影响。纹理和采样器在不同的GPU、不同的GL版本,以及Vulkan上的实现可能是有差别的。 在GPU里面,采样器的状态和纹理数据是分开的,两者不相关。同一个纹理,在一种情形下可以通过VK_FILTER_LINEAR(GL_LINEAR)来采样,也可以在另一种情形下使用VK_FILTER_NEAREST(GL_NEAREST)来采样。 OpenGL 3.2之前是不区分纹理和采样器的,WebGL也是如此。在这些版本的GL实现里面,纹理对象同时包含采样器的状态和控制信息。所以如果使用这些版本的GL创建一个纹理,调用glGenTextures就可以了。要配置纹理(其实是采样器)相关的滤波模式和环绕模式则需要调用glTexParameter*系列函数。OpenGL 3.2开始引入glGenSamplers以解耦合纹理和采样器。Vulkan里面,vkCreateImage用于纹理的创建,vkCreateSampler用于创建采样器,滤波模式和环绕模式是针对采样器的。本节分析的输入纹理将不包含采样器部分。 1.3.1创建并传递纹理 WebGL使用纹理是异步的,如程序清单17所示。 程序清单17WebGL纹理创建 // WebGL/texturemapping/projection_perspective_texture_mapping.html neheTexture = gl.createTexture(); neheTexture.image = new Image(); neheTexture.image.onload = function () { handleLoadedTexture(neheTexture) } neheTexture.image.src = "/resources/gorilla.png"; 纹理创建好,并且图片数据加载完毕后,回调handleLoadedTexture来进行纹理的绑定,如程序清单18所示。 程序清单18WebGL纹理绑定 // WebGL/texturemapping/projection_perspective_texture_mapping.html gl.bindTexture(gl.TEXTURE_2D, texture); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, texture.image); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.bindTexture(gl.TEXTURE_2D, null); Vulkan对纹理提供了多种抽象,如VkImage、VkImageView、VkSampler等。一种直接使用纹理的方式是: 从磁盘读取图片数据,并存储到GPU可见的存储空间,即复制到VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT(以下简称HOST_VISIBLE)类型的存储空间中,然后GPU直接对这块存储空间进行采样。 由于硬件和驱动设计的不同,HOST_VISIBLE类型的存储空间可能位于CPU的内存之中,因而对于独立显卡而言, HOST_VISIBLE不是最优的直接给GPU输入纹理数据的方式。所以本书Vulkan的例子还提供了staging缓冲区的纹理加载方式。 通过Staging 缓冲区加载资源是为了屏蔽CPU和GPU访问内存和显存的差异。这个差异来自两个方面: (1) 集成显卡的显存和内存是共享的,独立显卡使用自己的独立显存。 (2) 内存通过内存控制器直接接入SoC(System on Chip)环形总线; 独立显卡和独立显存则通过PCIe总线间接接入环形总线。 针对集成显卡和独立显卡,这些差异导致的结果是: (1) 对于集成显卡,CPU、集成显卡从内存读取数据和显存读取数据都很快。 (2) 对于独立显卡,独立显卡访问独立显存很快,访问内存很慢。CPU访问内存很快,访问独立显存很慢。 总线、CPU、显卡、内存和显存之间的关系如图12所示(DRAM是内存,VRAM是独立显存,Intel HD GPU是集成显卡,NV/AMD GPU是独立显卡)。 图12计算机系统的总线和存储结构 无论是集成显卡还是独立显卡,都可以直接访问内存的纹理数据。所以这里存在两种数据访问方式。 (1) 非Staging访问: GPU直接访问内存的纹理数据。集成显卡可以使用这个方式,独立显卡要避免GPU直接访问内存。 (2) Staging访问: 先将内存的纹理数据复制到显存,GPU直接从显存里面读取数据。独立显卡应该使用这个方式。 对于Vulkan,Staging访问的方法是: 先将纹理数据读入一个HOST_VISIBLE的内存空间,然后将这个HOST_VISIBLE的内存复制到DEVICE_LOCAL的显存中,如图13和图14所示。 图13独立显卡的Staging方式 图14集成显卡的Staging方式 相应地,非Staging访问将纹理数据读入一个HOST_VISIBLE的内存空间,剩下的交给GPU完成。 这两种方式都实现在同一个loadTexture接口中。如图15所示为Staging缓冲器优化的纹理加载方式。步骤如下。 图15Staging缓冲器优化的纹理加载方式 (1) 通过第三方库gli::load将textures/gorilla.ktx加载到CPU内存中,如程序清单19所示。 程序清单19从硬盘读取纹理 gli::texture2d tex2D(gli::load(filename)); (2) 创建一个VkBuffer以及相应的VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT类型的VkDeviceMemory,这个VkDeviceMemory通过映射(map)后CPU可见。于是可以将程序清单19读进内存的CPU纹理数据复制到VkDeviceMemory。这个过程和传递顶点数据是一样的。 (3) VkBuffer里面的纹理数据无法给GPU直接读取采样,需要通过vkCmdCopyBufferToImage复制给一个VkImage。该VkImage申请的存储空间类型是VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT,位于GPU显存里面。 (4) GPU读取数据的时候,需要VkImageView/VkSampler等辅助对象才能从VkImage里面读取数据。 1.3.2绑定纹理 WebGL通过程序清单110来绑定纹理。 程序清单110WebGL绑定纹理 // WebGL/texturemapping/projection_perspective_texture_mapping.html gl.bindBuffer(gl.ARRAY_BUFFER, cubeVertexPositionBuffer); gl.vertexAttribPointer(shaderProgram.vertexPositionAttribute, cubeVertexPositionBuffer.itemSize, gl.FLOAT, false, 0, 0); Vulkan并不直接绑定纹理。每个纹理会创建相应的描述符VkDescriptorBufferInfo,并追加到VkDescriptorSet。最后在录制GPU命令的时候,调用vkCmdBindDescriptorSets绑定VkDescriptorSet里面所有的描述符。 1.4输出帧缓冲 3D程序通常都有自己的输出,这个输出叫作帧缓冲(frame buffer)。 本书WebGL的例子,其输出帧缓冲都封装在浏览器引擎的Canvas元素里面。 Vulkan提供了一个VkFramebuffer的帧缓冲对象,它的实现比WebGL/OpenGL复杂,它还和VkRenderPass等概念有关。但是对于本书而言,除非有特殊说明,读者仅需要理解VkFramebuffer及其绑定的VkImage会被用来作为Vulkan程序的输出。 1.5数据处理过程 数据处理过程其实就是3D的流水线,是封装在GPU及其相关驱动里面的。不过由于GPU的绘图命令,例如gl.Draw*(WebGL)和vkCmdDraw*(Vulkan)会直接或者间接地触发GPU流水线开始工作,因此可以认为绘图命令封装了数据处理过程。 1.6TensorFlow JS的输入输出 本节用输入输出模型来分析TensorFlow JS的工作原理。 TensorFlow JS是开源机器学习软件库TensorFlow的JavaScript实现,擅长各种感知和语言理解等任务。它支持用GPU的片元着色器来实现硬件加速。 用户以Tensor的形式输入数据。输入的数据被当作GPU的纹理传递给GPU。GPU对纹理数据进行运算后,将结果写入输出的纹理。由于输出的纹理是存在GPU里面的,CPU需要用读回操作(gl.readPixels)将数据输出到输出Tensor里面。和本书要讨论的其他GPU处理模型不同的是,TensorFlow JS的目标是为了计算,所以TensorFlow JS没有将结果输出到一个具体的窗口。其输入输出模型如图16所示。 图16TensorFlow的输入输出模型 测试的例子,如程序清单111所示。 程序清单111TensorFlow JS实现的多个数求乘积 // 输入数据Tensor 1,通过gl.texSubImage2D/gl.texImage2D上传给GPU const xs = tf.tensor2d([-1, 0, 1, 2, 3, 4,-1, 0, 1, 2, 3, 4,-1, 0, 1, 2, 3, 4,-1, 0, 1, 2, 3, 4,-1, 0, 1, 2, 3, 4,-1, 0, 1, 2, 3, 4],[36, 1]); // 输入数据Tensor 2,同样通过gl.texSubImage2D/gl.texImage2D上传给GPU const ys = tf.tensor2d([-3, -1, 1, 3, 5, 7,-3, -1, 1, 3, 5, 7,-3, -1, 1, 3, 5, 7,-3, -1, 1, 3, 5, 7,-3, -1, 1, 3, 5, 7,-3, -1, 1, 3, 5, 7], [36, 1]); // 数据处理过程,会调用gl.Draw*,最后通过片元着色器执行乘法运算 const sum = ys.mul(xs); // 通过gl.readPixels从当前帧缓冲里面读出运算结果 sum.print(); 1.7Vulkan的输入输出 前面通过输入输出方法分析了GL和Vulkan的差异,本节介绍Vulkan一些主要的输入输出对象。Vulkan在描述输入输出对象的时候,在数据对象之外还抽象出了视图对象、布局对象。视图对象比较直观,它描述了数据对象的范围和格式等信息。布局对象则用于描述当前流水线会使用哪些数据、数据的类型和数目等,所以布局对象描述的其实是流水线的视图。根据Vulkan规范,布局对象(VkDescriptorSetLayout)用于描述流水线使用 VkBuffer和VkImage等数据资源的情况。本节在这个基础上拓宽了布局对象的概念,将描述顶点绑定的VkVertexInputBindingDescription和VkVertexInputAttributeDescription、描述Push Constant使用情况的VkPushConstantRange和输出帧缓冲使用情况的VkRenderPass都当作布局对象。 根据输入输出模型,Vulkan主要提供了三类对象: 输入数据对象以及相关的布局对象; 输出帧缓冲对象以及相关的布局对象; GPU命令相关的对象。 1. 输入数据对象及其相关的布局对象 输入对象主要用来描述和管理顶点纹理坐标、顶点索引、MVP、纹理等。相关的布局对象则描述了流水线使用这些对象的情况。 (1) 描述输入数据的实际存储资源。譬如VkDeviceMemory、VkBuffer、VkImage,用于描述输入存储资源。有些小规模的数据,可以通过VkBuffer传递给GPU,也可以存储到std::array,然后通过vkCmdPushConstants传递给GPU。 (2) 描述输入数据的布局。例如VkVertexInputBindingDescription、VkDescriptorSet Layout、VkPushConstantRange用于描述输入数据的布局。所有输入布局相关的信息,都被聚合到流水线对象VkPipeline里面。VkPipeline里面包含所有输入数据的布局信息,这个布局信息本身也需要通过vkCmdBindPipeline来绑定。 2. 输出帧缓冲对象及相关的布局对象 输出对象主要用来描述输出帧缓冲及流水线的输出布局情况。 (1) VkImage、VkFrameBuffer等用于描述输出帧缓冲的实际存储资源。 (2) VkRenderPass用于描述帧缓冲的布局。这里的布局信息,指的是当前绘图过程会使用VkFrameBuffer的哪些资源。 3. GPU命令相关的对象VkCommandBuffer 输入数据对象以及相关的布局对象、输出帧缓冲对象以及相关的布局对象,要经过GPU命令进行绑定后,才能参与绘图过程。和绑定对象相关的GPU命令主要有以下几个。 (1) 输入数据绑定命令: vkCmdBindVertexBuffers、vkCmdBindIndexBuffer、vkCmdBindDescriptorSets、vkCmdPushConstants。 (2) 输出帧缓冲绑定命令: vkCmdBeginRenderPass、vkCmdEndRenderPass。 (3) 流水线VkPipeline绑定命令: vkCmdBindPipeline; VkPipeline里面包含所有输入数据的布局信息。 GPU命令管理对象VkCommandBuffer通过vkBeginCommandBuffer、vkEndCommandBuffer来管理命令的开始和结束。 综合输入数据对象和相关的布局对象、输出帧缓冲对象和相关的布局对象,以及这些对象的绑定命令和着色器的访问方式,得到表11。 表11数据、布局、绑定命令和着色器访问(MVP 1: VkBuffer; MVP 2: Push Constant) 数 据 对 象布 局 对 象绑 定 命 令着色器访问 坐标VkBufferVkVertexInputBinding DescriptionvkCmdBindVertexBufferslayout (location = 0) in vec3 inPos; layout (location = 1) in vec2 inUV; layout (location = 2) in vec3 inNormal; 索引VkBufferVkVertexInputBinding DescriptionvkCmdBindIndexBufferglVertexID 纹理VkImageVkDescriptorSetLayoutvkCmdBindDescriptorSetslayout(binding = 1) uniform sampler2D samplerColor; MVP 1VkBufferVkDescriptorSetLayoutvkCmdBindDescriptorSetslayout (binding = 0) uniform UBO { mat4 projection; mat4 model; vec4 viewPos; } ubo; MVP 2std::arrayVkPushConstantRangevkCmdPushConstantslayout(pushconstant) uniform PushConsts{ layout (offset = 0) mat4 mvp; } pushConsts; 帧缓冲VkFrameBuffer VkImageVkRenderPassvkCmdBeginRenderPass vkCmdEndRenderPasslayout (location = 0) out vec4 outFragColor; 1.8GL和Vulkan的线程模型 从CPU、GPU硬件的角度看,CPU硬件和GPU硬件都是多线程的。GPU硬件的多线程指的是GPU能够通过多个执行单元来同时运行若干个着色器程序,但是所有这些GPU线程,都服务于同一个绘图任务(每次调用vkCmdDraw*对应一个绘图任务)。GPU在同一时刻只能处理一个绘图请求,所以GPU硬件是单任务的。 相对于GL的单线程,Vulkan的多线程,本质上是CPU的多线程,而不是GPU的多线程。所谓CPU的多线程,是指可以同时有多个CPU线程在录制多个绘图任务的GPU命令。但是,同一时刻的GPU流水线上面,只有一个绘图任务在执行。这意味着GL和Vulkan在GPU部分的执行模型并没有根本不同,都是单任务的。不同的是CPU部分: GL只支持同一个时刻仅有一个线程录制GPU命令,而且录制命令和绘图任务提交命令必须位于同一个线程。Vulkan则灵活了很多,Vulkan支持多个线程同时录制GPU命令。Vulkan命令的提交,也可以在其他的线程执行。 考虑一个场景里面的N个物体,每个物体有不同的顶点坐标、MVP矩阵、纹理,分别通过GL和Vulkan来绘制。 对GL而言,只能在一个用户线程里面,按照特定的顺序,逐个录制这些物体的绘制命令并提交给GPU(调用glDraw*)。GPU线程按照用户提交绘图任务的顺序,逐个处理每个物体的绘图请求,如图17所示。注意图中的用户线程和GPU线程里面的命令,都是按从上到下、从左到右顺序执行的。其中,用户线程在CPU上运行,GPU线程在GPU上运行。 图17GL的线程模型(*表示有多个同类命令) 对于Vulkan而言,可以为每个物体创建一个线程来录制物体的绘制命令,每个线程录制好的绘图命令存储在VkCommandBuffer里面。在提交绘图任务(和GL不同,Vulkan的vkCmdDraw*并不负责提交绘图任务,绘图任务的提交是通过某个线程调用另一个命令vkQueueSubmit实现的)的时候,可以一次将多个绘图任务(即多个VkCommandBuffer)提交给GPU线程。GPU线程按照用户绘图任务的顺序,逐个处理每个物体的绘图请求,如图18所示。注意图中的多个用户线程是并行执行的,用户线程里面里面的命令,都是按从上到下顺序执行的。其中,N个用户线程在CPU上运行,GPU线程在GPU上运行。 图18Vulkan的线程模型(*表示有多个同类命令) 针对OpenGL实现的应用,如果CPU占用的计算时间比GPU多,Vulkan多线程可以提升性能,如图19所示。 图19Vulkan可以提升性能 对于CPU占用时间比GPU短的情况,Vulkan可以降低功耗,如图110所示。 图110Vulkan可以降低功耗 1.9源码下载和编译 本书示例的Vulkan源代码https://github.com/math3d/Vulkan/tree/projection_perspective,是基于开源示例程序https://github.com/SaschaWillems/Vulkan修改而来。可以运行在Ubuntu和Windows环境。 Vulkan源码的获得: $git clone https://github.com/math3d/Vulkan.git $git submodule init $git submodule update Vulkan源码的编译Ubuntu 18.04: $cmake CMakeLists.txt $make Vulkan源码的编译Windows 10: $cmake -G "Visual Studio 15 2017 Win64" 用Visual Studio打开项目vulkanExamples.sln,就可以编译了。 本书示例的WebGL源代码,根目录位于https://github.com/math3d/WebGL。WebGL源码可以运行在主流的Web服务器上面。 小结 虽然GL(WebGL)难以和Vulkan的接口一一映射起来,但是两者的输入数据、输出数据等,却是非常类似的。给定一个3D场景,在大多数情况下,用GL或者Vulkan都可以实现对该场景的渲染。而从GL或者Vulkan对输入数据的描述来看,两者在接口方面有很多是类似的,总结如表12所示。从这个角度来说,基于输入数据输出数据的3D程序分析方法有助于理解3D程序的数据模型。当然,这个方法主要用于程序分析,实际编程的时候还是需要查阅具体标准理解每个接口的含义。 表12WebGL Vulkan数据操作接口对比 数 据 类 型准 备 数 据更新描述符 (仅Vulkan)录制命令时绑定 Vertex/ Index WebGLgl.createBuffer gl.bindBuffer gl.bufferDatagl.bindBuffer gl.vertexAttribPointer VulkanvkCreateBuffer vkAllocateMemory vkMapMemory vkBindBufferMemory vkUnmapMemoryvkCmdBindVertexBuffers vkCmdBindIndexBuffer MVP数据 WebGLgl.uniformMatrix4fv VulkanvkCreateBuffer vkAllocateMemory vkMapMemory memcpy vkUnmapMemoryvkUpdateDescriptorSetsvkCmdBindDescriptorSets 纹理数据 WebGLgl.createTexture gl.bindTexture gl.texImage2Dgl.activeTexture gl.bindTexture VulkanvkCreateImage vkAllocateMemory vkBindImageMemory vkMapMemory memcpy vkUnmapMemory vkCreateSampler vkCreateImageViewvkUpdateDescriptorSetsvkCmdBindDescriptorSets 本章的例子,都是由一次绘制过程完成的。实际上,为了达到更好的性能,如延迟渲染,实现某些特殊的效果(例如阴影),系统中可能使用了多次绘制。无论绘制多少次,都可以使用输入输出的分析方法。 本书重在分析3D编程的3D几何模型,方法是分析给定的输入数据,经过了什么样的流程得到输出数据。因此虽然本书使用的Vulkan例子源码冗长,但是如果使用输入数据输出数据的分析方法,背后的逻辑会简单很多。有了这个办法,读者就没必要担心数量庞大的Vulkan的编程接口了。当然,具体到每个Vulkan接口,读者还是要去查阅工具书理解每个接口背后的具体含义。 第2章3D图形学基础 本章介绍几何和图形编程的一些基本概念。 2.1符号和约定 本书中涉及较多的数学公式。对于数学公式中的符号,以及正文对公式中符号的引用,约定如下。 标量用小写字母斜体表示: a,b。其中,坐标轴和坐标分量用x, y, z, u, v, w等表示。 向量用小写字母粗斜体表示: a, b。 几何意义上的点用大写字母斜体表示: A, B。 矩阵用大写字母粗体表示: M。 在本书的部分章节,为了排版的需要,有时候不会刻意去区分整数和浮点数,例如1和1.0、0和0.0在本书都被当作是同一个数。 2.2向量的基本运算 向量的模如下。 向量a(x,y,z)的模是向量的大小,表示为: |a|=x2+y2+z2 向量a的单位化向量a′为: a′=a|a| 向量的点乘a·b为: a·b=|a||b|cosθ 点乘的几何意义是,如果b是单位向量的话,a·b得到的就是向量a在向量b上的投影的长度,如图21所示。 向量加法a+b,如图22所示。 向量减法a-b,如图23所示。 在任意两个点坐标A和B之间做减法,可以用来确定两个点之间的向量,即A-B得到的是B指向A的向量。 图21点乘 图22向量加法 三维空间中的两个向量a和b相乘,叫作叉乘a×b。叉乘具有下面的性质。 (1) a,b,a×b的方向遵守右手法则。右手法则如图24所示,a指向大拇指,b指向食指,a×b指向中指。 图23向量减法 图24叉乘右手法则 (2) a×b的模的长度,等于以a,b为边的平行四边形的面积。 a×b=|a||b|sinθn,其中,θ代表了a,b在平面上的夹角,且θ∈[0°,180°]。 2.3齐 次 坐 标 德国数学家August Ferdinand Mobius提出的齐次坐标在透视投影中使用得非常普遍。齐次坐标是在原来笛卡儿坐标的维度上增加一个维度的坐标表达方式。如笛卡儿坐标(x′,y′,z′)和齐次坐标(x,y,z,w)之间的关系如公式21所示。 x y z=x′w y′w z′w 公式21笛卡儿坐标到齐次坐标 当w非0的时候,(x,y,z,w)是一个点; 当w为0的时候,(x,y,z,0)是一个无穷远的点。我们更常用这个无穷远的点来表示具有大小和方向的向量(向量没有位置的概念,所以可以在空间里面平移),因而有了w分量就可以用一个齐次坐标表示两种不同的量。 齐次坐标翻译自“homogeneous coordinates”。homogeneous的英文解释是“同一种的、类似的”。中文对“齐次”的解释是“次数相等的意思”。此处英文解释更贴近齐次坐标