一、编程指南PDF下载链接(中英文档)
-
1、Metal编程指南PDF链接
https://github.com/dennie-lee/ios_tech_record/raw/main/Metal学习PDF/Metal 编程指南.pdf -
2、Metal着色语言(Metal Shader Language:简称MSL)编程指南PDF链接
https://github.com/dennie-lee/ios_tech_record/raw/main/Metal学习PDF/Metal 着色语言指南.pdf -
3、补充:官网API文档链接
https://developer.apple.com/documentation/metal/using_a_render_pipeline_to_render_primitives
二、内容前述
此示例展示如何配置渲染管线并将其用作渲染通道的一部分,以将简单的2D彩色三角形绘制到视图中。该示例为每个顶点提供位置和颜色,渲染管线使用该数据渲染三角形,在为三角形顶点指定的颜色之间插入颜色值。效果如下
三、了解Metal渲染管线
渲染管线处理绘图命令并将数据写入渲染通道的目标。渲染管线有许多阶段,一些使用着色器编程,另一些具有固定或可配置的行为。此示例侧重于管道的三个主要阶段:顶点阶段、光栅化阶段和片段阶段。顶点阶段和片段阶段是可编程的,可以使用Metal着色语言(MSL)编写函数。光栅化阶段具有固定的行为,如下图所示:
渲染从绘图命令开始,其中包括顶点数和要渲染的图元类型。 例如,这是此示例中的绘图命令:
// Draw the triangle.
[renderEncoder drawPrimitives:MTLPrimitiveTypeTriangle
vertexStart:0
vertexCount:3];
顶点阶段为每个顶点提供数据。当处理了足够多的顶点后,渲染管线将图元光栅化,确定渲染目标中的哪些像素位于图元的边界内。 片段阶段确定要写入这些像素的渲染目标的值。
四、渲染管道处理数据
顶点函数为单个顶点生成数据,片段函数为单个片段生成数据,可以自定义它们的工作方式。在配置管道阶段时要牢记目标,想象希望管道生成什么以及它如何生成这些结果。决定将哪些数据传递到渲染管道以及将哪些数据传递到管道的后续阶段。通常在三个地方执行此操作:
管道的输入,由应用程序提供并传递到顶点阶段。
顶点阶段的输出,传递到光栅化阶段。
片段阶段的输入,由应用程序提供或由光栅化阶段生成。
在此示例中,管道的输入数据是顶点的位置及其颜色。演示了通常在顶点函数中执行的变换类型,输入坐标在自定义坐标空间中定义,以距视图中心的像素为单位进行测量。这些坐标需要转换为 Metal 的坐标系。
声明一个Vertex 结构,使用SIMD向量类型来保存位置和颜色数据。要共享结构在内存中的布局方式的单一定义,请在公共标头中声明结构并将其导入Metal着色器和应用程序。
//输入的顶点数据信息
typedef struct {
//向量:顶点位置
vector_float2 position;
//向量:颜色值
vector_float4 color;
} Vertex;
SIMD类型在Metal Shading Language中很常见,使用simd库也是正常。SIMD类型包含特定数据类型的多个通道,因此将位置声明为vector_float2意味着它包含两个32位浮点值(将保存x和y坐标)。颜色使用vector_float4存储,因为它们有四个通道:红色、绿色、蓝色和alpha。
在应用程序中,输入数据使用常量数组指定:
//三个顶点位置和颜色值
static const Vertex vertices[] = {
//位置 //颜色值
{{250,-250},{1.0,0.0,0.0,1.0}},
{{-250,-250},{0.0,1.0,0.0,1.0}},
{{0,250}, {0.0,0.0,1.0,1.0}},
};
顶点阶段为顶点生成数据,需要提供颜色和变换后的位置。再次使用SIMD类型声明一个包含位置和颜色值的RasterizerData结构。
struct RasterizerData{
//[[position]]:在顶点着色函数中,表示当前的顶点信息,类型是float4、
//还可以表示描述了片元的窗口的相对坐标(x,y,z,1/w),
//即该像素点在屏幕上的位置信息
//----[[position]]----请在着色函数编程指南文档查看解释
float4 position [[position]];
//颜色值
float4 color;
};
输出位置(在下面详细描述)必须定义为vector_float4。颜色的声明与输入数据结构中的颜色相同。
因为Metal需要知道光栅化数据中的哪个字段提供位置数据,而Metal不会对结构中的字段强制执行任何特定的命名约定,所以使用[[position]]属性限定符注释位置字段以声明该字段保存输出位置。
片段函数只是将光栅化阶段的数据传递给后面的阶段,因此它不需要任何额外的参数。
五、定义顶点着色函数
声明顶点函数,包括它的输入参数和它输出的数据。就像使用kernel关键字声明计算函数一样,使用vertex关键字声明顶点函数。(kernel:可查看这篇文章https://blog.csdn.net/qqwyuli/article/details/130785820)
//[[vertex_id]] :顶点id标识符,并不由开发者传递
//属性修饰符"[[buffer(index)]]" 为着色函数参数设定了缓存的位置
vertex RasterizerData vertexShader(uint vertexID [[vertex_id]],
constant Vertex* vertices [[buffer(VertexInputIndexVertices)]],
constant vector_uint2 *viewPortSizePointer [[buffer(VertexInputIndexViewportSize)]])
第一个参数vertexID使用[[vertex_id]]属性限定符,Metal的一个关键字。执行渲染命令时,GPU会多次调用顶点函数,为每个顶点生成一个唯一值。
第二个参数 vertices 是一个包含顶点数据的数组,使用之前定义的Vertex结构。
要将位置转换为Metal的坐标,该函数需要绘制三角形的视口大小(以像素为单位),因此它存储在 viewportSizePointer 参数中。
第二个和第三个参数具有[[buffer(n)]]属性限定符。默认情况下,Metal会自动为每个参数分配参数表中的槽。当将[[buffer(n)]]限定符添加到缓冲区参数时,明确地告诉Metal要使用哪个插槽。显式声明插槽可以更轻松地修改着色器,而无需更改应用程序代码。在共享头文件中声明两个索引的常量,如下所示:
typedef enum AAPLVertexInputIndex
{
VertexInputIndexVertices = 0,
VertexInputIndexViewportSize = 1,
} AAPLVertexInputIndex;
该函数的输出是一个 RasterizerData 结构。
六、编写顶点函数
顶点函数必须生成输出结构的两个字段。使用vertexID参数索引顶点数组并读取顶点的输入数据。此外,检索视口尺寸
float2 pixelSpaceXY = vertices[vertexID].position.xy;
vector_float2 viewPortSize = vector_float2(*viewPortSizePointer);
顶点函数必须提供裁剪空间坐标中的位置数据,裁剪空间坐标是使用四维齐次向量(x,y,z,w)指定的3D点。光栅化阶段采用输出位置并将x、y和z坐标除以w,以在标准化设备坐标中生成3D点。规范化设备坐标与视口大小无关。
规范化设备坐标使用左手坐标系并映射到视口中的位置。基元被裁剪到这个坐标系中的一个盒子,然后光栅化。裁剪框的左下角位于(x,y)坐标(-1.0,-1.0),右上角位于 (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,1.0);
out.position.xy = pixelSpaceXY / (viewPortSize / 2);
最后将颜色值复制到out.color返回值中
out.color = vertices[vertexID].color;
完整的顶点着色函数
//[[vertex_id]] :顶点id标识符,并不由开发者传递
//属性修饰符"[[buffer(index)]]" 为着色函数参数设定了缓存的位置
vertex RasterizerData vertexShader(uint vertexID [[vertex_id]],
constant Vertex* vertices [[buffer(VertexInputIndexVertices)]],
constant vector_uint2 *viewPortSizePointer [[buffer(VertexInputIndexViewportSize)]]){
RasterizerData out;
float2 pixelSpaceXY = vertices[vertexID].position.xy;
vector_float2 viewPortSize = vector_float2(*viewPortSizePointer);
out.position = vector_float4(0,0,0,1.0);
out.position.xy = pixelSpaceXY / (viewPortSize / 2);
out.color = vertices[vertexID].color;
return out;
};
七、编写片段着色函数(片元着色函数)
片段是对呈现目标的可能更改。光栅化器确定渲染目标的哪些像素被基元覆盖。仅渲染像素中心位于三角形内的片段。
片段函数处理来自单个位置的光栅化器的传入信息,并计算每个渲染目标的输出值。这些片段值由管道中的后续阶段处理,最终写入渲染目标。
注意:
片段被称为可能更改的原因是片段阶段之后的管道阶段可以配置为拒绝某些片段或更改写入渲染目标的内容。在此示例中,片段阶段计算的所有值都按原样写入渲染目标。
此示例中的片段着色器接收与顶点着色器输出中声明的相同参数。使用fragment关键字声明片段函数。它采用单个参数,与顶点阶段提供的RasterizerData结构相同。添加 [[stage_in]]属性限定符以指示此参数由光栅器生成。
返回插值颜色作为函数的输出
return in.color;
完整的片元着色函数
fragment float4 fragmentShader(RasterizerData in [[stage_in]]){
return in.color;
};
八、创建渲染Pipeline State对象
顶点函数和片元函数已经完成,可以创建一个使用它们的渲染管道。首先,获取默认库并为每个函数获取一个MTLFunction对象。
id<MTLLibrary> defaultLibrary = [_device newDefaultLibrary];
id<MTLFunction> vertexFunction = [defaultLibrary newFunctionWithName:@"vertexShader"];
id<MTLFunction> fragmentFunction = [defaultLibrary newFunctionWithName:@"fragmentShader"];
接下来,创建一个 MTLRenderPipelineState 对象。渲染管道有更多阶段需要配置,可以使用 MTLRenderPipelineDescriptor 来配置管道。
MTLRenderPipelineDescriptor *descriptor = [[MTLRenderPipelineDescriptor alloc] init];
descriptor.label = @"Simple pipeline";
descriptor.vertexFunction = vertexFunction;
descriptor.fragmentFunction = fragmentFunction;
descriptor.colorAttachments[0].pixelFormat = view.colorPixelFormat;
_pipelineState = [_device newRenderPipelineStateWithDescriptor:descriptor error:&error];
除了指定顶点和片段函数外,还声明了管道将绘制到的所有渲染目标的像素格式。像素格式(MTLPixelFormat)定义了像素数据的内存布局。对于简单格式,此定义包括每个像素的字节数、存储在像素中的数据通道数以及这些通道的位布局。由于此示例只有一个渲染目标并且由视图提供,因此将视图的像素格式复制到渲染管道描述符中。渲染管道状态必须使用与渲染通道指定的像素格式兼容的像素格式。在此示例中,渲染通道和管道状态对象都使用视图的像素格式,因此它们始终相同。
当 Metal 创建渲染管道状态对象时,管道被配置为将片段函数的输出转换为渲染目标的像素格式。如果要针对不同的像素格式,则需要创建不同的管道状态对象。可以在针对不同像素格式的多个管道中重复使用相同的着色器。
九、设置视口
现在有了管道的渲染管道状态对象,将要渲染三角形。可以使用渲染命令编码器来执行此操作。首先,设置视口,以便 Metal 知道要绘制到渲染目标的哪一部分。
[commandEncoder setViewport:(MTLViewport){0.0,0.0,_viewPortSize.x,_viewPortSize.y,0.0,1.0}];
十、设置渲染Pipeline State
为要使用的管道设置渲染管道状态。
[commandEncoder setRenderPipelineState:_pipelineState];
十一、将参数数据发送到顶点函数
通常使用缓冲区(MTLBuffer)将数据传递给着色器。然而,当只需要将少量数据传递给顶点函数时,就像这里的情况一样,将数据直接复制到命令缓冲区中。
该示例将两个参数的数据复制到命令缓冲区中。顶点数据是从示例中定义的数组中复制的。视口数据是从用于设置视口的同一变量复制而来的。
在此示例中,片段函数仅使用它从光栅器接收的数据,因此没有要设置的参数。
//设置顶点着色器参数
[commandEncoder setVertexBytes:vertices length:sizeof(vertices) atIndex:VertexInputIndexVertices];
[commandEncoder setVertexBytes:&_viewPortSize length:sizeof(_viewPortSize) atIndex:VertexInputIndexViewportSize];
十二、对绘图命令进行编码
指定图元的种类、起始索引和顶点数。渲染三角形时,将使用vertexID参数的值 0、1 和2调用顶点函数。
[commandEncoder drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:3];
与使用Metal绘制到屏幕一样,结束编码过程并提交命令缓冲区。但是,可以使用同一组步骤对更多渲染命令进行编码。最终图像呈现为好像命令是按照指定的顺序处理的。(为了性能,允许 GPU 并行处理命令甚至部分命令,只要最终结果看起来是按顺序呈现的。)
十三、尝试颜色插值
在此示例中,颜色值被插值到整个三角形中 这通常是想要的效果,但有时希望一个值由一个顶点生成并在整个图元中保持不变。在顶点函数的输出上指定平面属性限定符来执行此操作。现在试试这个,在示例项目中找到RasterizerData的定义,并将[[flat]]限定符添加到其颜色字段。
float4 color [[flat]];
再次运行示例。渲染管线在整个三角形上统一使用第一个顶点(称为激发顶点)的颜色值,并忽略其他两个顶点的颜色。可以混合使用平面着色和插值,只需在顶点函数的输出中添加或省略平面限定符即可。金属着色语言规范定义了其他属性限定符,也可以使用它们来修改光栅化行为。
十四、完整代码
例子:github链接:https://github.com/dennie-lee/MetalDrawTriangleDemo