版本记录
版本号 | 时间 |
---|---|
V1.0 | 2018.10.13 星期六 |
前言
很多做视频和图像的,相信对这个框架都不是很陌生,它渲染高级3D图形,并使用GPU执行数据并行计算。接下来的几篇我们就详细的解析这个框架。感兴趣的看下面几篇文章。
1. Metal框架详细解析(一)—— 基本概览
2. Metal框架详细解析(二) —— 器件和命令(一)
3. Metal框架详细解析(三) —— 渲染简单的2D三角形(一)
4. Metal框架详细解析(四) —— 关于GPU Family 4(一)
5. Metal框架详细解析(五) —— 关于GPU Family 4之关于Imageblocks(二)
6. Metal框架详细解析(六) —— 关于GPU Family 4之关于Tile Shading(三)
7. Metal框架详细解析(七) —— 关于GPU Family 4之关于光栅顺序组(四)
8. Metal框架详细解析(八) —— 关于GPU Family 4之关于增强的MSAA和Imageblock采样覆盖控制(五)
9. Metal框架详细解析(九) —— 关于GPU Family 4之关于线程组共享(六)
10. Metal框架详细解析(十) —— 基本组件(一)
11. Metal框架详细解析(十一) —— 基本组件之器件选择 - 图形渲染的器件选择(二)
12. Metal框架详细解析(十二) —— 基本组件之器件选择 - 计算处理的设备选择(三)
13. Metal框架详细解析(十三) —— 计算处理(一)
14. Metal框架详细解析(十四) —— 计算处理之你好,计算(二)
15. Metal框架详细解析(十五) —— 计算处理之关于线程和线程组(三)
16. Metal框架详细解析(十六) —— 计算处理之计算线程组和网格大小(四)
17. Metal框架详细解析(十七) —— 工具、分析和调试(一)
18. Metal框架详细解析(十八) —— 工具、分析和调试之Metal GPU Capture(二)
19. Metal框架详细解析(十九) —— 工具、分析和调试之GPU活动监视器(三)
20. Metal框架详细解析(二十) —— 工具、分析和调试之关于Metal着色语言文件名扩展名、使用Metal的命令行工具构建库和标记Metal对象和命令(四)
21. Metal框架详细解析(二十一) —— 基本课程之基本缓冲区(一)
22. Metal框架详细解析(二十二) —— 基本课程之基本纹理(二)
23. Metal框架详细解析(二十三) —— 基本课程之CPU和GPU同步(三)
24. Metal框架详细解析(二十四) —— 基本课程之参数缓冲 - 基本参数缓冲(四)
25. Metal框架详细解析(二十五) —— 基本课程之参数缓冲 - 带有数组和资源堆的参数缓冲区(五)
26. Metal框架详细解析(二十六) —— 基本课程之参数缓冲 - 具有GPU编码的参数缓冲区(六)
27. Metal框架详细解析(二十七) —— 高级技术之图层选择的反射(一)
28. Metal框架详细解析(二十八) —— 高级技术之使用专用函数的LOD(一)
29. Metal框架详细解析(二十九) —— 高级技术之具有参数缓冲区的动态地形(一)
30. Metal框架详细解析(三十) —— 延迟照明(一)
31. Metal框架详细解析(三十一) —— 在视图中混合Metal和OpenGL渲染(一)
32. Metal框架详细解析(三十二) —— Metal渲染管道教程(一)
The Rendering Pipeline - 渲染管道
你终于开始研究GPU管道了! 在下图中,您可以看到管道的各个阶段。
图形管道将顶点通过多个阶段,在此期间顶点的坐标在各个空间之间转换。
作为Metal编程者,您只关注顶点和片段处理阶段(Vertex and Fragment Processing)
,因为它们是唯一的两个可编程阶段。 在本教程的后面,您将同时编写顶点着色器和片段着色器。 对于所有非可编程流水线阶段,例如Vertex Fetch
,Primitive Assembly
和Rasterization
,GPU都有专门设计的硬件单元来为这些阶段提供服务。
接下来,您将完成每个阶段。
1 – Vertex Fetch - 顶点提取
此阶段的名称因各种图形应用程序编程接口(Application Programming Interfaces (APIs))
而异。 例如,DirectX
将其称为输入汇编(Input Assembling)
。
要开始渲染3D内容,首先需要一个场景scene
。 场景由具有顶点网格的模型组成。 最简单的模型之一是具有6个面(12个三角形)的立方体。
您可以使用顶点描述符(vertex descriptor)
来定义顶点的读取方式及其属性,例如位置,纹理坐标,法线和颜色。 你可以选择不使用顶点描述符,只是在MTLBuffer
中发送一个顶点数组,但是,如果你决定不使用它,你需要知道顶点缓冲区是如何提前组织的。
当GPU获取顶点缓冲区时,MTLRenderCommandEncoder
绘制调用会告知GPU是否对缓冲区建立索引。 如果缓冲区未编入索引,则GPU假定缓冲区是一个数组,并按顺序一次读入一个元素。
此索引很重要,因为顶点被缓存以供重用。 例如,立方体有十二个三角形和八个顶点(在角落处)。 如果不进行索引,则必须为每个三角形指定顶点并向GPU发送三十六个顶点。 这可能听起来不是很多,但在具有几千个顶点的模型中,顶点缓存很重要!
还有一个用于着色顶点的第二个缓存,以便多次访问的顶点仅被着色一次。着色顶点是已应用颜色的顶点。 但这种情况发生在下一阶段。
一个名为Scheduler
的特殊硬件单元将顶点及其属性发送到Vertex Processing
阶段。
2 – Vertex Processing - 顶点处理
在此阶段,顶点将单独处理。 您编写代码来计算每个顶点的光照和颜色。 更重要的是,您通过各种坐标空间发送顶点坐标,以达到它们在最终帧缓冲区(framebuffer)
中的位置。
现在是时候看看硬件层面下发生了什么。 看看这款AMD GPU
的现代架构:
自上而下,GPU具有:
- 1 Graphics Command Processor - 图形命令处理器:这协调工作过程。
-
4 Shader Engines(SE)- 着色引擎:
SE
是GPU上的一个组织单元,可以为整个管道提供服务。 每个SE都有一个几何处理器,一个光栅化器和计算单元。 - 9 Compute Units (CU) - 计算单位:CU只不过是一组着色器核心。
- 64 shader cores - 着色器核心:着色器核心是GPU的基本构建块,其中所有着色工作在这里完成。
总共36
个CU具有2304
个shader cores
。 将其与四核CPU中的内核数量进行比较。 不公平,我知道!
对于移动设备,情况就有点不同。 为了进行比较,请查看以下图像,其中显示的GPU与最近的iOS设备中的GPU类似。 PowerVR GPU
没有SE
和CU
,而是具有Unified Shading Clusters(USC)
。 这种特殊的GPU模型有6个USC和每个USC有32个核心,总共只有192个核心。
注意:iPhone X拥有最新的移动GPU,完全由Apple内部设计。 不幸的是,Apple没有公开GPU硬件规范。
那么你可以用那么多核心做什么呢? 由于这些内核专门用于顶点和片段着色,因此一个显而易见的事情是让所有内核并行工作,以便更快地完成顶点或片段的处理。 但是有一些规则。 在CU
内部,您一次只能处理顶点或片段。 好的方面就是那里有三十六个! 另一个规则是每个SE
只能处理一个着色器函数。 拥有四个SE可以让您以有趣和有用的方式结合工作。 例如,您可以一次在一个SE上运行一个片段着色器,在另一个SE上运行第二个片段着色器。 或者,您可以将顶点着色器与片段着色器分开,让它们在不同的SE上并行运行。
现在是时候看顶点处理了! 您要编写的顶点着色器(vertex shader)
很小,但封装了您需要的大部分必要的顶点着色器语法。
使用Metal File
模板创建一个新文件,并将其命名为Shaders.metal
。 然后,在文件末尾添加此代码:
// 1
struct VertexIn {
float4 position [[ attribute(0) ]];
};
// 2
vertex float4 vertex_main(const VertexIn vertexIn [[ stage_in ]]) {
return vertexIn.position;
}
仔细阅读以下代码:
- 1) 创建结构体
VertexIn
以描述与先前设置的顶点描述符匹配的顶点属性。在这种情况下,只是position
。 - 2) 实现一个顶点着色器
vertex_main
,它接受VertexIn
结构并返回float4类型的顶点位置。
请记住,顶点在顶点缓冲区中被索引。顶点着色器通过[[stage_in]]
属性获取当前索引,并解包为当前索引处的顶点缓存的VertexIn
结构。
计算单元(Compute Units)
可以(一次)处理批量顶点,直到最大数量的着色器核心(shader cores)
。该批处理可以完全适合CU高速缓存,因此可以根据需要重用顶点。批处理将使CU保持忙碌,直到处理完成,但其他CU应该可用于处理下一批。
顶点处理完成后,清除缓存方便进行下一批顶点批处理。此时,顶点现在被排序和分组,准备发送到基元组装(primitive assembly)
阶段。
回顾一下,CPU向GPU发送了一个从模型网格mesh
创建的顶点缓冲区。 您使用顶点描述符配置顶点缓冲区,该描述符告诉GPU如何构造顶点数据。 在GPU上,您创建了一个用于封装顶点属性的结构。 顶点着色器接受此结构作为函数参数,并通过[[stage_in]]
限定符,通过顶点缓冲区中的[[attribute(0)]]
位置确认该位置来自CPU。 然后顶点着色器处理所有顶点并将其位置作为float4
返回。
名为Distributer
的特殊硬件单元将分组的顶点块发送到Primitive Assembly
阶段。
3 – Primitive Assembly - 图元组装
前一阶段将处理后的顶点分组为数据块到此阶段。 要记住的重要一点是,属于相同几何形状(图元)的顶点始终位于同一个块中。 这意味着一个点的一个顶点,或一个线的两个顶点,或三角形的三个顶点,将始终位于同一个块中,因此永远不需要第二个块提取。
与顶点一起,CPU在发出draw call
命令时也会发送顶点连接信息,如下所示:
renderEncoder.drawIndexedPrimitives(type: .triangle,
indexCount: submesh.indexCount,
indexType: submesh.indexType,
indexBuffer: submesh.indexBuffer.buffer,
indexBufferOffset: 0)
draw
函数的第一个参数包含有关顶点连接的最重要信息。 在这种情况下,它告诉GPU它应该从它发送的顶点缓冲区中绘制三角形。
Metal API提供五种基本类型:
-
point:对于每个顶点栅格化一个点。您可以在顶点着色器中指定具有
[[point_size]]
属性的点的大小。 - line:对于每对顶点,栅格化它们之间的一条线。如果某个顶点已包含在一行中,则不能再将其包含在其他行中。如果存在奇数个顶点,则忽略最后一个顶点。
- lineStrip:与简单线相同,只是线条连接所有相邻顶点并形成折线。每个顶点(第一个除外)都连接到前一个顶点。
- triangle:对于三个顶点的每个序列,栅格化三角形。如果它们不能形成另一个三角形,则忽略最后的顶点。
- triangleStrip:与简单三角形相同,但相邻顶点也可以连接到其他三角形。
还有一种称为patch的图元类型,但这需要特殊处理,不能与索引绘制调用函数一起使用。
管道指定顶点的缠绕顺序。如果绕组顺序是逆时针方向,并且三角形顶点顺序是逆时针方向,则意味着它们是正面的。否则,它们是背面的,可以剔除,因为我们看不到它们的颜色和光线。
当图元图像被其他图元完全遮挡时,它们将被剔除,然而,当它们仅部分偏离屏幕时,它们将被剪裁。
为了提高效率,您应指定缠绕顺序并启用背面剔除。
此时,基元从连接的顶点完全组装,然后移动到光栅化器。
4 – Rasterization - 光栅化
目前有两种现代渲染技术在不同的路径上发展,但有时一起使用:光线跟踪和光栅化(ray tracing and rasterization)
。他们是完全不同的;两者都有利有弊。
在渲染静态且远距离的内容时,首选光线跟踪,而当内容距离相机较近且更具动态性时,首选光栅化。
使用光线跟踪,对于屏幕上的每个像素,它会将光线发送到场景中,以查看是否存在与对象的交叉点。如果是,请将像素颜色更改为该对象的颜色,但前提是该对象比当前像素的先前保存对象更靠近屏幕。
光栅化反过来:对于场景中的每个对象,将光线发送回屏幕并检查对象覆盖的像素。深度信息的保持方式与光线跟踪的方式相同,因此如果当前对象比先前保存的对象更近,它将更新像素颜色。
此时,从前一阶段发送的所有连接顶点需要使用它们的X和Y坐标在二维网格上表示。此步骤称为三角形设置(triangle setup)。
这是光栅化器需要计算任意两个顶点之间的线段的斜率或陡度的位置。当已知三个顶点的三个斜率时,可以从这三个边缘形成三角形。
接下来,在屏幕的每一行上运行一个称为扫描转换的过程,以查找交叉点并确定哪些是可见的,哪些是不可见的。此时要在屏幕上绘制,只需要它们确定的顶点和斜率。扫描算法确定线段上的所有点或三角形内的所有点是否可见,在这种情况下,三角形完全用颜色填充。
对于移动设备,光栅化利用PowerVR GPU
的平铺(tiled)架构,通过并行光栅化32×32平铺网格上的基元。在这种情况下,32是分配给(tiled)的屏幕像素的数量,但是该尺寸完全适应USC
中的核心数量。
如果一个对象在另一个对象后面怎么办?栅格化器如何确定要渲染的对象?通过使用存储的深度信息(早期Z测试)来确定每个点是否在场景中的其他点之前,可以解决该隐藏表面移除问题。
光栅化完成后,三个更专业的硬件单元进入舞台:
- 名为Hierarchical-Z的缓冲区负责删除光栅化器标记为剔除的片段。
- 然后,Z and Stencil Test单元通过将它们与深度和模板缓冲区进行比较来移除不可见的碎片。
- 最后,Interpolator单元获取剩余的可见片段,并从组合的三角形属性生成片段属性。
此时,Scheduler单元再次将工作分派给着色器核心(shader cores)
,但这次是为Fragment Processing发送的光栅化片段。
5 – Fragment Processing - 片段处理
是时候快速审查管道了。
-
Vertex Fetch
单元从内存中抓取顶点并将它们传递给Scheduler
单元。 -
Scheduler
单元知道哪些着色器核心可用,因此它会调度它们的工作。 - 完成工作后,
Distributer
单元知道此工作是Vertex
还是Fragment Processing
。 - 如果是
Vertex Processing
工作,它会将结果发送到Primitive Assembly
单元。 此路径继续到Rasterization
单元,然后返回到Scheduler
单元。 - 如果是
Fragment Processing
工作,它会将结果发送到Color Writing
单元。 - 最后,彩色像素被发送回存储器。
前一阶段的primitive processing
是连续的,因为只有一个Primitive Assembly
单元和一个Rasterization
单元。 但是,只要片段到达Scheduler
单元,就可以将工作分叉(划分)为许多微小部分,并将每个部分分配给可用的着色器核心。
现在有数百甚至数千个内核正在进行并行处理。 工作完成后,结果将被连接(合并)并再次顺序发送到内存。
片段处理阶段是另一个可编程阶段。 您可以创建一个片段着色器函数,该函数将接收顶点函数输出的光照,纹理坐标,深度和颜色信息。
片段着色器输出是该片段的单一颜色。 这些片段中的每一个都将有助于framebuffer
中最终像素的颜色。 为每个片段插入所有属性。
例如,要渲染此三角形,顶点函数将处理三个顶点,颜色为红色,绿色和蓝色。 如图所示,构成该三角形的每个片段都是从这三种颜色中插入的。 线性插值简单地平均两个端点之间的线上每个点的颜色。 如果一个端点具有红色,而另一个端点具有绿色,则它们之间的线上的中点将为黄色。 等等。
插值方程是参数化的并且具有这种形式,其中参数p
是颜色存在的百分比(或0到1的范围):
newColor = p * oldColor1 + (1 - p) * oldColor2
颜色很容易可视化,但所有其他顶点函数输出也为每个片段进行了类似的插值。
注意:如果不希望顶点输出进行插值,请将属性
[[flat]]
添加到其定义中。
在Shaders.Metal
中,将片段函数添加到文件末尾:
fragment float4 fragment_main() {
return float4(1, 0, 0, 1);
}
这是最简单的片段函数。您以float4
的形式返回插值颜色红色。构成立方体的所有片段都是红色的。
GPU获取片段并进行一系列后处理测试:
- alpha-testing确定绘制哪些不透明对象,哪些不是基于深度测试。
- 在半透明对象的情况下,alpha-blending将新对象的颜色与先前已保存在颜色缓冲区中的颜色相结合。
- scissor testing - 剪刀测试 检查片段是否在指定的矩形内;此测试对于屏蔽渲染很有用。
- stencil testing - 模板测试 检查帧缓冲区中存储片段的模板值如何与我们选择的指定值进行比较。
- 在前一阶段early-Z testing运行;现在进行了late-Z testing以解决更多的可见性问题;模板和深度测试对于环境遮挡和阴影也很有用。
- 最后,还计算了抗锯齿- antialiasing,以便到达屏幕的最终图像看起来不会锯齿状。
6 – Framebuffer - 帧缓冲区
一旦片段被处理成像素,Distributer单元就将它们发送到Color Writing单元。该单元负责将最终颜色写入称为framebuffer的特殊存储器位置。从这里开始,视图的每个帧都会刷新其彩色像素。但这是否意味着在屏幕上显示时会将颜色写入帧缓冲区?
一种称为double-buffering - 双缓冲 的技术用于解决这种情况。当第一个缓冲区显示在屏幕上时,第二个缓冲区在后台更新。然后,交换两个缓冲区,并在屏幕上显示第二个缓冲区,同时更新第一个缓冲区,并继续循环。
这需要很多硬件信息。但是,您编写的代码是每个Metal
渲染器使用的代码,尽管刚刚开始,您应该在查看Apple的示例代码时开始识别渲染过程。
Build并运行应用程序,您的应用程序将呈现此红色立方体:
注意立方体不是方形的。 请记住,Metal使用X轴上-1到1的Normalized Device Coordinates (NDC)
。 调整窗口大小,立方体将保持相对于窗口大小的大小。
Send Data to the GPU - 将数据发送到GPU
Metal
是关于华丽的图形和快速和流畅的动画。 下一步,您将使您的立方体在屏幕上上下移动。 要做到这一点,你将有一个更新每一帧的计时器,立方体的位置将取决于这个计时器。 在顶点函数中更新顶点位置,因此您可以将计时器数据发送到GPU。
在Renderer
的顶部,添加timer
属性:
var timer: Float = 0
在draw(in :)
中,就在之前:
renderEncoder.setRenderPipelineState(pipelineState)
添加
// 1
timer += 0.05
var currentTime = sin(timer)
// 2
renderEncoder.setVertexBytes(¤tTime,
length: MemoryLayout<Float>.stride,
index: 1)
- 1) 您将计时器添加到每个帧。 您希望您的立方体在屏幕上上下移动,因此您将使用介于-1和1之间的值。使用
sin()
是实现此目的的好方法,因为正弦值始终为-1到1。 - 2) 如果您只向GPU发送少量数据(小于4kb),则
setVertexBytes(_:length:index :)
是设置MTLBuffer
的替代方法。 在这里,您将currentTime
设置为缓冲区参数表中的索引1。
在Shaders.metal
中,将顶点函数替换为:
vertex float4 vertex_main(const VertexIn vertexIn [[ stage_in ]],
constant float &timer [[ buffer(1) ]]) {
float4 position = vertexIn.position;
position.y += timer;
return position;
}
这里,在缓冲区1中,您的顶点函数将接收浮点型的计时器。您将计时器值添加到y位置并从函数返回新位置。
Build并运行应用程序,您现在拥有一个动画的立方体。
只需几个代码,您就可以了解管道的工作原理,甚至还添加了一些动画。
源码
首先我们看一下工程文件。
接着看一下sb中的内容
下面就一起看一下源码。
1. Swift
这里给的是Mac上运行的源码。
1. AppDelegate.swift
import Cocoa
@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ aNotification: Notification) {
// Insert code here to initialize your application
}
func applicationWillTerminate(_ aNotification: Notification) {
// Insert code here to tear down your application
}
}
2. ViewController.swift
import Cocoa
import MetalKit
class ViewController: NSViewController {
var renderer: Renderer?
override func viewDidLoad() {
super.viewDidLoad()
guard let metalView = view as? MTKView else {
fatalError("metal view not set up in storyboard")
}
renderer = Renderer(metalView: metalView)
}
}
3. Renderer.swift
import MetalKit
class Renderer: NSObject {
static var device: MTLDevice!
static var commandQueue: MTLCommandQueue!
var mesh: MTKMesh!
var vertexBuffer: MTLBuffer!
var pipelineState: MTLRenderPipelineState!
var timer: Float = 0
init(metalView: MTKView) {
guard let device = MTLCreateSystemDefaultDevice() else {
fatalError("GPU not available")
}
metalView.device = device
Renderer.device = device
Renderer.commandQueue = device.makeCommandQueue()!
let mdlMesh = Primitive.cube(device: device, size: 1.0)
mesh = try! MTKMesh(mesh: mdlMesh, device: device)
vertexBuffer = mesh.vertexBuffers[0].buffer
let library = device.makeDefaultLibrary()
let vertexFunction = library?.makeFunction(name: "vertex_main")
let fragmentFunction = library?.makeFunction(name: "fragment_main")
let pipelineDescriptor = MTLRenderPipelineDescriptor()
pipelineDescriptor.vertexFunction = vertexFunction
pipelineDescriptor.fragmentFunction = fragmentFunction
pipelineDescriptor.vertexDescriptor = MTKMetalVertexDescriptorFromModelIO(mdlMesh.vertexDescriptor)
pipelineDescriptor.colorAttachments[0].pixelFormat = metalView.colorPixelFormat
do {
pipelineState = try device.makeRenderPipelineState(descriptor: pipelineDescriptor)
} catch let error {
fatalError(error.localizedDescription)
}
super.init()
metalView.clearColor = MTLClearColor(red: 1.0, green: 1.0,
blue: 0.8, alpha: 1)
metalView.delegate = self
}
}
extension Renderer: MTKViewDelegate {
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
}
func draw(in view: MTKView) {
guard let descriptor = view.currentRenderPassDescriptor,
let commandBuffer = Renderer.commandQueue.makeCommandBuffer(),
let renderEncoder =
commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else {
return
}
timer += 0.05
var currentTime: Float = sin(timer)
renderEncoder.setVertexBytes(¤tTime, length: MemoryLayout<Float>.stride, index: 1)
renderEncoder.setRenderPipelineState(pipelineState)
renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
for submesh in mesh.submeshes {
renderEncoder.drawIndexedPrimitives(type: .triangle,
indexCount: submesh.indexCount,
indexType: submesh.indexType,
indexBuffer: submesh.indexBuffer.buffer,
indexBufferOffset: submesh.indexBuffer.offset)
}
renderEncoder.endEncoding()
guard let drawable = view.currentDrawable else {
return
}
commandBuffer.present(drawable)
commandBuffer.commit()
}
}
4. Primitive.swift
import MetalKit
class Primitive {
class func cube(device: MTLDevice, size: Float) -> MDLMesh {
let allocator = MTKMeshBufferAllocator(device: device)
let mesh = MDLMesh(boxWithExtent: [size, size, size],
segments: [1, 1, 1],
inwardNormals: false, geometryType: .triangles,
allocator: allocator)
return mesh
}
}
5. Shaders.metal
#include <metal_stdlib>
using namespace metal;
struct VertexIn {
float4 position [[ attribute(0) ]];
};
vertex float4 vertex_main(const VertexIn vertexIn [[ stage_in ]],
constant float &timer [[ buffer(1) ]]) {
float4 position = vertexIn.position;
position.y += timer;
return position;
}
fragment float4 fragment_main() {
return float4(1, 0, 0, 1);
}
下面看一下运行效果
后记
本篇主要讲述了Metal渲染管道教程,感兴趣的给个赞或者关注~~~