Metal(2)——三角形案例

1,002 阅读8分钟

效果图如下

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 的结构体,它有两个四维向量,clipSpacePositioncolor,结构与 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 的限定符,规定具体是哪类型的着色器,包括 vertexfragmentkernel。 这里和普通的函数没什么差别。定义了一个名为 vertexShader 的函数,它的返回值类似是 RasterizerData,之前定义的顶点数据类型。同时,vertex 限定符表示它是顶点着色器。

函数的入参有三个:

  1. uint vertexID [[vertex_id]][[vertex_id]]内置的属性限定符表明 vertexID参数代表着顶点下标,数据类型是 uint。针对这个三角形,我们传入了三个顶点,所以 vertexID 的取值范围是 [0, 2]。
  2. constant ZVertex *vertices [[buffer(ZVertexInputIndexVertices)]]ZVertexInputIndexVerticesShaderTypes.h定义的一个枚举值,用来区分顶点着色器输入参数对应的下标。[[buffer(index)]] 也是一个限定符,它指明我们的数据是存放在哪块内存区域,即 buffer 对应索引。ZVertex *vertices 则是我们之前在上层定义的顶点数据数组,这里上/下层都使用相同的数据类型,能保证内存布局的一致。
// 缓存区索引值 共享与 shader 和 C 代码 为了确保Metal Shader缓存区索引能够匹配 Metal API Buffer 设置的集合调用
typedef enum ZVertexInputIndex
{
    //顶点
    ZVertexInputIndexVertices     = 0,
    //视图大小
    ZVertexInputIndexViewportSize = 1,
} ZVertexInputIndex;
  1. 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在这里