示例程序(Sample)
在使用Metal来画一个视图内容的文章中,你知道了如何设置一个MTKView对象并且知道了如何通过传递渲染参数来改变视图的内容。那个示例演示了将视图内容抹除换成了一个背景颜色。这个示例将向你展示如何配置一个渲染管道(Pipeline)并将它作为一部分的渲染传递参数来在视图上画一个简单的二维彩色的三角形。这个示例为每个顶点(Vertex)提供了位置和颜色,并且渲染管道使用了那些数据来渲染三角形,在三角形的顶点之间指定颜色的插入值。
注意:
在Xcode的示例项目中包含了用于运行macOS,iOS和tvOS的计划。Metal在iOS和tvOS的模拟器上不支持,所以iOS和tvOS需要在真机上运行。默认的运行计划是macOS平台.
(Pipeline)渲染管道是一个处理绘画命令和往渲染对象写数据的过程。一个渲染管道有许多过程,一些程序处理使用着色器,还有其他的使用固定的或者可配置的方式。这里的示例处理了管道的三个主要过程:顶点过程,光栅化过程和片段着色器过程。顶点过程和片段着色器过程是可编程的,所以你可以用Metal渲染语言(MSL)为他们写功能。光栅化过程有固定的方式。
图 1 Metal的图形渲染管道的主要阶段
渲染以绘画命令开始,包含了一个顶点数量和渲染的图元类型。例如,这是示例中的绘画命令:
// Draw the triangle.
[renderEncoder drawPrimitives:MTLPrimitiveTypeTriangle
vertexStart:0
vertexCount:3];
顶点过程为每个顶点提供了数据。当足够的顶点数已经被处理,渲染管道就光栅化图元,确定哪些像素是位于图元边界需要渲染的目标。片段着色器过程确定这些值写入需要渲染目标的像素中。
在这个示例的其他内容里,你将会看到如何去写顶点和片段着色器的功能,和如何去创建渲染管道的状态对象,并且最后,知道如何使用管道去编码一个绘画命令。
一个顶点着色器函数为单个顶点生成数据,并且一个片段着色器函数为单个片段生成数据,但是你决定他们如何工作。你以你的思维导向来配置管道的过程,那意味着你知道如何让管道去生成你想要的和如何去产生那些结果。
决定传递什么数据到你的渲染管道中并且也决定了什么数据被传递到后面过程的管道中。这里有三个代表性的地方你可以这么做:
在这个示例程序中,管道的输入数据源是顶点的位置和它的颜色值组成。为了展示这种类型的转化一般代表性的在一个顶点着色器的程序中执行,输入的坐标在一个自定义的坐标系空间内定义,像素的判断是从视图的中心开始。这些坐标需要被转化成Metal的坐标系系统。
声明一个AAPLVertex的结构体,使用SMID的向量类型来拥有位置数据和颜色数据。为了分析一个单独的定义来了解这个结构体如何在内存中排列,先在这个结构体中声明一个通用的头并且在Metal的着色器和应用中引入它。
typedef struct
{
vector_float2 position;
vector_float4 color;
} AAPLVertex;
SIMD类型在Metal着色器语言中是常见的,并且你应该在应用中通过simd库使用他们。SIMD类型在一个特定的数据类型中包含多个通道,所以声明的这个位置作为vector_float2意味着他包含两个32bit的float值(拥有x和y的坐标)。颜色值通过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 } },
};
这个顶点着色过程为一个顶点生成数据,所以他需要提供一个颜色值和一个需要转化的位置值。声明一个RasterizerData的结构体用来包含一个颜色值和一个位置值,并且还是使用了SIMD类型。
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;
这个输出位置值(在下面会详细描述)必须是被定义成一个vector_float4的类型。这个颜色值是被声明在它所在的输入结构体数据中。
你需要告诉Metal在光栅化数据的哪个区域需要提供位置数据,因为Metal不会在你的结构体的区域中强制使用任何命名约定。注释这个位置区域用[[position]]的属性修饰符来声明这个区域保存输出的位置信息。
这个片段着色器功能简单地传递了这个光栅化过程的数据给后续的阶段,所以它并不需要其他的参数。
声明顶点着色器的功能, 包含了它的输入参数和它输出的数据。很多计算功能使用kernel关键字声明,就像你使用顶点关键字声明一个顶点着色器的功能。
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)]]修饰符到一个buffer属性时,你就是在告诉Metal使用指定的值。指定的值可以简单地让你不需要改变你的应用代码来修改你的着色器。也就是在着色器的头文件中给两个指标声明常量。
这个功能函数的输出是一个RasterizerData的结构体。
你的顶点着色器功能必须同时生成输出结构数据的区域位置。使用vertexID的属性来索引顶点数组并且读取顶点的输入数据。同时,检索视口的大小。
float2 pixelSpacePosition = vertices[vertexID].position.xy;
// Get the viewport size and cast to float.
vector_float2 viewportSize = vector_float2(*viewportSizePointer);
顶点功能函数必须提供位置数据给clip-space坐标系,这是一个三维的点使用一个思维齐次向量顶点(x,y,z,w)。光栅化的过程使用这个输出的点将x,y和z坐标根据w分离,最终在标准的设备坐标系中生成一个三维的点。标准的设备坐标系是独立于视口的大小的。
图2 标准的设备坐标系系统
标准的设备坐标系系统使用左手坐标系系统,并在视口中绘制点。图元在这个坐标系系统内被裁剪进一个格子中并且光栅化。在裁剪的格子的左下角的(x,y)是(-1.0,-1.0)的坐标并且右上角的(x,y)是(1.0,1.0)的坐标。正数z的坐标值远离相机(往屏幕内方向)。z坐标的可见部分范围在0.0(靠近裁剪面)和1.0(远离裁剪面)之间。
转化输入的坐标系系统为标准的设备坐标系系统。
因为这是一个二维的应用,所有不需要齐次向量坐标,第一步先通过给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);
最后,拷贝这个颜色值到out.color返回值。
out.color = vertices[vertexID].color;
一个片段是一种改变渲染目标的可能方式。光栅化程序决定了渲染目标的哪个像素被图元覆盖。只有片段的像素位于三角形范围内才会被渲染。
图三 片段由光栅化阶段生成
一个片段着色器功能函数为一个单独的光栅化的位置处理了输入信息和计算了每一个渲染目标的输出值。这些片段数值是在管道中的后面的阶段被处理,最终被写入渲染的目标中。
注意
一个片段被叫做一种可能的改变方式的原因是因为管道处理阶段是在片段着色器处理阶段之后可以被配置来丢弃一些片段或者改变获得什么来写入渲染目标中。在这个示例中,所有的值由片段着色阶段计算并按其结果直接写入到渲染的目标中。
在这个示例中的片段着色器接收到了这些相同的参数是被声明在了顶点着色器的输出中。声明片段着色器功能函数使用了fragment关键字。它带有一个单独的属性,是由顶点阶段提供的与RasterizerData相同的结构体。加入[[stage_in]]属性修饰符来显示这个属性是由光栅化产生的。
fragment float4 fragmentShader(RasterizerData in [[stage_in]])
如果你的片段功能函数写入了多个渲染的目标,它必须为每个渲染目标的区域声明一个结构体。因为这个示例只是有一个单独的渲染目标,你可以直接设定顶点的浮点值作为功能函数的输出。这些输入是被写入到渲染目标的颜色值。
光栅化程序阶段为每个片段的属性计算出了值并调用片段着色功能函数。光栅化阶段在三角形的顶点上计算了它的颜色属性作为一种混合颜色。片段和顶点越接近,顶点对最终的颜色效果越好。
图四 插入的片段颜色
返回插入的颜色值作为函数的输出:
return in.color;
现在这个功能函数是完整的了,你可以创建一个渲染管道来使用他们。第一,获取默认的库并为每个功能生成一个MTLFunction对象。
id defaultLibrary = [_device newDefaultLibrary];
id vertexFunction = [defaultLibrary newFunctionWithName:@"vertexShader"];
id 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];
为了指定顶点和片段功能函数,你也需要为所有的渲染目标声明pixel format使得管道将被绘画进去。一个像素格式(MTLPixelFormat)定义了像素数据的内存结构。对于简单的格式,这个定义显示了每个像素的字节数,在一个像素数据存储时的频道数,和这些频道的比特结构。因为这个示例只有一个渲染目标并由这个视图提供,所以拷贝这个视图的像素格式到渲染管道的描述符中。你的渲染管道状态必须使用一个可以兼容指定渲染数据传入的像素格式。在这个示例中,渲染数据的传入和管道状态对象都是使用了视图的像素格式,所以他们一直是一样的。
当Metal创建了渲染管道状态对象,这个管道被配置用来转化片段功能函数来输出到渲染对象的像素格式中。如果你想指定一个不同的像素格式目标,你需要创建一个不同的管道状态对象。你可以在多个管道中重复使用这个相同的着色器来指定不同的像素格式。
设置一个视点(Viewport)
现在管道拥有了一个渲染管道状态对象了,你将渲染出这个三角形。你通过使用一个渲染命令编码函数来做这些。第一,设定一个视点,以至于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];
指定图元的类型,开始的位置,和顶点的数量。当三角形被渲染,顶点的功能是通过调研0,1和2的值来作为vertexID的参数。
// Draw the triangle.
[renderEncoder drawPrimitives:MTLPrimitiveTypeTriangle
vertexStart:0
vertexCount:3];
当使用Metal来绘画数据到屏幕的时候,你来结束执行的结束和递交命令的缓冲数据。然而,你可以使用相同的设置步骤来编码更多的渲染命令。最终的图像是被渲染成所指定的需要的命令来执行。(为了性能,GPU被允许并行地执行部分的命令,只要最终的结果是需要被显示的。)
在这个示例中,颜色值插入贯穿了整个三角形。那通常是你想要的,但是有时你想一个值被一个顶点生成并且保持一个常量贯穿整个图元。指定一个flat属性修饰符在一个输出顶点功能函数来完成这个。现在试一下这个。在实例项目中找到RasterizerData的定义和添加[[flat]]修饰符到它的颜色区域。
float4 color [[flat]];
再次跑一下这个示例程序。渲染管道使用了来自整个三角形的第一个顶点的颜色值(叫做provoking vertex),并且它忽略了来自其他两个顶点的颜色值。你可以使用混合的flat着色器和插入的值,简单地添加或者省略这个flat修饰符在你的顶点功能函数的输出中。Metal着色器语言的文档定义了其他属性修饰符,你也可以使用他们来修改光栅化的行为。
官方文档链接:https://developer.apple.com/documentation/metal/using_a_render_pipeline_to_render_primitives
注:本文仅作翻译学习研究,如有翻译不恰当,希望指出改进。