渲染一个简单的 2D 三角形。 代码传送门
概述
此示例展示了如何配置渲染管线并将其作为渲染通道的一部分,用于在视图中绘制一个简单的二维彩色三角形。示例为每个顶点提供了位置和颜色信息,渲染管线使用这些数据来渲染三角形,并在三角形顶点指定的颜色之间进行插值。

Metal 渲染管线理解
渲染管线负责处理绘图指令,并将数据写入渲染通道的目标中。此管线包含多个阶段,其中一些阶段可以通过着色器编程控制,而其他阶段则具有固定或可配置的行为。这个示例主要关注管线的三个关键阶段:顶点阶段、光栅化阶段和片段阶段。顶点阶段和片段阶段是可编程的,因此你可以使用 Metal 着色语言 (MSL) 为它们编写函数。而光栅化阶段的行为是固定的。

渲染开始于一个绘图指令,该指令包括顶点数量以及要渲染的图元类型。例如,这是本示例中的绘图指令:
objc
// 绘制三角形。
[renderEncoder drawPrimitives:MTLPrimitiveTypeTriangle
vertexStart:0
vertexCount:3];
顶点阶段为每个顶点提供数据。当处理足够的顶点后,渲染管线对图元进行光栅化,确定在渲染目标中哪些像素位于图元边界内。片段阶段决定这些像素在渲染目标中要写入的值。
本示例的其余部分演示了如何编写顶点和片段函数,如何创建渲染管线状态对象,以及最后如何编码一条使用此管线的绘图指令。这提供了对如何利用 Metal 构建高效的图形渲染流程的基本了解,特别是针对那些希望深入理解图形编程底层细节的人。通过这种方式,开发者可以更好地控制图形渲染过程,实现高性能和高质量的视觉效果。
自定义渲染管线如何处理数据
顶点函数为单个顶点生成数据,片段函数为单个片段生成数据,但您需要决定它们的工作方式。您需根据目标配置管线的各个阶段,这意味着您知道希望管线生成什么结果以及它如何生成这些结果。
决定将哪些数据传递到渲染管线中,并将哪些数据传递到管线的后续阶段。通常有三个地方可以实现这一点:
- 管线的输入数据:由您的应用程序提供并传递给顶点阶段。
- 顶点阶段的输出数据:传递给光栅化阶段。
- 片段阶段的输入数据:由您的应用程序提供或由光栅化阶段生成。
在此示例中,管线的输入数据是顶点的位置及其颜色。为了演示顶点函数中通常执行的变换类型,输入坐标被定义在一个自定义坐标空间中,以视图中心为原点用像素表示。这些坐标需要转换为 Metal 的坐标系。
声明一个 AAPLVertex
结构体,使用 SIMD 向量类型存储位置和颜色数据。为了在内存布局中共享单一定义,在通用头文件中声明该结构体,并在 Metal 着色器和应用程序中导入它。
objc
typedef struct
{
vector_float2 position;
vector_float4 color;
} AAPLVertex;
SIMD 类型在 Metal 着色语言中非常常见,您还应该在应用程序中使用 simd
库来使用它们。SIMD 类型包含多个特定数据类型的通道,因此将位置声明为 vector_float2
意味着它包含两个 32 位浮点值(即 x 和 y 坐标)。颜色则使用 vector_float4
存储,因此它们具有四个通道------红、绿、蓝和透明度 (RGBA)。
在应用程序中,输入数据通过一个常量数组指定:
objc
static const AAPLVertex triangleVertices[] =
{
// 2D 位置, RGBA 颜色
{ { 250, -250 }, { 1, 0, 0, 1 } },
{ { -250, -250 }, { 0, 1, 0, 1 } },
{ { 0, 250 }, { 0, 0, 1, 1 } },
};
顶点阶段为每个顶点生成数据,因此需要提供颜色和变换后的位置。声明一个 RasterizerData
结构体,包含位置和颜色值,同样使用 SIMD 类型。
objc
struct RasterizerData
{
// 此成员的 [[position]] 属性表明,当此结构体从顶点函数返回时,
// 此值是顶点的裁剪空间位置。
float4 position [[position]];
// 由于此成员没有特殊属性,光栅化阶段会将其值与三角形其他顶点的值
// 进行插值,然后将插值后的值传递给每个片段的片段着色器。
float4 color;
};
输出位置(详见下文)必须定义为 vector_float4
。颜色的声明方式与输入数据结构中相同。
您需要告诉 Metal 哪个字段在光栅化数据中提供位置数据,因为 Metal 不会对结构体中的字段强制任何特定的命名约定。使用 [[position]]
属性限定符注解位置字段,以声明该字段保存输出位置。
片段函数只需将光栅化阶段的数据传递给后续阶段,因此不需要任何额外的参数。
声明顶点函数
声明顶点函数,包括其输入参数和输出数据。类似于使用 kernel
关键字声明计算函数,您可以使用 vertex
关键字声明顶点函数。
objc
vertex RasterizerData
vertexShader(uint vertexID [[vertex_id]],
constant AAPLVertex *vertices [[buffer(AAPLVertexInputIndexVertices)]],
constant vector_uint2 *viewportSizePointer [[buffer(AAPLVertexInputIndexViewportSize)]])
第一个参数 vertexID
使用 [[vertex_id]]
属性限定符,这是另一个 Metal 关键字。当执行渲染命令时,GPU 会多次调用您的顶点函数,为每个顶点生成唯一值。
第二个参数 vertices
是一个包含顶点数据的数组,使用之前定义的 AAPLVertex
结构体。
为了将位置转换为 Metal 的坐标系,函数需要绘制三角形的目标视口大小(以像素为单位),因此将其存储在 viewportSizePointer
参数中。
第二个和第三个参数具有 [[buffer(n)]]
属性限定符。默认情况下,Metal 会自动为每个参数分配参数表中的槽位。当您为缓冲区参数添加 [[buffer(n)]]
限定符时,您明确告诉 Metal 使用哪个槽位。显式声明槽位可以使您更轻松地修改着色器,而无需同时更改应用程序代码。在共享头文件中声明这两个索引的常量。
函数的输出是一个 RasterizerData
结构体。
编写顶点函数
您的顶点函数必须生成输出结构体的所有字段。使用 vertexID
参数索引到 vertices
数组中,读取该顶点的输入数据。同时,获取视口的尺寸。
objc
float2 pixelSpacePosition = vertices[vertexID].position.xy;
// 获取视口大小并转换为浮点类型。
vector_float2 viewportSize = vector_float2(*viewportSizePointer);
顶点函数必须以裁剪空间坐标(clip-space coordinates)的形式提供位置数据,这些坐标是通过四维齐次向量 (x, y, z, w)
表示的 3D 点。光栅化阶段会将输出位置的 x
、y
和 z
坐标除以 w
,以生成归一化设备坐标(normalized device coordinates)。归一化设备坐标与视口大小无关。

归一化设备坐标使用左手坐标系,并映射到视口中的位置。图元会被裁剪到此坐标系中的一个盒子内,然后进行光栅化。裁剪盒子的左下角坐标为 (-1.0, -1.0)
,右上角坐标为 (1.0, 1.0)
。正的 z
值指向远离摄像机的方向(进入屏幕)。z
坐标的可见部分在 0.0
(近裁剪平面)和 1.0
(远裁剪平面)之间。
将输入坐标系转换为归一化设备坐标系。

由于这是一个 2D 应用程序,不需要齐次坐标,因此首先为输出坐标写入一个默认值,其中 w
值设置为 1.0
,其他坐标设置为 0.0
。这意味着坐标已经在归一化设备坐标空间中,顶点函数应在该坐标空间中生成 (x, y)
坐标。将输入位置除以视口大小的一半以生成归一化设备坐标。由于此计算使用 SIMD 类型,两个通道可以同时除以一个操作完成。执行除法并将结果放入输出位置的 x
和 y
通道中。
objc
out.position = vector_float4(0.0, 0.0, 0.0, 1.0);
out.position.xy = pixelSpacePosition / (viewportSize / 2.0);
最后,将颜色值复制到 out.color
返回值中。
objc
out.color = vertices[vertexID].color;
编写片元函数
片元是渲染目标可能发生的更改。光栅化器确定渲染目标中哪些像素被图元覆盖。只有像素中心位于三角形内部的片元才会被渲染。

片元函数处理来自光栅化器的单个位置的传入信息,并为每个渲染目标计算输出值。这些片元值由管线的后续阶段处理,最终写入渲染目标。
注意
片元被称为"可能的更改",是因为片元之后的管线阶段可以配置为拒绝某些片元或更改写入渲染目标的内容。在此示例中,片元阶段计算的所有值都会直接写入渲染目标。
此示例中的片元着色器接收与顶点着色器输出相同的参数。使用 fragment
关键字声明片元函数。它接受一个参数,即顶点阶段提供的相同 RasterizerData
结构体。添加 [[stage_in]]
属性限定符以表明此参数由光栅化器生成。
lua
objc
深色版本
fragment float4 fragmentShader(RasterizerData in [[stage_in]])
如果您的片元函数写入多个渲染目标,则必须声明一个包含每个渲染目标字段的结构体。由于此示例只有一个渲染目标,因此直接指定一个浮点向量作为函数的输出。该输出是要写入渲染目标的颜色。
光栅化阶段为每个片元的参数计算值,并使用这些值调用片元函数。光栅化阶段将其颜色参数计算为三角形顶点颜色的混合值。片元越接近某个顶点,该顶点对最终颜色的贡献越大。

返回插值后的颜色作为函数的输出。
objc
return in.color;
创建渲染管线状态对象
现在函数已完成,您可以创建一个使用它们的渲染管线。首先,获取默认库并为每个函数获取一个 MTLFunction
对象。
objc
id<MTLLibrary> defaultLibrary = [_device newDefaultLibrary];
id<MTLFunction> vertexFunction = [defaultLibrary newFunctionWithName:@"vertexShader"];
id<MTLFunction> fragmentFunction = [defaultLibrary newFunctionWithName:@"fragmentShader"];
接下来,创建一个 MTLRenderPipelineState
对象。渲染管线有更多阶段需要配置,因此使用 MTLRenderPipelineDescriptor
配置管线。
objc
MTLRenderPipelineDescriptor *pipelineStateDescriptor = [[MTLRenderPipelineDescriptor alloc] init];
pipelineStateDescriptor.label = @"Simple Pipeline";
pipelineStateDescriptor.vertexFunction = vertexFunction;
pipelineStateDescriptor.fragmentFunction = fragmentFunction;
pipelineStateDescriptor.colorAttachments[0].pixelFormat = mtkView.colorPixelFormat;
_pipelineState = [_device newRenderPipelineStateWithDescriptor:pipelineStateDescriptor
error:&error];
除了指定顶点和片段函数外,还需要声明管线绘制的所有渲染目标的像素格式。像素格式 (MTLPixelFormat
) 定义了像素数据的内存布局。对于简单格式,此定义包括每像素的字节数、存储在像素中的通道数以及这些通道的位布局。由于此示例只有一个渲染目标并且由视图提供,因此将视图的像素格式复制到渲染管线描述符中。您的渲染管线状态必须使用与渲染通道兼容的像素格式。在此示例中,渲染通道和管线状态对象都使用视图的像素格式,因此它们始终相同。
当 Metal 创建渲染管线状态对象时,管线会配置为将片段函数的输出转换为渲染目标的像素格式。如果您想针对不同的像素格式进行渲染,则需要创建不同的管线状态对象。您可以在多个管线中重用相同的着色器,以针对不同的像素格式进行渲染。
设置视口
现在您已经有了渲染管线状态对象,可以使用渲染命令编码器渲染三角形。首先设置视口,以便 Metal 知道要绘制到渲染目标的哪个部分。
objc
// 设置绘制区域。
[renderEncoder setViewport:(MTLViewport){0.0, 0.0, _viewportSize.x, _viewportSize.y, 0.0, 1.0}];
设置渲染管线状态
设置要使用的渲染管线状态。
objc
[renderEncoder setRenderPipelineState:_pipelineState];
将参数数据传递到顶点函数
通常,您使用缓冲区 (MTLBuffer
) 将数据传递到着色器。然而,当只需要向顶点函数传递少量数据(如本示例)时,可以直接将数据复制到命令缓冲区中。
示例将两个参数的数据都复制到命令缓冲区中。顶点数据从示例中定义的数组中复制,而视口数据则从用于设置视口的同一变量中复制。
在此示例中,片段函数仅使用从光栅化器接收到的数据,因此没有需要设置的参数。
objc
[renderEncoder setVertexBytes:triangleVertices
length:sizeof(triangleVertices)
atIndex:AAPLVertexInputIndexVertices];
[renderEncoder setVertexBytes:&_viewportSize
length:sizeof(_viewportSize)
atIndex:AAPLVertexInputIndexViewportSize];
编码绘制命令
指定图元类型、起始索引和顶点数量。当三角形被渲染时,顶点函数会分别使用 0
、1
和 2
的 vertexID
值调用。
objc
// 绘制三角形。
[renderEncoder drawPrimitives:MTLPrimitiveTypeTriangle
vertexStart:0
vertexCount:3];
与《使用 Metal 绘制到屏幕》类似,结束编码过程并提交命令缓冲区。不过,您可以使用相同的步骤编码更多的渲染命令。最终图像会按命令指定的顺序渲染。(为了性能,GPU 可以并行处理命令甚至部分命令,只要最终结果看起来是按顺序渲染即可。)
实验颜色插值
在此示例中,颜色值在三角形上进行了插值。这通常是您想要的效果,但有时您希望某个顶点生成的值在整个图元上保持恒定。为此,可以在顶点函数的输出上指定 flat
属性限定符。现在尝试一下。在示例项目中找到 RasterizerData
的定义,并为其颜色字段添加 [[flat]]
限定符。
objc
float4 color [[flat]];
再次运行示例。渲染管线会在整个三角形上统一使用第一个顶点(称为引发顶点)的颜色值,并忽略其他两个顶点的颜色。您可以通过在顶点函数的输出上添加或省略 flat
限定符来混合使用平面着色和插值值。Metal 着色语言规范还定义了其他属性限定符,可用于修改光栅化行为。