本系列的学习笔记是我读《Metal by Tutorials》一书的读书笔记,这本书可以在Metal by Tutorials 这里购买,附带源代码可以从 Metal by Tutorials Source Code 这里下载到。这是一本入门学习Metal特别棒的书,全书分为四个部分,共有32个章节:
初级 | 中级 | 高级 | 光线追踪 |
3D模型 | 映射和材质 | 镶嵌和地形 | 光线渲染 |
渲染管线 | 渲染 | 片元后处理 | 高级阴影 |
顶点函数 | 阴影 | 基于图片的光照 | 高级光照 |
3D变换 | 延迟渲染 | 反射和折射 | Metal性能着色器 |
坐标空间 | 基于瓦片的延迟渲染 | 动画 | 性能优化 |
片元函数 | GPU计算编程 | 角色动画 | 最佳实践 |
纹理 | 粒子系统 | 素材管理 | |
3D导航 | 粒子行为 | GPU驱动渲染 | |
光照基础 |
Metal是如何诞生的
从历史上看,您有两种选择来利用GPU的能力:OpenGL和仅Windows可用的DirectX。 2013年,GPU供应商AMD宣布了Mantle项目,致力于改进GPU API,作为Direct3D(DirectX的一部分)和OpenGL的替代方案。 AMD是第一个创建一个真正的低开销API,实现对GPU的底层访问。 Mantle承诺能够比类似的API生成高达9倍的draw calls(绘制调用,绘制到屏幕的对象数),并且还引入了异步命令队列,以便图形和计算负载可以并行运行。不幸的是,该项目在成为主流API之前已终止。
Metal于2014年6月2日在全球开发人员会议(WWDC)上发布,最初仅在A7或更新的GPU上提供。苹果创建了一种新的语言,可以直接通过着色器函数对GPU进行编程。这是基于C ++ 11规范的Metal着色器语言(MSL)。一年后,在WWDC 2015年,苹果宣布了两个Metal子框架:Metalkit和Metal Performance Shaders(MPS)。在2018年,MPS作为光线追踪加速器闪亮登场。
API继续发展,以与Apple内部设计的新Apple GPU的令人兴奋的功能合作。Metal 2增加了对虚拟现实(VR),增强现实(AR)和加速机器学习(ML)的支持,包括许多新功能,包括图像块,瓷砖阴影和线程组共享。Metal着色器语言现在基于C ++ 14规范。
2022年,Apple推出了Metal 3,并带有新的框架MetalFX,以提高较低的分辨率。Metal 3功能还包括直接从磁盘上快速加载纹理,用于添加或减少几何形状的网格着色器以及在Metal中使用C/C +++。
为什么你需要使用Metal
Metal是一流的图形API。这意味着Metal可以赋予图形管线的能力,更具体地说,例如,诸如以下功能:
- Unity和虚幻引擎:当今两个领先的跨平台游戏引擎非常适合针对一系列控制台,台式机和移动设备的游戏程序员。但是,这些引擎并不总是与Metal的新功能保持同步。例如,Unity中Tessellation长期推迟,并且仍然不支持网格着色器。如果您想使用尖端的Metal开发项目并使用苹果芯片的功能,则不能总是依赖第三方引擎。
- 神界-原罪2:Larian Studios与Apple紧密合作,利用Metal和Apple GPU硬件将其惊人的AAA游戏带到iPad。这确实是一次令人惊叹的视觉体验。
- 证人:这款屡获殊荣的益智游戏有一个运行在Metal之上的定制引擎。通过利用Metal,iPad版本与桌面版本一样华丽,强烈建议用于益智游戏迷。
- 许多其他人:从著名的游戏冠军,例如《打人侠》,《生化奇兵》,《 杀出重围》,《黑手党》,《星际争霸》,《魔兽世界》,《堡垒之夜》,《虚幻》,《虚幻的锦标赛》,《蝙蝠侠》,甚至是亲爱的《我的世界》。
但是Metal不仅限于游戏世界。对于图像和视频处理的GPU加速,有许多应用程序受益:
-
Procreate:一款用于素描,绘画和说明的应用。由于转换为Metal,它的运行速度比以前快四倍。
-
Pixelmator:基于Metal的应用程序,可提供图像失真工具。实际上,他们能够实施由Metal 2提供动力的新的绘画引擎和动态涂料混合技术。
-
Affinity Photo:iPad上可用。根据开发人员Serif的说法,“使用Metal,使用户可以轻松地在大型,超高分辨率的照片或具有可能数千层的复杂构图上工作。”
-
Metal,尤其是MPS子框架,在机器和深度学习领域中的卷积神经网络(CNN)非常有用。
什么时候你需要使用Metal
GPU 属于一类特殊的计算,Flynn 的分类法将其称为单指令多数据 (SIMD)。简单地说,GPU 是针对吞吐量(在一个单位时间内可以处理多少数据)进行了优化的处理器,而 CPU 是针对延迟(处理单个数据单位所需的时间)进行了优化的处理器。大多数程序都是串行执行的:它们接收输入、处理输入、提供输出,然后重复循环。
这些周期有时会执行计算密集型任务,例如大型矩阵乘法,这将花费 CPU 大量时间进行串行处理,即使在少数内核上以多线程方式也是如此。
相比之下,GPU 有数百甚至数千个内核,这些内核比 CPU 内核更小,内存更少,但可以执行快速的并行数学计算。
在以下情况下选择 Metal:
• 您希望尽可能高效地渲染 3D 模型。
• 您希望您的游戏具有自己独特的风格,可能带有自定义光照和阴影。
• 您将执行密集的数据处理,例如计算和更改每帧屏幕上每个像素的颜色,就像处理图像和视频时一样。
• 您有大型数值问题 (例如科学模拟),可以将其划分为独立的子问题以进行并行处理。
• 您需要并行处理多个大型数据集,例如在训练深度学习模型时。
下面我们来编写第一个使用Metal渲染的程序,为了简单,我们的第一个程序会用到Swift的playground来编写。我们将会渲染一个红色的球在屏幕上,如下图:
它看起来不够酷,但是它是一个你开始学习Metal的绝妙起点。它可以让你接触到渲染流程的每个部分。在开始之前,让我们先理解一下,什么是“渲染”和“帧”。
什么是渲染
在 3D 计算机图形学中,你把一堆点连接起来,然后在屏幕上创建一个图像。此图像称为渲染。
从点渲染图像涉及计算屏幕上每个像素的明暗。光线会在场景周围反射,因此您必须确定光照的复杂程度以及渲染每个图像需要多长时间。Pixar 电影中的单个图像可能需要几天时间才能完成渲染,但游戏需要实时渲染,您可以立即看到图像。
渲染 3D 图像的方法有很多种,但大多数都是从 Blender 或 Maya 等建模应用程序中构建的模型开始的。以 Blender 中构建的这个火车模型为例:
与所有其他模型一样,此模型由顶点组成。顶点是指三维空间中几何图形的两条或多条线、曲线或边相交的点,例如立方体的角。模型中的顶点数可能从少数(如多维数据集)到数千甚至数百万(更复杂的模型)不等。
3D 渲染器将使用模型加载器代码读取这些顶点,该代码将解析顶点列表。然后,渲染器将顶点传递给 GPU,着色器函数处理顶点,创建最终图像或纹理,发送回 CPU 并显示在屏幕上。
以下渲染使用 3D 火车模型和一些不同的着色技术,使其看起来好像火车是由闪亮的铜制成的:
从导入模型的顶点到在屏幕上生成最终图像的整个过程通常称为渲染管线。渲染管线是发送到 GPU 的命令列表,以及构成最终图像的资源(顶点、材质和光源)。
该管线包括可编程和非可编程功能。管线的可编程部分(称为顶点函数和片段函数)是您可以手动影响渲染模型最终外观的地方。您将在本书的后面部分了解相关API 的更多信息。
什么是帧
如果游戏所做的只是渲染单个静态图像,那么它就不会很有趣。以流畅的方式在屏幕上移动角色需要 GPU 每秒渲染大约 60 次静态图像。每个静态图像称为一个帧,图像显示的速度称为帧速率。
当您最喜欢的游戏出现卡顿时,通常是因为帧速率降低,尤其是在有过多的后台处理,消耗 GPU 的情况下。在设计游戏时,重要的是平衡您想要的和硬件所可以提供的。
虽然添加实时阴影、水面反射和数百万片动画草叶可能很酷(您将在本书中学习如何作),但在可能的情况,和 GPU 可以在 1/60 秒内处理的内容之间,找到适当的平衡可能很困难。
你的第一个Metal程序
在您的第一个 Metal 应用程序中,您将渲染的形状看起来更像一个扁平的圆圈,而不是 3D 球体。这是因为您的第一个模型将不包含任何透视或阴影。但是,其顶点网格包含完整的 3D 信息。
无论应用程序的大小和复杂程度如何,Metal 渲染的过程都大致相同,您将非常熟悉以下在屏幕上绘制模型的顺序:
最初,您可能会对 Metal 所需的步骤数量感到有点不知所措,但请不要担心。您将始终以相同的顺序执行这些步骤,它们将逐渐成为第二天性。
本章不会详细介绍每个步骤,但随着您阅读本书的进度,您将根据需要获得更多信息。现在,请集中精力运行您的第一个 Metal 应用程序。
开始
我们需要打开Xcode,并且创建一个playground,通过在菜单中选择 File -> New -> Playground... 当提示从一个模板中选时,选择macOS Blank。如下:
命名这个playground为Chapter1,并且点击“Create”。
下一步则删除playground中所有预设代码。
Metal View
现在,您有一个 Playground,您将创建一个用来渲染的视图。
➤ 通过添加以下内容导入您将要使用的两个主要框架:
// Playground支持库,让你可以在编辑器中看到实时视图
import PlaygroundSupport
// Metalkit是一个让使用Metal更为方便的框架,它有定制视图类MTKView,
// 以及各种方便加载纹理,使用Metal缓冲的框架,以及Model I/O框架等
import MetalKit
// 通过创建设备来检测一个适合的GPU
guard let device = MTLCreateSystemDefaultDevice() else {
fatalError("GPU is not supported")
}
// 视图大小
let frame = CGRect(x: 0, y: 0, width: 600, height: 600)
// 创建一个MTKView视图
let view = MTKView(frame: frame, device: device)
// 设置视图的清空背景色
view.clearColor = MTLClearColor(red: 1, green: 1, blue: 0.8, alpha: 1)
Model
Model I/O 是与 Metal 和 SceneKit 集成的框架。它的主要目的是加载在 Blender 或 Maya 等应用程序中创建的 3D 模型,并设置数据缓冲区以更轻松地进行渲染。
您将加载Model I/O 基本 3D 形状(也称为基元),而不是加载 3D 模型。3D 基元通常是立方体、球体、圆柱体或圆环。
➤ 将此代码添加到 Playground 的末尾:
// 创建Mesh缓冲分配器,管理mesh数据的内存
let allocator = MTKMeshBufferAllocator(device: device)
// 模型 I/O 创建一个具有指定大小的球体,并返回一个 MDLMesh,其中包含数据缓冲区中的所有顶点信息。
let mdlMesh = MDLMesh(sphereWithExtent: [0.75, 0.75, 0.75],
segments: [100, 100],
inwardNormals: false,
geometryType: .triangles,
allocator: allocator)
// 为了让Metal可以使用这个球体mesh,需要转换成MetalKit的mesh对象
let mesh = try MTKMesh(mesh: mdlMesh, device: device)
队列、缓冲以及编码器
每个帧都包含您发送到 GPU 的命令。您可以将这些命令封装在渲染命令编码器中。命令缓冲区组织这些命令编码器,命令队列组织命令缓冲区。
通过添加如下代码来创建一个命令队列:
guard let commandQueue = device.makeCommandQueue() else {
fatalError("Could not create a command queue")
}
您应该在应用程序开始时设置设备和命令队列,并且通常,您应该在整个过程中使用相同的设备和命令队列。
每个渲染通道必须尽快完成,因此您将在应用程序开始时预加载对象。您需要将模型加载到缓冲区中,生成着色器函数并创建管道状态对象。
在每个帧上,您将创建一个命令缓冲区和至少一个描述渲染通道的渲染命令编码器。渲染命令编码器是一个轻量级对象,用于设置 GPU 的管道状态,并告诉 GPU 在渲染过程中要使用哪些缓冲区。
着色器函数
着色器函数是运行在GPU上的小程序;使用Metal Shading Language(语法类似C++)来编写。
通常,你需要创建一个单独的.metal文件来编写着色器代码,不过对于我们第一个测试程序,我们直接用一个多行字符串来编写着色器代码,以便直接放在我们的playground中。
let shader = """
#include <metal_stdlib>
using namespace metal;
struct VertexIn {
float4 position [[attribute(0)]];
};
vertex float4 vertex_main(const VertexIn vertex_in [[stage_in]])
{
return vertex_in.position;
}
fragment float4 fragment_main() {
return float4(1, 0, 0, 1);
}
"""
这里有两个着色器函数:一个名为 vertex_main 的顶点函数和一个名为 fragment_main 的片段函数。顶点函数通常是计算顶点位置的地方,而片段函数是指定像素颜色的地方。
设置一个Metal library 来包含这两个函数:
let library = try device.makeLibrary(source: shader, options:
nil)
let vertexFunction = library.makeFunction(name: "vertex_main")
let fragmentFunction = library.makeFunction(name:
"fragment_main")
编译器会检测这些函数是否存在,让它们可用于管线状态描述。
管线状态
在 Metal 中,您可以为 GPU 设置管线状态。通过设置此状态,您告诉 GPU 在状态更改之前不会发生任何变化。当 GPU 处于固定状态时,它可以更高效地运行。管线状态包含 GPU 需要的各种信息,例如它应该使用哪种像素格式以及它是否应该使用深度进行渲染。管线状态还包含您刚刚创建的顶点和片段函数。
我们不会直接创建一个管线状态对象,我们需要先创建一个管线状态描述,这个描述会保存管线状态对象需要的一切信息,描述对象的属性较多,大部分可以保持使用默认值,只需要设置我们必要的信息就行。如下代码:
// 流水线状态描述
let pipelineDescriptor = MTLRenderPipelineDescriptor()
// 我们设置颜色为32位色,顺序为蓝/绿/红/不透明通道
pipelineDescriptor.colorAttachments[0].pixelFormat = .bgra8Unorm
// 设置顶点shader函数
pipelineDescriptor.vertexFunction = vertexFunction
// 设置像素shader函数
pipelineDescriptor.fragmentFunction = fragmentFunction
您使用顶点描述符向 GPU 描述顶点如何在内存中分布。Model I/O 在加载球体网格时会自动创建顶点描述符,因此您可以直接使用它。添加如下代码:
// 设置顶点布局,加载球体模型时,Model I/O会自动创建一个顶点描述,我们直接用即可
pipelineDescriptor.vertexDescriptor =
MTKMetalVertexDescriptorFromModelIO(mesh.vertexDescriptor)
如上代码,我们已经把管线状态描述的所有必要信息都设置好了,让我们来创建管线状态对象吧~
// 创建流水线状态对象
let pipelineState =
try device.makeRenderPipelineState(descriptor: pipelineDescriptor)
此代码从描述符创建管线状态。创建管线状态需要宝贵的处理时间,因此以上所有内容都应该是一次性设置。在实际应用中,您可以创建多个管线状态来调用不同的着色函数或使用不同的顶点布局。
渲染
从现在开始,代码应该每帧执行一次。MTKView 有一个每帧执行一次的委托方法,但是由于您正在执行简单的渲染,该渲染将简单地填充静态视图,因此您不需要每帧都刷新屏幕。
在执行图形渲染时,GPU 的最终工作是从 3D 场景输出单个纹理。此纹理类似于由物理相机创建的数字图像。在每帧中,纹理将显示在设备的屏幕上。
渲染通道
如果要尝试实现逼真的渲染,则需要考虑阴影、照明和反射。这些任务都需要大量的计算,并且通常在单独的渲染通道中完成。例如,阴影渲染通道将渲染 3D 模型的整个场景,但仅保留灰度阴影信息。
第二个渲染通道将用彩色来渲染3D模型。然后,您可以组合阴影和颜色纹理,以生成最终的输出纹理,最后将其显示到屏幕上。
在本书的第一部分,您将使用单个渲染通道。稍后,您将了解多通道渲染。
方便的是,MTKView 提供了一个渲染通道描述符,该描述符将保存称为 drawable 的纹理。
添加如下代码:
// 创建命令缓冲,它将会存储你给GPU的所有命令
guard let commandBuffer = commandQueue.makeCommandBuffer(),
// 您可以获得对视图的渲染过程描述符的引用。描述符保存渲染目标的数据,称为附件(attachments)。每个附件都需要信息,例如要存储到的纹理,以及是否在整个渲染过程中保留纹理。渲染过程描述符用于创建渲染命令编码器。
let renderPassDescriptor = view.currentRenderPassDescriptor,
// 从命令缓冲区中,您可以使用渲染通道描述符获得渲染命令编码器。渲染命令编码器包含发送到 GPU 所需的所有信息,以便它可以绘制顶点。
let renderEncoder =
commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor)
else { fatalError() }
如果系统无法创建 Metal 对象(如命令缓冲区或渲染编码器),这是致命错误。视图的 currentRenderPassDescriptor 可能在特定帧中不可用,通常您只需从渲染委托方法返回。因为您在此 Playground 中只请求一次,所以您会收到一个致命错误。
创建好命令编码器后,我们将要设置一些命令,如下代码:
// 此代码为渲染编码器提供您之前设置的管道状态
renderEncoder.setRenderPipelineState(pipelineState)
// 您之前加载的球体网格包含一个包含简单顶点列表的缓冲区。
// 通过添加以下代码将此缓冲区提供给渲染编码器:
renderEncoder.setVertexBuffer(mesh.vertexBuffers[0].buffer,
offset: 0, index: 0)
//offset是缓冲区中顶点信息开始的位置。index是 GPU 顶点着色器函数定位此缓冲区的方式。
子网格
网格由子网格组成。当艺术家创建 3D 模型时,他们使用不同的材质组进行设计。这些将转换为子网格。例如,如果要渲染汽车对象,则可能具有闪亮的车身和橡胶轮胎。一种材料是闪亮的油漆,另一种是橡胶。导入时,Model I/O 会创建两个不同的子网格,这些子网格会索引到该组对应的顶点。一个顶点可以由不同的子网格多次渲染。此球体只有一个子网格,因此您将仅使用它。
添加如下代码:
guard let submesh = mesh.submeshes.first else {
fatalError()
}
现在到了令人兴奋的部分:绘制!您使用 draw调用在 Metal 中绘制。
添加如下代码:
renderEncoder.drawIndexedPrimitives(
type: .triangle,
indexCount: submesh.indexCount,
indexType: submesh.indexType,
indexBuffer: submesh.indexBuffer.buffer,
indexBufferOffset: 0)
在这里,您指示 GPU 渲染一个由三角形组成的顶点缓冲区,其中顶点按子网格索引信息的正确顺序放置。此代码不执行实际渲染----在 GPU 收到命令缓冲区的所有命令之前,不会进行渲染。
为了完成发送命令给渲染命令编码,并完成这一帧,添加如下代码:
// 您告诉渲染编码器没有更多的绘制调用并结束渲染通道。
renderEncoder.endEncoding()
// 您可以从 MTKView 获得可绘制对象。MTKView 由 Core Animation CAMetalLayer 提供支持,并且该图层拥有 Metal 可以读取和写入的可绘制纹理。
guard let drawable = view.currentDrawable else {
fatalError()
}
// 您要求命令缓冲区显示 MTKView 的可绘制对象并提交到 GPU。
commandBuffer.present(drawable)
commandBuffer.commit()
在playground最后添加如下代码:
PlaygroundPage.current.liveView = view
使用这行代码,您将能够在 Assistant 编辑器中看到 Metal 视图。
点击Playerground的运行按钮,你将看到一个红色的球:
恭喜!您已经编写了自己的第一个 Metal 应用程序,并且还使用了许多 Metal API 命令,您将在编写的每个 Metal 应用程序中使用的这些命令。
挑战
你可以尝试修改球体的半径大小,比如从[0.75, 0.75, 0.75] 改成 [0.2, 0.75, 0.2]试试,也可以修改球体颜色,从红色改成其他颜色试试。
至此,我们的Metal的第一章节就分享到这里了,下一节我们将讲述3D模型相关,敬请期待!