效果图如下
1.顶点数据
先来复习渲染管线,渲染管线可以理解成从顶点数据(Vertices)到屏幕显示的像素点(Pixels)的过程。
所以第一步,我们需要准备需要传入渲染管线的顶点数据。
如图所示的,我们需要 3 个顶点来组成一个三角形,并以数组的形式传递这 3 个顶点作为图形渲染管线的输入,这个数组叫做顶点数据(Vertex Data),顶点数据是一系列顶点的集合。在这个例子里每个顶点只由一个位置和一个颜色值组成。
创建一个名为ShaderTypes.h的头文件,在里面定义一个ZVertex结构体,将顶点信息保存在结构体中,以供后续传递和使用。单独定义一个头文件的目的是为了能够在.metal、OC、.siwft文件中,能够共享数据类型和枚举值。
/*
介绍:
头文件包含了 Metal shaders 与C/OBJC 源之间共享的类型和枚举常数
*/
#include <simd/simd.h>
//结构体: 顶点/颜色值
typedef struct
{
// 像素空间的位置
vector_float4 position;
// RGBA颜色
vector_float4 color;
} ZVertex;
在这里定义了一个vector_float4类型的位置坐标(xyzw)和颜色值(rgba)。
vector_float4 是在** SIMD** 库中定义的基础数据类型。SIMD 库为图形处理定义了一系列常用数据类型,比如向量,矩阵,以及它们对应的一些便捷操作。
它定义的数据类型,在工程中(.swift、.m)中可以直接使用,在着色器(.metal)中也可以直接使用,保证了类型、内存分布的一致。
有了表示顶点信息的结构体,就可以使用它来创建顶点数据了。
//1. 顶点数据/颜色数据
let triangleVertices = [ZVertex(position: [0.5, -0.25, 0.0, 1.0], color: [1.0, 0.0, 0.0,1.0]),
ZVertex(position: [-0.5, -0.25, 0.0, 1.0], color: [0.0, 1.0, 0.0, 1.0]),
ZVertex(position: [0.0, 0.25, 0.0, 1.0], color: [0.0, 0.0, 1.0, 1.0])
]
这里创建的坐标范围在[-1,1]之间,因为我们要将本地坐标转化为裁剪系坐标(Clip space)的坐标值。即将显示区域(目前是屏幕完整区域)映射到到 X:[-1, 1],y:[-1, 1] 所在的矩形区域内。
在顶点着色器中最重要的任务是执行顶点坐标变换,但是这个三角形绘制里头,顶点数据较为简单,我们外部传入的时候,直接是换算后的坐标,避免不必要的操作。
2. 顶点着色器
创建一个名为 Shaders的 .metal文件。顶点着色器和片段着色器的代码都写在这个文件里面。关于编写 Metal 着色器用到的语言,MSL(Metal Shading Language)将在后面的篇章中详细介绍。
引入必要的头文件和命名空间。
#include <metal_stdlib>
//使用命名空间 Metal
using namespace metal;
// 导入Metal shader 代码和执行Metal API命令的C代码之间共享的头
#import "ShaderTypes.h"
接着定义了一个名为 RasterizerData 的结构体,它有两个四维向量,clipSpacePosition和color,结构与 ZVertex相似,它们是对应的,只是作用在了不同阶段。
- ZVertex 作用在 Vertices —> Vertex function 这个阶段,作为入参数据类型传入顶点着色器中。
- RasterizerData 作用在 Vertex function —> Rasterization —> Fragment function 这个阶段。它作为顶点着色器的输出,传入到片段着色器中,作为输入。期间经过 Rasterization,做固定的光栅化操作。
RasterizerData 结构体:
// 顶点着色器输出和片段着色器输入
//结构体
typedef struct {
//处理空间的顶点信息
float4 clipSpacePosition [[position]];
//颜色
float4 color;
} RasterizerData;
顶点着色函数
vertex RasterizerData vertexShader(uint vertexID [[vertex_id]],
constant ZVertex *vertices [[buffer(ZVertexInputIndexVertices)]],
constant vector_uint2 *viewportSizePointer [[buffer(ZVertexInputIndexViewportSize)]])
{
/*
处理顶点数据:
1) 执行坐标系转换,将生成的顶点剪辑空间写入到返回值中.
2) 将顶点颜色值传递给返回值
*/
//定义out
RasterizerData out;
//剪辑空间位置赋值
out.clipSpacePosition = vertices[vertexID].position;
//把我们输入的颜色直接赋值给输出颜色. 这个值将于构成三角形的顶点的其他颜色值插值,从而为我们片段着色器中的每个片段生成颜色值.
out.color = vertices[vertexID].color;
//完成! 将结构体传递到管道中下一个阶段:
return out;
}
这里 vertex 是 function 的限定符,规定具体是哪类型的着色器,包括 vertex、fragment、kernel。
这里和普通的函数没什么差别。定义了一个名为 vertexShader 的函数,它的返回值类似是 RasterizerData,之前定义的顶点数据类型。同时,vertex 限定符表示它是顶点着色器。
函数的入参有三个:
uint vertexID [[vertex_id]],[[vertex_id]]内置的属性限定符表明vertexID参数代表着顶点下标,数据类型是uint。针对这个三角形,我们传入了三个顶点,所以vertexID的取值范围是 [0, 2]。constant ZVertex *vertices [[buffer(ZVertexInputIndexVertices)]]。ZVertexInputIndexVertices是ShaderTypes.h定义的一个枚举值,用来区分顶点着色器输入参数对应的下标。[[buffer(index)]]也是一个限定符,它指明我们的数据是存放在哪块内存区域,即buffer对应索引。ZVertex *vertices则是我们之前在上层定义的顶点数据数组,这里上/下层都使用相同的数据类型,能保证内存布局的一致。
// 缓存区索引值 共享与 shader 和 C 代码 为了确保Metal Shader缓存区索引能够匹配 Metal API Buffer 设置的集合调用
typedef enum ZVertexInputIndex
{
//顶点
ZVertexInputIndexVertices = 0,
//视图大小
ZVertexInputIndexViewportSize = 1,
} ZVertexInputIndex;
constant vector_uint2 *viewportSizePointer [[buffer(ZVertexInputIndexViewportSize)]]。 与上面类似的,传递进来的是视口大小。在buffer中的的下标是1
3. 光栅化
光栅化阶段,会把通过顶点绘制出来的图元,进行判断,拆分,筛选。保留下最终需要显示的像素点。 然后逐像素传入片段着色器。如下:
每个片段的坐标,色值,都会根据实际的位置,线性计算出。如下所示:
4. 片段着色器
fragment float4 fragmentShader(RasterizerData in [[stage_in]])
{
//返回输入的片元颜色
return in.color;
}
同样,创建一个用 fragment修饰的方法 fragmentShader表明这是一个片段着色器函数。传入参数的类型是同顶点着色器传出数据类型一样的 RasterizerData,但是是经过光栅化处理后生成的数据, [[stage_in]]限定符,表示这个参数是光栅化处理后的片段。
片段着色器的主要作用是计算每一个片段最终的颜色值。 所以它的返回值应该是一个 float4 类型的色值。这里我们简单的返回插值后的色值即可。
5. 加载着色器
在上一个小案例里,由于我们没有使用自定义的metal文件,所以我们在初始化Render实例对象的时候,只获取了device和创建了commandQueue。接下来,我们看一下如何加载在metal里编写的着色器程序。
//2.在项目中加载所有的(.metal)着色器文件
// 从bundle中获取.metal文件
let defaultLibrary = device?.makeDefaultLibrary()
//从库中加载顶点函数
let vertexFunction = defaultLibrary?.makeFunction(name: "vertexShader")
//从库中加载片元函数
let fragmentFunction = defaultLibrary?.makeFunction(name: "fragmentShader")
苹果大大已经编译好了,直接获取就好了。是不是比OpenGL 的手动创建和编译方便多了。但是这是是完成了编译 我们还需要把着色器链接到渲染管道上。
6. 渲染管道
在 Metal 里面,MTLRenderPipelineState 就是对渲染管线的描述。同样的,它的具体配置需要依赖 MTLRenderPipelineDescriptor 对象来完成。
//3.配置用于创建管道状态的管道
let pipelineStateDescriptor = MTLRenderPipelineDescriptor()
//管道名称
pipelineStateDescriptor.label = "Simple Pipeline"
//可编程函数,用于处理渲染过程中的各个顶点
pipelineStateDescriptor.vertexFunction = vertexFunction
//可编程函数,用于处理渲染过程中各个片段/片元
pipelineStateDescriptor.fragmentFunction = fragmentFunction
//一组存储颜色数据的组件
pipelineStateDescriptor.colorAttachments[0].pixelFormat = mtkView.colorPixelFormat
//4.同步创建并返回渲染管线状态对象
pipelineState = try! device?.makeRenderPipelineState(descriptor: pipelineStateDescriptor)
7. 渲染
前期准备工作完毕, 就差最后一步的渲染了。 固定的渲染流程我们已经在上个小案例里说明了。 这离重点讲一下如何利用编码器绘图
7.1 设置视口
视口指定Metal渲染内容的可绘制drawable区域。 视口是具有x和y偏移,宽度和高度以及近和远平面的3D区域。 为管道分配自定义视口需要通过调用setViewport:方法将MTLViewport结构编码为渲染命令编码器。 如果未指定视口,Metal会设置一个默认视口,其大小与用于创建渲染命令编码器的drawable相同。
let viewPort = MTLViewport(originX: 0.0, originY: 0.0, width: Double(viewportSize.x), height: Double(viewportSize.y), znear: -1.0, zfar: 1.0)
renderEncoder?.setViewport(viewPort)
7.2 设置当前渲染管道状态
我们就需要把渲染管线和 Command Encoder 关联起来,这样之后关于指令的操作,才能知道要在哪个管线上执行。
renderEncoder?.setRenderPipelineState(pipelineState)
7.3 发送数据到顶点着色器
顶点数据发送给顶点着色器
renderEncoder?.setVertexBytes(triangleVertices,
length: MemoryLayout<ZVertex>.size*triangleVertices.count,
index: Int(ZVertexInputIndexVertices.rawValue))
triangleVertices 是我们在前面创建的顶点数据,一个包括的顶点位置和顶点颜色的数组。
length 我们想要传递的数据的内存大小
index 一个整数索引,它对应于我们的“vertexShader”函数中的缓冲区属性限定符的索引。
viewportSize发送给顶点着色器
renderEncoder?.setVertexBytes(&viewportSize,
length: MemoryLayout.size(ofValue: viewportSize),
index: Int(ZVertexInputIndexViewportSize.rawValue))
7.4 图元装配
最后要设置图元装配的方式。 Metal里有以下 5 中图元装配方式。
/*
MTLPrimitiveTypePoint = 0, 点
MTLPrimitiveTypeLine = 1, 线段
MTLPrimitiveTypeLineStrip = 2, 线环
MTLPrimitiveTypeTriangle = 3, 三角形
MTLPrimitiveTypeTriangleStrip = 4, 三角型扇
*/
renderEncoder?.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
这是完图元装配以后,就可以结束编码,提交绘制了。 完整demo在这里