这篇笔记主要是关于一个 Metal 的综述,以及最简单的 Metal 应用的结构。
渲染流程
Metal 的渲染流程可以总结为四步:
启动 Metal
加载模型
设置渲染管线
渲染
和很多其它的图形 API 一样,这个过程虽然很简洁明了,但是每一个步骤都需要很多底层的指令来完成。在本篇这个最简单的应用中,我们不会介绍每个部分过多的特性,只会从一个比较顶层的视角来观察大概的流水线过程。
启动 Metal
Metal 需要通过 Xcode 进行编译,我们使用 Xcode 中的新的 macOS 的 Blank Playground 来进行本篇的演示。(File > New > Playground > macOS > Blank)
窗口
窗口的管理主要使用的是苹果官方提供的 MetalKit 库。为了在 Playground 中能够用辅助编辑器实时观看我们的渲染结果,因此,import部分我们使用这两个内容。
import PlaygroundSupport
import MetalKit
创建窗口基于 MetalKit 中的 MTKView 类。
// MTKView 需要一个支持 Metal 的 GPU 设备,所以第一步是声明一个 device 常量
guard let device = MTLCreateSystemDefaultDevice() else {
fatalError()
}
// 设置窗口大小
let frame = CGRect(x: 0, y: 0, width: 600, height: 600)
// 声明 MTKView
let view = MTKView(frame: frame, device: device)
// 清除背景颜色
view.clearColor = MTLClearColorMake(red: 1, green: 1, blue: 1, alpha: 1)
加载模型
Metal 中被加载的模型也被称作图元(primitive)。这里我们先用 MDLMesh 来制作一个球,而不从外部文件读取一个 Mesh。之后我们也会讨论如何读取一个外部的模型文件。
// 对于一个图元,我们需要一个 allocator 来管理它的内存
let allocator = MTKMeshBufferAllocator(device: device)
// 使用 MDLMesh 制作一个球的 mesh
let mdlMesh = MDLMesh(sphereWithExtent: [0.25, 0.25, 0.25],
segments: [100, 100],
inwardNormals: false,
geometryType: .triangles,
allocator: allocator)
// 将其转换成一个 MTKMesh,这样的话 Metal 才能使用它
let mesh = try MTKMesh(mesh: mdlMesh, device: device)
设置渲染管线
着色器设置
Metal 渲染管线的具体内容我们会在下一篇笔记中介绍,但实际上不同 API 的渲染管线大同小异。对于 Metal 而言,最重要的两个部分依然是顶点着色器和片元着色器,苹果官方对它们的命名是顶点函数(Vertex Function)和片元函数(Fragment Function)。我们这里尊重官方,用这两个称呼。
正式的项目中,它们是使用 Metal Shading Language (MSL) 来书写的 .metal 文件,MSL 是 C++ 的一个子集,遵循 C++ 的基本使用语法。在这里,我们先用读取代码字符串的方式来写一个最基本的顶点和片元函数。
let shader = """
#include <metal_stdlib>
using namespace metal;
struct VertexIn {
float4 position [[attribute(0)]];
};
vertex float4 vert(const VertexIn vertex_in [[stage_in]]) {
return vertex_in.position;
}
fragment float4 frag() {
return float4(1,0,0,1);
}
"""
// 设置着色器
let library = try device.makeLibrary(source: shader, options: nil)
let vert = library.makeFunction(name: "vert")
let frag = library.makeFunction(name: "frag")
渲染状态机状态设置
与其它 API 一样,Metal 也采取了状态机的策略。状态机的特性就是在状态更改之前,设备中所有的信息可以默认为不会变化,这样的话显卡工作的效率也就会更高。Metal 需要通过一种叫做描述符(descriptor)的媒介来告知渲染管线它需要知道的信息。
渲染状态机的描述符需要有以下的信息,并可以通过以下的代码进行设置。
// 状态机描述符
let pipelineDescriptor = MTLRenderPipelineDescriptor()
// 描述读取颜色的顺序为 r/g/b/a,每个颜色用8位无符号整数描述
pipelineDescriptor.colorAttachments[0].pixelFormat = .rgba8Uint
// 描述顶点函数
pipelineDescriptor.vertexFunction = vert
// 描述片元函数
pipelineDescriptor.fragmentFunction = frag
// 描述顶点描述符(这个概念我们之后会介绍)
pipelineDescriptor.vertexDescriptor = MTKMetalVertexDescriptorFromModelIO(mesh.vertexDescriptor)
// 制成渲染状态
let rpState = try device.makeRenderPipelineState(descriptor: pipelineDescriptor)
最终做成的 rpState 被称为一个渲染管线状态对象(Render Pipeline State Object)。
渲染
渲染指令封装
每一帧都包含着我们给 GPU 发送的多条指令。这些指令需要通过渲染指令编码器(Render Command Encoder)封装。然后,渲染指令缓冲区(Render Command Buffer)封装多个指令编码器。最终,渲染指令队列(Render Command Queue)按照顺序封装多个渲染指令缓冲区。
从层级上来说:
渲染指令队列(RCQ)被生成一次,管理所有的渲染指令缓冲区的执行顺序。
渲染指令缓冲区(RCB)装填渲染指令。
渲染指令编码器(RCE)装填每一帧的渲染命令。每一帧都会被生成。
因此,我们先声明一个渲染指令队列,作为指令的执行依据。
guard let commandQueue = device.makeCommandQueue() else {
fatalError()
}
然后,声明一个指令缓冲区和指令编码器。
guard
// 生成渲染指令缓冲区
let commandBuffer = commandQueue.makeCommandBuffer(),
// 生成渲染通道描述符
let renderPassDescriptor = view.currentRenderPassDescriptor,
// 生成渲染指令编码器,需要使用通道描述符
let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor)
else { fatalError() }
在这里,出现了一个渲染通道(Render Pass)的概念。每次渲染可以执行数个渲染通道,它们可以各司其职,比如有的负责渲染阴影,有的负责渲染颜色,有的负责渲染反射。我们将渲染通道合并最终输出成一个屏幕上的像素颜色。渲染通道描述符的作用就是告知渲染的目的地,例如是渲染到某个纹理上,或是是否需要在渲染通道中保留纹理等。
在渲染指令编码器上,我们输入我们对显卡的命令。在这里,我们设置渲染状态以及设置需要渲染的 mesh 的顶点缓冲区。
// 设置顶点状态
renderEncoder.setRenderPipelineState(rpState)
// 设置顶点缓冲区
renderEncoder.setVertexBuffer(mesh.vertexBuffers[0].buffer, offset: 0, index: 0)
副网格
有的时候我们的 Mesh 可能是由多个 Submesh(副网格)组成的,这是因为艺术家在制作模型的时候可能会给不同的副网格不同的材质。在目前这个最简单的例子中,副网格只有一个,因为我们只有一个简单的球。所以,设置副网格的步骤也很简单。
guard let submesh = mesh.submeshes.first else { fatalError() }
渲染指令发送
最后,我们需要将渲染指令 Draw Call 发送给 GPU。我们通过的是编码器类里的 drawIndexedPrimitives( : : : : : ) 函数来完成。
renderEncoder.drawIndexedPrimitives(type: .triangle,
indexCount: submesh.indexCount,
indexType: submesh.indexType,
indexBuffer: submesh.indexBuffer.buffer,
indexBufferOffset: 0)
然后,我们结束我们的渲染指令读入,并且让我们的渲染指令缓冲最终确定。
// 结束读入
renderEncoder.endEncoding()
guard let drawable = view.currentDrawable else {
fatalError()
}
// 确定渲染指令缓冲
commandBuffer.present(drawable)
commandBuffer.commit()
// 渲染在 Playground 的屏幕上
PlaygroundPage.current.liveView = view
参考资料:
Metal by Tutorials, Beginning Game Engine Development With Metal, by Caroline Begbie & Marius Horga
Using Metal to Draw a View’s Contents, https://developer.apple.com/documentation/metal/using_metal_to_draw_a_view_s_contents
Using a Render Pipeline to Render Primitives, https://developer.apple.com/documentation/metal/using_a_render_pipeline_to_render_primitives
Comments