这篇文章主要关于 Metal 中的渲染管线 (Rendering Pipeline) 知识点。
硬件基础
首先,我们要知道 GPU 与 CPU 的区别。
GPU (Graphics Processing Unit): 图形处理单元,或者说图形处理器。这个硬件的作用是处理大量的数据。因为采用高度并行的结构,所以能够快速处理像图像、视频这类大型数据。
CPU (Central Processing Unit): 中央处理单元,或者说中央处理器。这个硬件的作用是快速处理有序的数据,数据的处理是一个接着一个的。
CPU 会将指令传递给 GPU。Metal 的策略是在 CPU 上使用指令缓冲区存储多个 CPU 指令,并且为了防止阻塞,CPU 还会为下一帧持续发出指令,而不是等待 GPU 完成当前的任务。
渲染管线
从顶层的视角来看,各种 API 的渲染管线其实没有什么太大的差别。对于 Metal 而言,我们依然可以按照应用阶段-顶点阶段-图元装配-像素阶段-映射阶段的步骤来总结。
应用阶段:主要就是从 CPU 中抓取需要的顶点,剔除不需要渲染的顶点。
顶点阶段:在 GPU 的顶点着色器中处理 CPU 输送过来的顶点、计算顶点在 NDC 中的位置。
图元装配:将顶点装配到三角形中,输送到光栅化阶段。
像素阶段:进行光栅化,计算像素覆盖情况以及像素颜色,对三角形内部做颜色插值。
映射阶段:将输出结果存入帧缓存。
在 Metal 的官方文档中,它将 Metal 的渲染管线总结为应用阶段-顶点阶段-光栅化阶段-片元阶段-像素阶段。从底层视角来看,要实现上面的每一个步骤,我们都需要程序对使用到的抽象概念有具体的控制。
例如:
CPU 输送到 GPU : 输送到哪个 GPU ?我们需要知道 GPU 设备的引用。
CPU 输送渲染指令:输送什么样的指令?指令的内容是什么?
存入帧缓存:帧缓存在哪里?怎么存入?
初始化
为了具体地实现这些内容,Metal 给了我们如下的初始化设置。
MTLDevice:GPU 设备的引用。
MTLCommandBuffer:CPU 输入的指令的载体。
MTLCommandQueue:CPU 输入指令缓冲区的队列。
MTLLibrary:包含具体的顶点和片元着色器。
MTLBuffer:数据的载体。具体来说,可能装填的内容是顶点的信息等。我们将所谓的顶点数据通过这个载体传递给 GPU 。
MTLRenderPipelineState:渲染的具体设置,例如使用什么着色器、深度设置、颜色设置、顶点数据读取规则等。
在 Renderer 所在的类中,我们可以增加如下变量来获得对这些功能的控制。
static var device: MTLDevice!
static var commandQueue: MTLCommandQueue!
static var library: MTLLibrary!
var vertexBuffer: MTLBuffer!
var pipelineState: MTLRenderPipelineState!
顶点描述符
我们知道顶点数据是以 Buffer 的形式传送给 GPU 的,所以最终它们就是一大串字节。GPU 需要知道如何去理解这一大堆字节,否则这些数据将没有意义。Metal 使用顶点描述符(Vertex Descriptor)来完成这个任务。
首先,我们先了解几个与顶点有关的术语:
属性(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
管线状态描述符
与顶点描述符类似,为了布局 GPU 的渲染状态,我们需要创建一个渲染管线状态对象 (Pipeline State Object, PSO)。
渲染管线的状态包括:
指定图形函数和相关数据
顶点函数 (vertexFunction)
片元函数 (fragmentFunction)
最顶层顶点着色器函数的最大函数调用深度 (maxVertexCallStackDepth)
最顶层片元着色器函数的最大函数调用深度 (maxFragmentCallStackDepth)
指定渲染管线状态
颜色数据的附件数组 (colorAttachment)
深度数据的像素格式附件 (depthAttachmentPixelFormat)
模板数据的像素格式附件 (stencilAttachmentPixelFormat)
重置默认状态 (reset)
指定缓冲区布局和获取行为
顶点描述符 (vertexDescriptor)
还有不少可以设置以及获取的数据,详情请查阅 Apple Metal 的文档。
渲染
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 指令集的硬件支持,可以充分利用硬件加速。
SIMD类型包含了某个数据类型的多个通道,所以
顶点着色器
声明
实现
片元着色器
参考资料:
Comments