在上篇文章中我们学会了如何使用Metal来绘制视图内容,在这个篇章中我们将展示如何使用自定义渲染管道来绘制一个2D彩色图形。该示例为每个顶点提供了位置和颜色,并且渲染管线使用该数据渲染三角形,在为三角形顶点指定的颜色之间插入颜色值。
Metal渲染管道
一个渲染管线流程绘图命令和数据写入到一个渲染通道的目标。渲染管线具有许多阶段,其中一些阶段使用着色器进行编程,而其他阶段则具有固定或可配置的行为。该示例着重于流水线的三个主要阶段:顶点阶段,栅格化阶段和片元阶段。顶点阶段和片元阶段是可编程的,因此您可以使用Metal Shading Language
(MSL)为其编写函数。光栅化阶段具有固定的行为。
下图:Metal图形渲染管线的主要阶段
渲染从绘制命令开始,该命令包括一个顶点数和要渲染的图元类型。例如,这是此示例中的绘图命令:
// Draw the triangle.
[renderEncoder drawPrimitives:MTLPrimitiveTypeTriangle
vertexStart:0
vertexCount:3];
顶点阶段为每个顶点提供数据。处理完足够的顶点后,渲染管线将对图元进行栅格化,确定渲染目标中的哪些像素位于图元的边界内。片元阶段确定要写入这些像素的渲染目标的值。
在本示例的其余部分,您将看到如何编写顶点和片元函数,如何创建渲染管道状态对象,最后如何对使用该管道的绘图命令进行编码。
自定义渲染管道如何使用数据
顶点函数为单个顶点生成数据,片元函数为单个片元生成数据,但是您可以决定它们的工作方式。
决定哪些数据要传递到渲染管道,哪些数据要传递到管道的后续阶段。通常有三个地方可以做到这一点:
- 管道的输入,由应用程序提供并传递到顶点阶段。
- 顶点阶段的输出,传递到光栅化阶段。
- 片元阶段的输入,由您的应用程序提供或由栅格化阶段生成。
在此示例中,管道的输入数据是顶点的位置及其颜色。为了演示您通常在顶点函数中执行的变换类型,输入坐标是在自定义坐标空间中定义的,以自视图中心的像素为单位。这些坐标需要转换为Metal的坐标系。
AAPLVertex使用SIMD向量类型声明结构,以保存位置和颜色数据。要共享有关结构在内存中的布局方式的单一定义,请在公共标头中声明该结构,然后将其导入Metal着色器和应用程序中。
typedef struct
{
vector_float2 position;
vector_float4 color;
} AAPLVertex;
SIMD类型包含特定数据类型的多个通道,因此将位置声明为vector_float2
意味着它包含两个32位浮点值(将保存x和y坐标。)使用vector_float4
存储颜色,因此它们具有四个通道-红色,绿色,蓝色和Alpha。
在应用程序中,使用常量数组指定输入数据:
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
// Vertex shader outputs and fragment shader inputs
typedef struct
{
// 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;
} RasterizerData;
您需要告诉Metal栅格化数据中的哪个字段提供位置数据,因为Metal不会对结构中的字段实施任何特定的命名约定。position用[[position]]
属性限定符注释该字段,以声明该字段占据输出位置。
片元函数只是将栅格化阶段的数据传递到以后的阶段,因此不需要任何其他参数。
声明顶点函数
声明顶点函数,包括其输入参数和其输出的数据。就像使用kernel关键字声明计算函数一样,您可以使用关键字声明顶点函数vertex。
vertex RasterizerData
vertexShader(uint vertexID [[vertex_id]],
constant AAPLVertex *vertices [[buffer(AAPLVertexInputIndexVertices)]],
constant vector_uint2 *viewportSizePointer [[buffer(AAPLVertexInputIndexViewportSize)]])
第一个参数,使用属性限定符,这是另一个Metal关键字。执行渲染命令时,GPU会多次调用顶点函数,从而为每个顶点生成唯一的值。vertexID[[vertex_id]]
第二个参数,vertices是一个使用AAPLVertex先前定义的结构包含顶点数据的数组。
要将位置转换为Metal的坐标,此函数需要绘制三角形的视口大小(以像素为单位),因此将其存储在参数中。viewportSizePointer
第二个和第三个参数具有[[buffer(n)]]属性限定符。默认情况下,Metal自动在参数表中为每个参数分配插槽。将[[buffer(n)]]限定词添加到缓冲区参数时,您明确告诉Metal使用哪个插槽。显式声明插槽可以使您更轻松地修改着色器,而无需更改应用程序代码。在共享头文件中声明两个索引的常量。
该函数的输出是一个结构。RasterizerData
编写顶点函数
您的顶点函数必须生成输出结构的两个字段。使用自变量索引数组并读取顶点的输入数据。
// Index into the array of positions to get the current vertex.
// The positions are specified in pixel dimensions (i.e. a value of 100
// is 100 pixels from the origin).
float2 pixelSpacePosition = vertices[vertexID].position.xy;
// Get the viewport size and cast to float.
vector_float2 viewportSize = vector_float2(*viewportSizePointer);
顶点函数必须在剪贴空间坐标中提供位置数据,该数据是使用四维均匀向量(x,y,z,w)指定的3D点。光栅化阶段需要的输出位置,并且将x,y和z由坐标w,以生成一个3D点归一化设备坐标。规范化的设备坐标与视口大小无关。
下图:规范化设备坐标系
规范化的设备坐标使用左手坐标系并映射到视口中的位置。在图元坐标系中将图元裁剪到一个框,然后进行栅格化。剪辑框的左下角为的(x,y)坐标,右上角为的坐标。正z值指向远离相机的位置(进入屏幕)。坐标的可见部分在(近裁剪平面)和(远裁剪平面)之间。(-1.0,-1.0)(1.0,1.0)z0.01.0
将输入坐标系转换为规范化的设备坐标系。
因为这是2D应用程序,并且不需要同质坐标,所以首先将默认值写入输出坐标,将w值设置为,将其他坐标设置为。这意味着坐标已经在归一化设备坐标空间中,并且顶点函数应该在该坐标空间中生成(x,y)坐标。将输入位置除以视口大小的一半以生成标准化的设备坐标。由于此计算是使用SIMD类型执行的,因此可以使用一行代码同时分割两个通道。执行除法,然后将结果放入输出位置的x和y通道中。
// To convert from positions in pixel space to positions in clip-space,
// divide the pixel coordinates by half the size of the viewport.
out.position = vector_float4(0.0, 0.0, 0.0, 1.0);
out.position.xy = pixelSpacePosition / (viewportSize / 2.0);
最后,将颜色值复制到返回值中。
out.color = vertices[vertexID].color;
编写片元函数
下图:栅格化阶段生成的片元
片元函数处理来自光栅化器的单个位置的传入信息,并计算每个渲染目标的输出值。这些片元值由流水线中的后续阶段处理,最终写入渲染目标。
此示例中的片元着色器接收的参数与顶点着色器的输出中声明的参数相同。使用fragment关键字声明片元功能。它只接受一个参数,即顶点阶段提供的结构。添加属性限定符[[stage_in]]
以指示此参数由光栅化器生成。
fragment float4 fragmentShader(RasterizerData in [[stage_in]])
如果您的片元函数写入多个渲染目标,则必须为每个渲染目标声明一个带有字段的结构。由于此示例仅具有一个渲染目标,因此您可以直接将浮点向量指定为函数的输出。此输出是要写入渲染目标的颜色。
光栅化阶段为每个片元的参数计算值,并使用它们调用片元函数。栅格化阶段将其颜色参数计算为三角形顶点处颜色的混合。片元离顶点越近,该顶点对最终颜色的贡献就越大。
下图:插值的片元颜色
返回插值的颜色作为函数的输出。
return in.color;
创建渲染管道状态对象
现在功能已完成,您可以创建使用它们的渲染管道。首先,获取默认库并MTLFunction为每个函数获取一个对象。
// Load all the shader files with a .metal file extension in the project.
id defaultLibrary = [_device newDefaultLibrary];
id vertexFunction = [defaultLibrary newFunctionWithName:@"vertexShader"];
id fragmentFunction = [defaultLibrary newFunctionWithName:@"fragmentShader"];
接下来创建一个MTLRenderPipelineState
对象,渲染管道又很多的可配置项,你可以使用MTLRenderPipelineDescriptor
来进行配置。
// Configure a pipeline descriptor that is used to create a pipeline state.
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)将数据传递到着色器。但是,当您只需要向顶点函数传递少量数据时(如此处所示),可以将数据直接复制到命令缓冲区中。
该示例将两个参数的数据复制到命令缓冲区中。从样本中定义的数组复制顶点数据。视口数据是从用于设置视口的同一变量中复制的。
在此示例中,fragment函数仅使用从光栅化器接收到的数据,因此没有要设置的参数。
// Pass in the parameter data.
[renderEncoder setVertexBytes:triangleVertices
length:sizeof(triangleVertices)
atIndex:AAPLVertexInputIndexVertices];
[renderEncoder setVertexBytes:&_viewportSize
length:sizeof(_viewportSize)
atIndex:AAPLVertexInputIndexViewportSize];
与使用Metal绘制到屏幕一样,您结束编码过程并提交命令缓冲区。但是,您可以使用相同的步骤对更多的渲染命令进行编码。渲染最终图像,就像按指定顺序处理命令一样。(为了提高性能,只要最终结果看上去已经按顺序呈现,GPU便可以并行处理命令或什至部分命令。)