top of page
  • Writer's pictureLingheng Tao

Metal #2 Rendering Pipeline

Updated: Jun 8


这篇文章主要关于 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类型包含了某个数据类型的多个通道,所以


顶点着色器

声明


实现


片元着色器



 

参考资料:

28 views0 comments

Recent Posts

See All

Comments


bottom of page