Apple Metal文档(3):使用渲染管线渲染图形

渲染一个简单的2D三角形

本文翻译自苹果Metal的官方文档:Using a Render Pipeline to Render Primitives,目的主要是为了加深自己的理解,如果哪里翻译有误,请大家指出,我会及时更正。欢迎转载,但禁止用于商业用途。谢谢!

概述

这个例程向你展示了如何配置一个渲染管线,并作为渲染通道一部分去绘制一个简单的2D三角形到视图中。该示例为每个顶点提供了位置和颜色信息,渲染管道使用该数据渲染三角形,并为三角形顶点指定的颜色之间插入颜色值。

顶点数据

Xcode 项目包含用于在 macOS、iOS 和 tvOS 上运行示例的方案。

理解Metal渲染管线

渲染管线(pipline)用于处理绘图指令,并将图元数据写到渲染通道的目标中去。一个渲染管线会有很多阶段,其中一些需要使用着色器进行编程,另一些具有固定或可配置的属性。这个例程聚焦于渲染管线的三个主要阶段:顶点阶段、光栅化阶段和片段阶段,其中顶点阶段和片段阶段是可编程的,因此需要使用Metal着色器语言(MSL),而在光栅化阶段是被固定的。

pipline

渲染首先通过一个绘图命令开始,包含顶点数量,需要绘制什么样的图形。下面是例程中的绘图指令。

// Draw the triangle.
[renderEncoder drawPrimitives:MTLPrimitiveTypeTriangle
                  vertexStart:0
                  vertexCount:3];

在顶点阶段会为每个顶点提供坐标信息,当顶点被处理完成,渲染管线会对图形进行光栅化处理,以确定渲染目标中的哪些像素位于图形的边界内。在片段阶段会确定这些像素的颜色值,并把它们写到渲染目标中。

在本示例的其余部分,将看到如何编写顶点和片段函数,如何创建渲染管道状态对象,最后,如何编码使用此管道的绘图命令。

自定义渲染管线如何处理数据

顶点函数会对每个顶点生成数据,片段函数会为图形上的片段生成数据,我们可以自定义其工作过程。配置管道的各个阶段时要记住一个目标,这意味着您知道希望管道生成什么以及它如何生成这些结果。

决定将什么数据传入你的渲染管线以及什么样的数据会在后续阶段通过渲染管线。总体上有三点需要你做的:

  1. 管道的输入,由您的应用程序提供并传递到顶点阶段。
  2. 顶点阶段的输出,传递到光栅化阶段。
  3. 片段阶段的输入,由您的应用程序提供或由光栅化阶段生成。

在本示例中,渲染管线的输入是顶点的位置坐标和颜色数据。输入坐标在自定义坐标空间中定义,以距视图中心的像素为单位。这些坐标需要转换为 Metal 的坐标系。

声明一个AAPLVertex结构体,使用 SIMD 矢量类型来保存位置和颜色数据。要共享一个关于结构在内存中的布局方式的定义,请在公共标头中声明结构并将其导入 Metal 着色器和应用程序。

typedef struct
{
    vector_float2 position;
    vector_float4 color;
} AAPLVertex;

SIMD 类型在 Metal Shading Language 中很常见,您还应该使用 simd 库在您的应用程序中使用它们。SIMD 类型包含特定数据类型的多个通道,所以以vector_float(包含2个32位的float)格式声明坐标,颜色信息使用vector_float4进行存储,包含红、绿、蓝、透明度通道。

在应用程序中,输入数据使用常量数组指定:

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]] 属性限定符注释位置字段以声明此字段包含输出位置。

片段函数只是将光栅化阶段的数据传递到后面的阶段,因此它不需要任何额外的参数。

声明顶点函数

声明顶点函数,包括它的输入参数和它输出的数据。 就像使用 kernel 关键字声明计算函数一样,您使用 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 使用哪个接收对象。 显式声明可以更轻松地修改着色器,而无需更改应用程序代码。 在共享头文件中声明两个索引的常量。

这个函数会输出一个RasterizerData对象。

编写顶点数据

顶点函数必须包含输出结构体的两个字段,使用 vertexID 参数来索引顶点数组并读取顶点的输入数据。 此外,需要获取绘制图像视图的尺寸。

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 点。 归一化设备坐标与视口大小无关。

空间坐标

归一化坐标使用左手坐标系(opengl为右手)并映射到视图上的坐标系;在此坐标系中裁切出一个框,包裹住需要绘制的图形,进而进行光栅化;裁切框的左下角位于x-y坐标系上,坐标为(-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 通道中。

out.position = vector_float4(0.0, 0.0, 0.0, 1.0);
out.position.xy = pixelSpacePosition / (viewportSize / 2.0);

最后将颜色信息赋值给输出的color字段:

out.color = vertices[vertexID].color;

编写片段函数

渲染目标的片段(fargment)是可通过着色器配置的,光栅化会确定渲染目标的哪些像素被图元覆盖。 只有像素中心在三角形内的片段才会被渲染。

片段渲染

片段函数处理来自光栅器的单个位置的传入信息,并计算每个渲染目标的输出值。 这些片段值由渲染管线中的后续阶段处理,并最终被写入渲染目标。

片段可更改的原因是因为片段阶段之后的渲染管线可以配置为拒绝某些片段或更改写入渲染目标的内容。 在此示例中,片段阶段计算的所有值都按原样写入渲染目标。

此示例中的片段着色器接收参数与顶点着色器输出参数相同。使用 fragment 关键字声明片段函数。它只有一个参数,即顶点阶段提供的RasterizerData 结构体。添加 [[stage_in]] 属性限定符以指示此参数由光栅器生成。

fragment float4 fragmentShader(RasterizerData in [[stage_in]])

如果您的片段函数对应多个渲染目标,它必须为每个渲染目标声明一个结构体。 因为这个示例只有一个渲染目标,所以您直接指定一个浮点向量作为函数的输出。 此输出是要写入渲染目标的颜色。

光栅化阶段计算每个片段参数的值并使用它们调用片段函数。 光栅化阶段将其颜色参数计算为三角形顶点处颜色的混合。 片段离顶点越近,顶点颜色对最终颜色的影响就越大。

片段着色

片段函数会返回插值颜色作为函数的输出。

return in.color;

创建渲染管线状态对象

现在顶点和片段函数已经完成,还需要通过创建一个渲染管线来完成调用,首先,获取默认库,并为每个函数获取一个 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];

编写绘制命令

指定基本类型、起始索引和顶点数。 渲染三角形时,调用顶点函数,其 vertexID 参数的值为 0、1 和 2。

// Draw the triangle.
[renderEncoder drawPrimitives:MTLPrimitiveTypeTriangle
                  vertexStart:0
                  vertexCount:3];

与使用 Metal 绘制到屏幕一样,您结束编码过程并提交命令缓冲区。 但是,您可以使用同一组步骤对更多渲染命令进行编码。 最终图像的渲染就像命令按照指定的顺序处理一样。 (出于性能考虑,GPU 可以并行处理命令甚至部分命令,只要最终结果看起来已经按顺序呈现即可。)

颜色插值的实验

在此示例中,颜色会在三角形中插值。 这通常是您想要的,但有时您希望由一个顶点生成一个值并在整个图元中保持不变。 在顶点函数的输出上指定平面属性限定符以执行此操作。 现在试试这个。 在示例项目中找到 RasterizerData 的定义,并将 [[flat]] 限定符添加到其颜色字段。

float4 color [[flat]];

再次运行示例。 渲染管道在整个三角形中统一使用第一个顶点(称为激发顶点)的颜色值,并忽略其他两个顶点的颜色。 您可以混合使用平面着色值和插值值,只需在顶点函数的输出上添加或省略平面限定符即可。 Metal 着色器语言规范定义了其他属性限定符,您也可以使用它来修改光栅化行为。

See also

comments powered by Disqus