这篇文章主要关于 Metal 中的渲染管线 (Rendering Pipeline) 知识点。
硬件基础
首先,我们要知道 GPU 与 CPU 的区别。
GPU (Graphics Processing Unit): 图形处理单元,或者说图形处理器。这个硬件的作用是处理大量的数据。因为采用高度并行的结构,所以能够快速处理像图像、视频这类大型数据。
CPU (Central Processing Unit): 中央处理单元,或者说中央处理器。这个硬件的作用是快速处理有序的数据,数据的处理是一个接着一个的。
CPU 会将指令传递给 GPU。Metal 的策略是在 CPU 上使用指令缓冲区存储多个 CPU 指令,并且为了防止阻塞,CPU 还会为下一帧持续发出指令,而不是等待 GPU 完成当前的任务。
渲染管线
从顶层的视角来看,各种 API 的渲染管线其实没有什么太大的差别。在 Metal 的官方文档中,它将 Metal 的渲染管线总结为应用阶段-顶点阶段-光栅化阶段-片元阶段-像素阶段。从底层视角来看,要实现上面的每一个步骤,我们都需要程序对使用到的抽象概念有具体的控制。
我们可以从头写一个项目,然后进行一整个流程,以了解渲染管线在 Metal 中的实现。新建一个 Multiplatform 的 App。
初始化
MetalView
在 SwiftUI 中,可以通过 import MetalKit 来获取 MTKView。我们需要将 MTKView 包装在一个 UIViewRepresentable (iOS) 或者 NSViewRepresentable (macOS) 中来获取和使用 MTKView。例如,
上面代码中的 MetalViewRepresentable 必须要遵从 ViewRepresentable 协议。在 macOS 上,它需要实现 makeNSView() 以及 updateNSView() 两个函数,而在 iOS 上则需要实现 makeUIView() 和 updateUIView() 两个函数。具体步骤不在本文的讨论范围内,下面给一段参考代码。
然后在 ContentView.swift 中搞一个最简单的窗口。
Renderer 类
其它的 API 需要以某种方式手动实现一帧内的生命周期,或者说游戏循环(game loop)。在 Metal 中,苹果提供了 MetalKit,底层有一些帮我们简化游戏循环实现方式的结构。因此,在 Metal 中我们会使用 MetalKit 配合一个我们自己写的(并且遵从 MTKViewDelegate 协议的) Renderer 类的方式来实现渲染调用。
MTKViewDelegate 定义了与 MTKView 相关的回调方法。通过这个协议,我们可以监听和相应 MTKView 的事件。其中主要有两个方法:
mtkView (_: drawableSizeWillChange: ) : 这个方法在 MTKView 的 drawable 尺寸发生变化时被调用。说人话就是窗口大小变动了。
draw (in: ) :这个方法在每一帧被调用。通常是在这个方法里调用 Metal API 进行渲染。
也就是说,我们需要一个这样的 Renderer。
让 Renderer 继承自 NSObject 主要是苹果的历史遗留问题,很多 UIKit / Cocoa 框架的核心功能依然是基于 Object-C 实现的,因此我们还是让 Renderer 继承 NSObject, 然后通过 extension 让它遵从 MTKViewDelegate。
接下来让 MetalView 上增加一个 @State 变量,令其知道自己的 Renderer 是谁。在窗口初始化时,将 Renderer 中 metalView 设置成当前的 metalView。
单次设置的变量
初始化步骤(Initialization) 的目的是为了的能够获得设备、状态、指令、缓冲区等引用。Metal 相比于其它 API 的一个很好的特性就是在初始化步骤中我们可以预先设置好很多变量,而不是在每一帧中做这些事。
其中,有一些变量我们只需要设置一次(且应该被视作单例):
MTLDevice:GPU 设备的引用。
MTLCommandQueue:CPU 输入指令缓冲区的队列。
MTLLibrary:包含着色器代码的函数库。
有一些变量则根据我们的需求可以设置多个:
MTLBuffer:缓冲区。具体来说,可能装填的内容是顶点的信息等。我们将所谓的顶点数据通过这个载体传递给 GPU 。
MTLRenderPipelineState:渲染状态的具体设置,例如使用什么着色器、深度设置、颜色设置、顶点数据读取规则等。
这些都应该由 Renderer 类负责。
我们先声明三个只需要设置一次的变量。在这里我们都将它们设置成了隐式解包可选类型(implicitly unwrapped optionals),也就是这里的 !。它的作用是可以表示变量是 nil,但是在使用时会自动解包,而不需要每次手动解包(使用 ? 或 ! 访问值)。如果我们将其定义为普通的可选类型(?),在使用时需要显式解包,例如:
或者
Renderer.device?.someMethod()
这样就比较麻烦。如果定义为 !,则可以直接使用
Renderer.device.someMethod()
类似地,我们也可以先给那些可能在渲染过程中会发生变化的对象设置一些变量,
下面,在 init 函数中,调用父类 init 之前,先设置好这些变量的数值。
最后,还可以设置好一个刷屏颜色,用来清空屏幕。
随便设置一个 Mesh
在正式的项目中我们几乎不会使用手动声明的方式来创建 Mesh,一般都是读取某个文件。不过为了方便本文的讨论我们先随便放个盒子在这里。注意在声明 mesh 之后要将其 pass 给 GPU。
设置渲染管线状态
渲染管线状态通常通过渲染管线状态对象(pipeline state object, PSO)来描述。状态包括此时激活的顶点和片元着色器,此时的顶点描述符,像素格式等。
在这里我们假设已经有 vert 和 frag 两个 shader 函数了,我们可以通过如下的设置创建 PSO。
完成创建之后,将 pipelineState 设置为改 PSO 描述的状态。 以上代码在 super.init() 之前,这里设置的是渲染管线的初始状态。
包括上面这里设置的这些状态在内,渲染管线的常用状态有:
指定图形函数和相关数据
顶点函数 (vertexFunction)
片元函数 (fragmentFunction)
最顶层顶点着色器函数的最大函数调用深度 (maxVertexCallStackDepth)
最顶层片元着色器函数的最大函数调用深度 (maxFragmentCallStackDepth)
指定渲染管线状态
颜色数据的附件数组 (colorAttachment)
深度数据的像素格式附件 (depthAttachmentPixelFormat)
模板数据的像素格式附件 (stencilAttachmentPixelFormat)
重置默认状态 (reset)
指定缓冲区布局和获取行为
顶点描述符 (vertexDescriptor)
还有不少可以设置以及获取的数据,详情请查阅 Apple Metal 的文档。
顶点描述符
我们知道顶点数据是以 Buffer 的形式传送给 GPU 的,所以最终它们就是一大串字节。GPU 需要知道如何去理解这一大堆字节,否则这些数据将没有意义。Metal 使用顶点描述符(Vertex Descriptor)来完成这个任务。顶点描述符是用于让GPU 了解你放在 MTLBuffer 中的数据的数据结构。
首先,我们先了解几个与顶点有关的术语:
属性(attributes):例如位置、法线、顶点坐标等。一个顶点可能有多个属性。因此,我们递送给 GPU 的数据可能是类似于
v1 = [position_v1, normal_v1, uv_v1],
v2 = [position_v2, normal_v2, uv_v2],
...
buffer = [v1, v2, ...]
的形式。
布局(layouts):指定一些与顶点有关的例如步长(stride)之类的数据。
Metal 的顶点描述符使用的语法如下。
let vertexDescriptor = MTLVertexDescriptor()
vertexDescriptor.attributes[0].format = .float3
vertexDescriptor.attributes[0].offset = 0
vertexDescriptor.attributes[0].bufferIndex = 0
vertexDescriptor.layouts[0].stride =
MemoryLayout<SIMD3<Float>>.stride
管线状态描述符
渲染
draw 会在每一帧执行,在这个函数中我们来设置要输送给 GPU 的指令。回顾一下,要输送指令,我们需要存储指令的载体 commandBuffer,指令载体的队列 commandQueue (应该已经在初始化中设置好了),
渲染开始于一个绘画指令,也就是 draw command。这个指令需要告知顶点的数量以及绘制图元的类型。例如一个从0号顶点开始按照三角形的方式绘制三个顶点的渲染指令。
[renderEncoder drawPrimitives: MTLPrimitiveTypeTriangle
vertexStart:0
vertexCount:3];
顶点阶段为每个顶点提供数据。当处理完足够的顶点之后,渲染管线就会开始光栅化图元,决定渲染目标上的哪些像素处于图元“内”。然后,在片元阶段中,渲染管线会决定具体的写入这些像素内的颜色值。
渲染管线处理数据的方法
概述
我们知道 Vertex Function (顶点着色器)为每个顶点生成顶点数据,而 Fragment Function(片元着色器)则是为每个片元提供片元数据。但是,这些数据的内容都是我们可以自定义的,这也就是这两个着色器存在的理由。
Metal 文档提及,通常我们有三个地方可以定义我们要传递什么数据。
给渲染管线的输入。这些输入由应用提供,并传递到顶点着色器(也就是应用阶段到顶点阶段的过程)。
顶点阶段的输出。这些输出由顶点着色器提供,转交给片元着色器(严格来说应该是传递到光栅化阶段,因为这里还有插值的步骤)。
片元着色器的输入。虽然顶点阶段输出和片元阶段的输入是同一类型,但实际上并不是同一组数据,因为我们知道在光栅化阶段中 rasterizer 实际上生成了远远多于顶点数量的 fragment function 输入类型,这是由于插值的存在。
例如,给渲染管线的输入(来自于 CPU 端的应用)可能包含的数据有顶点位置数据和颜色。
准备给顶点着色器的数据
例如,顶点位置数据和颜色可以通过使用 SIMD 向量类型,包裹在结构体中。
typedef struct
{
vector_float2 position;
vector_float4 color;
} AAPLVertex;
在 MSL 中, SIMD 类型很常用。SIMD 指的是 Single Instruction, Multiple Data,这些向量类型可以进行并行计算,即单条指令可以同时处理多个数据元素,从而提高运算效率。与普通向量类型相比,它们的区别主要体现在并行计算的性能优化方面。SIMD 向量类型能够在一条指令中并行处理多个数据元素,因此在大量数据处理场景中能够显著提升性能,并且现代的 GPU 和 CPU 通常都有对 SIMD 指令集的硬件支持,可以充分利用硬件加速。
参考资料:
Comments