Metal 框架之渲染管线渲染图元
「这是我参与11月更文挑战的第14天,活动详情查看:2021最后一次更文挑战」
概述
在 《 Metal 框架之使用 Metal 来绘制视图内容 》中,介绍了如何设置 MTKView 对象并使用渲染通道更改视图的内容,实现了将背景色渲染为视图的内容。本示例将介绍如何配置渲染管道,作为渲染通道的一部分,在视图中绘制一个简单的 2D 彩色三角形。该示例为每个顶点提供位置和颜色,渲染管道使用该数据,在指定的顶点颜色之间插入颜色值来渲染三角形。
在本示例中,将介绍如何编写顶点和片元函数、如何创建渲染管道状态对象,以及最后对绘图命令进行编码。
理解 Metal 渲染管线
渲染管线处理绘图命令并将数据写入渲染通道的目标中。一个完整地渲染管线有许多阶段组成,一些阶段需要使用着色器进行编程,而一些阶段则需要配置固定的功能件。本示例的管线主要包含三个阶段:顶点阶段、光栅化阶段和片元阶段。其中,顶点阶段和片元阶段是可编程的,这可以使用 Metal Shading Language (MSL) 来编写函数,而光栅化阶段则是不可编程的,直接使用固有功能件来配置。
渲染从绘图命令开始,其中包括顶点个数和要渲染的图元类型。如下是本例子的绘图命令:
// Draw the triangle.
[renderEncoder drawPrimitives:MTLPrimitiveTypeTriangle
vertexStart:0
vertexCount:3];
顶点阶段会处理每个顶点的数据。当顶点经过顶点阶段处理后,渲染管线会对图元光栅化处理,以此来确定渲染目标中的哪些像素位于图元的边界内(即图元可以转化成的像素)。片元阶段是要确定渲染目标的像素值。
自定义渲染管线
顶点函数为单个顶点生成数据,片元函数为单个片元生成数据,可以通过编写函数来指定它们的工作方式。我们可以依据希望管道完成什么功能以及如何完成来配置管道的各个阶段。
决定将哪些数据传递到渲染管道以及将哪些数据传递到管道的后期阶段,通常可以在三个地方执行此操作:
管道的输入,由 App 提供并传递到顶点阶段。
顶点阶段的输出,它被传递到光栅化阶段。
片元阶段的输入,由 App 提供或由光栅化阶段生成。
在本示例中,管道的输入数据包括顶点的位置及其颜色。为了演示顶点函数中执行的转换类型,输入坐标在自定义坐标空间中定义,以距视图中心的像素为单位进行测量。这些坐标需要转换成 Metal 的坐标系。
声明一个 AAPLVertex 结构,使用 SIMD 向量类型来保存位置和颜色数据。
typedef struct
{
vector_float2 position;
vector_float4 color;
} AAPLVertex;
SIMD 类型在 Metal Shading Language 中很常见,相应的需要在 App 中使用 simd 库。 SIMD 类型包含特定数据类型的多个通道,因此将位置声明为 vector_float2 意味着它包含两个 32 位浮点值(x 和 y 坐标)。颜色使用 vector_float4 存储,因此它们有四个通道:红色、绿色、蓝色和 alpha。
在 App 中,输入数据使用常量数组指定:
static const AAPLVertex triangleVertices[] =
{
// 2D positions, RGBA colors
{ { 250, -250 }, { 1, 0, 0, 1 } },
{ { -250, -250 }, { 0, 1, 0, 1 } },
{ { 0, 250 }, { 0, 0, 1, 1 } },
};
顶点阶段为顶点生成数据,需要提供颜色和变换的位置。使用 SIMD 类型声明一个包含位置和颜色值的 RasterizerData 结构。
struct RasterizerData
{
// The [[position]] attribute of this member indicates that this value
// is the clip space position of the vertex when this structure is
// returned from the vertex function.
float4 position [[position]];
// Since this member does not have a special attribute, the rasterizer
// interpolates its value with the values of the other triangle vertices
// and then passes the interpolated value to the fragment shader for each
// fragment in the triangle.
float4 color;
};
输出位置(在下面详细描述)必须定义为 vector_float4 类型。颜色在输入数据结构中声明。
需要告诉 Metal 光栅化数据中的哪个字段提供位置数据,因为 Metal 不会对结构中的字段强制执行任何特定的命名约定。使用 [[position]] 属性限定符来标记位置字段,使用它来保存该字段输出位置。
fragment 函数只是将光栅化阶段的数据传递给后面的阶段,因此它不需要任何额外的参数。
定义顶点函数
需要使用 vertex 关键字来定义顶点函数,包含入参和出参。
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 要使用哪个插槽。显式声明插槽可以方便的修改着色器代码,而无需更改 App 代码。
编写顶点函数
编写的顶点函数必须生成输出结构的两个字段,使用 vertexID 参数索引顶点数组并读取顶点的输入数据,还需要获取视口尺寸。
float2 pixelSpacePosition = vertices[vertexID].position.xy;
// Get the viewport size and cast to float.
vector_float2 viewportSize = vector_float2(*viewportSizePointer);
复制代码
顶点函数必须提供裁剪空间坐标中的位置数据,这些位置数据是 3D 的点,使用四维齐次向量 (x,y,z,w) 来表示。光栅化阶段获取输出位置,并将 x、y 和 z 坐标除以 w 以生成归一化设备坐标中的 3D 点。归一化设备坐标与视口大小无关。
归一化设备坐标使用左手坐标系来映射视口中的位置。图元被裁剪到这个坐标系中的一个裁剪框上,然后被光栅化。剪切框的左下角位于 (-1.0,-1.0) 坐标处,右上角位于 (1.0,1.0) 处。正 z 值指向远离相机(指向屏幕)。z 坐标的可见部分在 0.0(近剪裁平面)和 1.0(远剪裁平面)之间。
下图是将输入坐标系转换为归一化的设备坐标系。
因为这是一个二维应用,不需要齐次坐标,所以先给输出坐标写一个默认值,w值设置为1.0,其他坐标设置为0.0。这意味顶点函数在该坐标空间中生成的 (x,y) 已经在归一化设备坐标空间中了。将输入位置除以1/2视口大小就生成归一化的设备坐标。由于此计算是使用 SIMD 类型执行的,因此可以使用一行代码同时计算两个通道,执行除法并将结果放在输出位置的 x 和 y 通道中。
out.position = vector_float4(0.0, 0.0, 0.0, 1.0);
out.position.xy = pixelSpacePosition / (viewportSize / 2.0);
最后,将颜色值赋给 out.color 作为返回值。
out.color = vertices[vertexID].color;
编写片元函数
片元阶段对渲染目标可以做修改处理。光栅化器确定渲染目标的哪些像素被图元覆盖,仅处于三角形片元中的那些像素才会被渲染。
片元函数处理光栅化后的位置信息,并计算每个渲染目标的输出值。这些片元值由管道中的后续阶段处理,最终写入渲染目标。
本示例中的片元着色器接收与顶点着色器的输出中声明的相同参数。使用 fragment 关键字声明片元函数。它只有一个输入参数,与顶点阶段提供的 RasterizerData 结构相同。添加 [[stage_in]] 属性限定符以指示此参数由光栅化器生成。
fragment float4 fragmentShader(RasterizerData in [[stage_in]])
如果片元函数写入多个渲染目标,则必须为每个渲染目标声明一个变量。由于此示例只有一个渲染目标,因此可以直接指定一个浮点向量作为函数的输出,此输出是要写入渲染目标的颜色。
光栅化阶段计算每个片元参数的值并用它们调用片元函数。光栅化阶段将其颜色参数计算为三角形顶点处颜色的混合,片元离顶点越近,顶点对最终颜色的贡献就越大。
将内插颜色作为函数的输出返回。
return in.color;
创建渲染管线状态对象
完成着色器函数编写后,需要创建一个渲染管道,通过 MTLLibrary 为每个着色器函数指定一个 MTLFunction 对象。
id<MTLLibrary> defaultLibrary = [_device newDefaultLibrary];
id<MTLFunction> vertexFunction = [defaultLibrary newFunctionWithName:@"vertexShader"];
id<MTLFunction> fragmentFunction = [defaultLibrary newFunctionWithName:@"fragmentShader"];
接下来,创建一个 MTLRenderPipelineState 对象,使用 MTLRenderPipelineDescriptor 来配置管线。
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 要绘制到渲染目标的哪个部分。
// Set the region of the drawable to draw into.
[renderEncoder setViewport:(MTLViewport){0.0, 0.0, _viewportSize.x, _viewportSize.y, 0.0, 1.0 }];
设置渲染管线状态
为渲染管线指定渲染管线状态对象。
[renderEncoder setRenderPipelineState:_pipelineState];
将参数数据发送到顶点函数
通常使用缓冲区 (MTLBuffer) 将数据传递给着色器。但是,当只需要向顶点函数传递少量数据时,可以将数据直接复制到命令缓冲区中。
该示例将两个参数的数据复制到命令缓冲区中,顶点数据是从定义的数组复制而来的,视口数据是从设置视口的同一变量中复制的,片元函数仅使用从光栅化器接收的数据,因此没有传递参数。
[renderEncoder setVertexBytes:triangleVertices
length:sizeof(triangleVertices)
atIndex:AAPLVertexInputIndexVertices];
[renderEncoder setVertexBytes:&_viewportSize
length:sizeof(_viewportSize)
atIndex:AAPLVertexInputIndexViewportSize];
编码绘图命令
指定图元的种类、起始索引和顶点数。当三角形被渲染时,vertex 函数被调用,参数 vertexID 的值分别为 0、1 和 2。
// Draw the triangle.
[renderEncoder drawPrimitives:MTLPrimitiveTypeTriangle
vertexStart:0
vertexCount:3];
与使用 Metal 绘制到屏幕一样,需要结束编码过程并提交命令缓冲区。不同之处是,可以使用相同的一组步骤对更多渲染命令进行编码。按照指定的顺序来执行命令,生成最终渲染的图像。 (为了性能,GPU 可以并行处理命令甚至部分命令,只要最终结果是按顺序渲染的就行。)
颜色插值
在此示例中,颜色值是在三角形内部插值计算出来的。有时希望由一个顶点生成一个值并在整个图元中保持不变,这需要在顶点函数的输出上指定 flat 属性限定符来执行此操作。示例项目中,通过在颜色字段中添加 [[flat]] 限定符来实现此功能。
float4 color [[flat]];
渲染管线使用三角形的第一个顶点(称为激发顶点)的颜色值,并忽略其他两个顶点的颜色。还可以混合使用 flat 着色和内插值,只需在顶点函数的输出上添加或删除 flat 限定符即可。
总结
本文介绍了如何配置渲染管道,如何编写顶点和片元函数、如何创建渲染管道状态对象,以及最后对绘图命令进行编码,最终在视图中绘制一个简单的 2D 彩色三角形。