阅读 1774

[译]Metal 渲染管线教程

译者注:本文是Raywenderlich上《Metal by Tutorials》免费章节的翻译,是原书第3章.原书第 3 章完成了一个显示立方体的 app,相比其他教程介绍了很多 GPU 硬件部分基础知识.
官网原文地址Metal Rendering Pipeline Tutorial


版本 Swift 4,iOS 11, Xcode 9

本文是我们书《Metal by Tutorials》中第 3 章的节选。这本书会带你进入 Metal 图形编程---Metal 是苹果的 GPU 编程框架。你将会用 Metal 构建你自己的游戏引擎,创建 3D 场景及构建你自己的 3D 游戏。希望你喜欢!

在本教程中,你将深入了解渲染管线,并创建一个 Metal app 来渲染出一个红色立方体。在这个过程中,你会了解到所有相关的硬件芯片基本知识,他们负责接收 3D 物体并将其变成屏幕上显示的像素。

GPU 和 CPU

所有的计算机都有一个Central Processing Unit (CPU),它操作并管理着电脑上的资源。计算机也都有一个Graphics Processing Unit (GPU)

GPU 是一个特殊的硬件,它可以非常快速地处理图像,视频和海量的数据。这被称作throughput(吞吐量)。吞吐量是指在单位时间内处理的数据量。

CPU 则无法非常快速处理大量数据,但它可以非常快的处理很多序列任务(一个接一个的)。处理一个任务所需的时间叫做latency(延迟)

最理想的配置就是低延迟高吞吐量。低延迟用于 CPU 执行串行队列任务, 就不会导致系统变慢或无响应;高吞吐量允许 GPU 异步渲染视频或游戏无需阻塞 CPU。因为 GPU 有高度并行性的架构,专门用于做一些重复的任务,只需少量或无数据传递,所以它可以处理大量数据。

下面的图表显示了 CPU 和 GPU 之间的主要差异。

CPU 有大容量缓存及少量算术逻辑单元Arithmetic Logic Unit (ALU) 核心。CPU 上的低延迟缓存是用于快速访问临时资源。GPU 没有那么大的缓存,但有更多的 ALU 核心,它们只进行计算无需保存中间结果到内存中。

同时,CPU 只有几个核心,而 GPU 有上百个甚至上千个核心。有了更多的核心,GPU 可以将问题分割成许多小部分,每个部分并行运行在单独的核心上,这样隐藏了延迟。处理完成后,各部分的结果被组合起来,并将最终结果返回给 CPU。但是,核心数并不是惟一的关键因素!

GPU 核心除了经过精简之外,还有一些特殊的电路用来处理几何体,一般叫做shader cores(着色器核心)。这些着色器核心负责处理你在屏幕上看到的各种漂亮颜色。GPU 一次写入一整帧来填满整个渲染窗口。然后继续处理下一帧以维持一个合理的帧率。

CPU 则继续传递指令给 GPU 使其保持忙碌状态,但有时候,可能 CPU 会停止发送指令,或者 GPU 停止处理接收到的指令。为了避免阻塞,CPU 上的 Metal 会在命令缓冲区排列多个命令,并按顺序传递新指令,这样下一帧就不用等待 GPU 完成第一帧了。这样,不管 CPU,GPU 谁先完成工作,都会有更多工作等待完成。

图形管线的 GPU 部分在它接收到所有指令和资源时就会启动。

Metal 项目

你已经用 Playgrounds 学过了 Metal。Playgrounds 非常适合于测试学习新的概念。同时学会如何建立一个完整的 Metal 工程也是很重要的。因为 iOS 模拟器不支持 Metal,你需要使用 macOS app.

注意:本教程的项目文件中也包含了 iOS target。

使用Cocoa App模板创建一个新的 macOS app。

命名为Pipeline并勾选Use Storyboards。其他不勾选。

打开Main.storyboard并选中View Controller SceneView

在右侧检查器中,将 view 从NSView改为MTKView

这样就将主视图作为了 MetalKit View。

打开ViewController.swift。在文件的顶部,导入MetalKit framework:

import MetalKit
复制代码

然后,在viewDidLoad()中添加下面代码:

guard let metalView = view as? MTKView else {
  fatalError("metal view not set up in storyboard")
}
复制代码

现在你可以选择。你可以继承MTKView并在 storyboard 中使用这个视图。这样,子类的draw(_:)将会每帧被调用,你就可以将代码写在该方法里面。但是,本教程中,你将建立一个Renderer类并遵守MTKViewDelegate协议,并设置RendererMTKView的代理。MTKView每帧都会调用代理方法,你需要把必须的绘制代码写在这里。

注意:如果你以前用的是其他 API,你可能会想要寻找游戏循环构造。你也可以选择扩展CAMetalLayer而不是创建MTKView。你还可以用CADisplayLink来计时;但是苹果引入了MetalKit并使用协议来更方便地管理游戏循环。

Renderer 类

创建一个新的 Swift 文件命名为Renderer.swift,并用下面代码替换其中内容:

import MetalKit

class Renderer: NSObject {
  init(metalView: MTKView) {
    super.init()
  }
}

extension Renderer: MTKViewDelegate {
  func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
  }
  
  func draw(in view: MTKView) {
    print("draw")
  }
}
复制代码

这里你创建一个构造器并让Renderer遵守了MTKViewDelegate,实现MTKView的两个代理方法:

  • mtkView(_:drawableSizeWillChange:):获取每次窗口尺寸改变。这允许你更新渲染坐标系统。
  • draw(in:):每帧调用。 在ViewController.swift,添加一个属性来持有 renderer:
var renderer: Renderer?
复制代码

viewDidLoad()的末尾,初始化 renderer:

renderer = Renderer(metalView: metalView)
复制代码

初始化

首先,你需要建立 Metal 环境。 Metal 相比 OpenGL 的巨大优势就是,你可以预先实例化一些对象,而不必每帧都创建一次。下面的图表列出了你可以在 app 一开始就创建的对象。

  • MTLDevice:软件对 GPU 硬件的引用。
  • MTLCommandQueue:负责创建及组织每帧所需的MTLCommandBuffers.
  • MTLLibrary:包含了从顶点着色器和片段着色器转换得到的代码。
  • MTLRenderPipelineState:设置绘制信息,比如使用哪个着色器函数,哪个深度和颜色设置,及如何读取顶点数据。
  • MTLBuffer:以一种格式持有数据,如顶点信息,方便你将其发送到 GPU。

一般情况下,在你的 app 中只有一个MTLDevice, 一个MTLCommandQueue及一个MTLLibrary对象。一般会有若干个MTLRenderPipelineState对象来定义不同的管线状态,还有若干个MTLBuffer来保存数据。

在你使用这些对象前,你需要初始化他们。在Renderer中添加下列属性:

static var device: MTLDevice!
static var commandQueue: MTLCommandQueue!
var mesh: MTKMesh!
var vertexBuffer: MTLBuffer!
var pipelineState: MTLRenderPipelineState!
复制代码

这些属性是用来引用不同对象的。方便起见,他们现在都是隐式解包的,但是你可以在完成初始化后改变他们。你不必引用MTLLibrary,所以需要创建它。

下一步,在init(metalView:)super.init()前面添加代码:

guard let device = MTLCreateSystemDefaultDevice() else {
  fatalError("GPU not available")
}
metalView.device = device
Renderer.commandQueue = device.makeCommandQueue()!
复制代码

这里初始化了 GPU 并创建了命令队列。你使用了类属性来保存 device 和命令队列以确保只有一份存在。有些情况下,你可能需要不止一个,但是大部分情况下,一个就够了。

最后,在super.init()之后,添加下面代码:

metalView.clearColor = MTLClearColor(red: 1.0, green: 1.0,
                                     blue: 0.8, alpha: 1.0)
metalView.delegate = self
复制代码

这里设置metalView.clearColor为一种奶油色。同时也将Renderer设置为metalView的代理,这样它就会调用MTKViewDelegate的绘制方法。

构建并运行 app 以确保所有事情已经完成并起作用了。如果正常的话,你将看到一个灰色的窗口。在调试控制台中,你将会看到单词"draw"不断重复出现。用这个来检验你的 app 是否每帧都在调用draw(in:)方法。

你看不到metalView的奶油色因为你没有请求 GPU 来做任何绘制操作。

准备数据

一个专门的类来创建 3D 图元网格是很有用的。在本教程中,你将创建一个类来创建 3D 形状图元,并向其添加立方体。

创建一个新的 Swift 文件命名为Primitive.swift,并用下面代码替换默认代码:

import MetalKit

class Primitive {
  class func makeCube(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
  }
}
复制代码

这个类方法返回一个立方体。

Renderer.swift中,在init(metalView:),在调用super.init()之前,先建立网格:

let mdlMesh = Primitive.makeCube(device: device, size: 1)
do {
  mesh = try MTKMesh(mesh: mdlMesh, device: device)
} catch let error {
  print(error.localizedDescription)
}
复制代码

然后,创建MTLBuffer来盛放将发送到 GPU 的顶点数据。

vertexBuffer = mesh.vertexBuffers[0].buffer
复制代码

这会将数据放在一个MTLBuffer中。现在你需要建立管线状态,以让 GPU 知道如何渲染数据。

首先,创建MTLLibrary并确保顶点和片段着色器函数可用。

继续在super.init()之前添加:

let library = device.makeDefaultLibrary()
let vertexFunction = library?.makeFunction(name: "vertex_main")
let fragmentFunction = library?.makeFunction(name: "fragment_main")
复制代码

你将会在本教程的稍后部分创建这些着色器。与 OpenGL 着色器不同,这些着色器会在你编译项目时被编译好,这无疑比运行中编译更有效率。结果被储存在 library 中。

现在,创建管线状态:

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)
}
复制代码

这里为 GPU 创建了一个可能的状态。GPU 需要在开始管理顶点之前,就知道它的完整状态。你为 GPU 设置两个着色器函数,并设置要写入纹理的像素格式。

同时设置了管线的顶点描述符。它决定了 GPU 如何翻译处理你在网格数据MTLBuffer传递过去的顶点数据。

如果你需要调用不同的顶点或片段函数,或使用不同的数据布局,那么你就需要多个管线状态。创建管线状态是相当花费时间的,这就是为什么你需要尽早创建,但是在不同帧间切换管线状态是非常快速和高效的。

初始化是完整的,你的项目即将编译。但是,当你尝试运行它时,你会遇到一个错误,因为你还没有创建着色器函数。

渲染帧

Renderer.swift中,替换draw(in:)中的print语句:

guard let descriptor = view.currentRenderPassDescriptor,
  let commandBuffer = Renderer.commandQueue.makeCommandBuffer(),
  let renderEncoder = 
    commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else {
    return
}

// drawing code goes here

renderEncoder.endEncoding()
guard let drawable = view.currentDrawable else {
  return
}
commandBuffer.present(drawable)
commandBuffer.commit()
复制代码

这里创建了渲染命令编码器,并将视图的可绘制纹理发送到 GPU。

绘制

在 CPU 上,要给 GPU 准备数据,你需要把数据和管线状态给 GPU。然后你需要发起绘制调用(draw call)。

还是在draw(in:)中,替换注释:

// drawing code goes here
复制代码

为下面代码:

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)
}
复制代码

当你在draw(in:)的末尾提交命令缓冲时,就指示了 GPU 数据和管线都准备好了,GPU 可以接管过去了。

渲染管线

终于到了审查 GPU 管线的时候了!在下面图表中,你可以看到管线的状态。

图形管线在多个阶段都接收顶点,同时顶点会在多个空间坐标系中进行变换。

做为一个 Metal 程序员,你只需要考虑顶点和片段处理阶段,因为只有这两个阶段是可编程控制的。在教程后面,你会写一个顶点着色器和一个片段着色器。其他的非可编程管线阶段,如Vertex Fetch(顶点获取),Primitive Assembly(图元组装)和Rasterization(光栅化),GPU 有专门设计的硬件单元来处理这些阶段。

下一步,你将逐个了解这些阶段。

1-Vertex Fetch(顶点获取)

该阶段的名称在不同图形 API 中不同。例如,DirectX中叫做Input Assembling

要开始渲染 3D 内容,你首先需要一个 scene。一个 scene 场景包含很多模型,模型中有顶点组成的网格。最简单的模型就是立方体,它有 6 个面(12 个三角形)。

你使用顶点描述符来定义顶点的属性读取方式,如位置,纹理坐标,法线和颜色。你也可以选择使用顶点描述符,只将一组MTLBuffer顶点发送过去。但是,如果你这样做,就必须提前知道顶点缓冲是如何组织的。

当 GPU 获取顶点缓冲时,MTLRenderCommandEncoder 的绘制调用告诉 GPU 缓冲是否有索引。如果缓冲没有索引,GPU 就假设缓冲是个数组,按顺序一次取一个元素。

这些索引非常重要,因为顶点是被缓存起来以供重用的。例如,一个立方体有 12 个三角形和 8 个顶点。如果你不使用索引,你必须为每个三角形指定顶点并将 36 个顶点发送到 GPU。这个听起来可能不太多,但是在一个拥有上千个顶点的模型中,顶点缓存是非常重要的!

另外还有一个给已着色顶点用的第二缓冲,这样被多次访问的顶点也只需着色一次。已着色顶点是指已经应用了颜色的顶点。但是这些是在下一阶段才发生的。

一个特殊的硬件单元叫做调度器Scheduler将顶点和他们的属性发送到Vertex Processing(顶点处理) 阶段。

2-Vertex Processing(顶点处理)

在这个阶段,顶点是被单独处理的。你需要写代码来计算逐顶点的光照和颜色。更重要的是,你要将顶点坐标,经过不同坐标空间的转换,来确定在最终帧缓冲中的位置。

现在是时候来看看在硬件层面上到底发生了什么吧。来看一眼现代的 AMD GPU 的架构:

从上到下,GPU 拥有:

  • 1 个图形命令处理器Graphics Command Processor:它调度整个工作流程。
  • 4 个着色器引擎Shader Engines (SE):一个SE就是 GPU 上服务整个管线的组织单元。每个SE有一个图形处理器,一个光栅化器和一个计算单元。
  • 9 个计算单元Compute Unit (CU):一个CU是一组着色器核心。
  • 64 着色器核心shader coreshader core是 GPU 的基本构成模块,它负责完成所有的着色工作。

36 个CU共有 2304 个着色器核心 shader core。这个数目和你的四核心 CPU 相比,差异巨大!

对移动设备来说,事情有点不同。下面这张图,展示了最近几年 iOS 设备上的 GPU 结构。PowerVR GPU 取消了SECU,使用了Unified Shading Cluster (USC)。这个特制的 GPU 有 6 个USC,每个USC又有 32 个核心,总共 192 个核心。

注意:iPhoneX 上的最新的移动 GPU 是苹果完全自主设计的。不幸的是,苹果并没有公开它的 GPU 硬件特性。

那么你能用这么多核心做什么呢?因为这些核心是专门用于顶点和片段着色的,显然这些核心可以并行工作,所以顶点和片段的处理可以更快速。当然还有一些规则。在一个 CU 内,你只能处理顶点或片段,不能同时处理两者。好消息是有 36 个 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结构体缓存。

计算单元能够处理(一次)大批量的顶点,数量取决于着色器核心的最大值。该批处理可以完整利用 CU 高速缓存,因此可以根据需要重用顶点。该批处理会让 CU 保持繁忙状态直到处理完成,但是其他的 CU 会变成可用状态以处理下一批次。

顶点处理一旦完成,高速缓存就会被清理,为下一批次顶点做好准备。此时,顶点已经被排序过,分组过了,准备被发送到下一阶段了。

回顾一下,CPU 将一个从模型的网格中创建的顶点缓冲发送给 GPU。用一个顶点描述符来配置顶点缓冲,以此告诉 GPU 顶点数据是什么结构的。在 GPU 上,你创建一个结构体来包装顶点属性。顶点着色器通过函数参数接收这个结构体,并通过[[ stage_in ]]修饰词,知道了position是从 CPU 通过顶点缓冲中的[[ attribute(0) ]]位置传递过来。然后,顶点着色器处理所有的顶点并通过float4返回他们的位置。

一个特殊的硬件单元叫做分配器Distributer,将分组过的顶点数据块发送到下一个Primitive Assembly(图元组装) 阶段。

3-Primitive Assembly(图元组装)

前一阶段将顶点分组成数据块发送到本阶段。需要注意的是,同一个几何体形状(图元primitive)的顶点总是会在同一个块中。这就意味着,一个顶点的点,或两个顶点的线,或者三个顶点的三角形,总是会在同一个块中,因此,再也不需要读取第二个数据块了。

与此同时,CPU 还在派发绘制调用draw call命令时,发送了顶点的连接信息过来,比如这样:

renderEncoder.drawIndexedPrimitives(type: .triangle,
                          indexCount: submesh.indexCount,
                          indexType: submesh.indexType,
                          indexBuffer: submesh.indexBuffer.buffer,
                          indexBufferOffset: 0)
复制代码

绘制函数的第一个参数包含了最重要的顶点连接信息。在本例中,它告诉 GPU 利用拿到的顶点缓冲绘制三角形。

Metal API 提供了五种基础形状:

  • point:为每个顶点光栅化一个点。你可以在顶点着色器中用属性[[point_size]]来指定点的尺寸。
  • line:为每一对顶点光栅化出之间的线段。如果一个顶点已经包含在一条线上了,它就不能再被包含在另一条线上。如果顶点是奇数个,那么最后的顶点会被忽略掉。
  • lineStrip:和前面的简单直线类似,但是 line strip 连接了所有邻近的顶点,形成了一个多段线。每个顶点(除了第一个)都连接到前一个顶点上。
  • trangle:为每三个连续顶点光栅化出一个三角形。如果最末尾的顶点不能构成三角形,它们将被忽略。
  • trangleStrip:和前面的简单三角形类似,但是一个顶点可以和相邻的三角形边构成新的三角形。

其实还有另一种基础形状(图元)叫做patch,但是它需要特殊处理,不能被用在带有索引的绘制调用函数中。

管线指定了顶点的旋转方向。如果旋转方向是逆时针的,那么三角形顶点的顺序就是逆时针的面,就是正面。否则,这个面就是背面,可以被剔除,因为我们看不到他们的颜色和光照。

当被其他图元遮挡时,该图元将会被剔除,但是,如果他们只是部分在屏幕外,他们将会被裁剪。

为了效率,你应当指定旋转方向并启用背面剔除。

此时,图元已经从顶点被完全组装好了,并将进入到光栅化器。

4-Rasterization(光栅化)

当前,有两种不同的渲染技术:光线追踪ray tracing光栅化rasterization,当然有时候也会一起使用。它们差异非常大,各有优点和缺点。

当渲染内容是静态的,距离较远的时候,光线追踪效果更好;当内容非常靠近镜头且不断移动时,光栅化效果更好。

使用光线追踪时,从屏幕上的每一个点,发射一条射线到场景中,看看是否和场景中的物体有交点。如果有,将屏幕上像素的颜色改成距离屏幕最近的物体的颜色。

光栅化是另一种工作方式:从场景中的每一个物体,发射射线到屏幕上,看看哪些像素被该物体覆盖了。深度信息也会像光线追踪一样被保留,所以,当有更近的物体出现时,会更新屏幕上像素的颜色。

此时,上一阶段中发过来的连接后的顶点,会根据 X 和 Y 坐标被呈现在二维网格上。这一步就是三角形设置triangle setup

这里,光栅化器需要计算任意两个顶点间线段的斜率。当三个顶点间的三个斜率都已知后,三角形就可以同这三条边构成。

下一步的处理叫做扫瞄转换scan conversion,逐行扫瞄屏幕寻找交点,确定哪一部分是可见的,哪一部分是不可见的。要绘制屏幕上的点,只需它们的顶点和斜率就够了。扫瞄算法确定是否线段上的所有点或三角形内的所有点都是可见的,如果是可见的,就全都会被填充上颜色。

对移动设备来说,光栅化可以充分利用 PowerVR GPU 的tiled架构优势,可以并行光栅化一个 32x32 的图块网格。这样一来,32 就是分配给图块的屏幕像素的数量,该尺寸完美匹配了 USC 的核心数量。

如果一个物体躲在另一个物体后面会怎样?光栅化器如何决定哪个物体要被渲染呢?这个隐藏表面的移除问题可以被解决,方法是通过使用储存的深度信息(提前 Z 测试)来决定任意一个点是否在场景中另一些点的前面。

在光栅化完成后,三个另外的硬件单元接管了任务:

  • 一个叫Hierarchical-Z的缓冲,负责移除那些被光栅化器标记为剔除的片段。
  • Z and Stencil Test单元接着对比片段与深度缓冲和模板缓冲,移除那些不可见的片段。
  • 最后,插值器Interpolator单元接收剩余的可见片段,并从组装好的三角形属性中产生片段属性。

此时,调度器Scheduler单元再次将任务调度给着色器核心,但是这一次,光栅化后的片段被发送到Fragment Processing(片段处理) 阶段。

5-Fragment Processing(片段处理)

是时候快速复习一下管线知识了。

  • 顶点获取Vertex Fetch单元从内存中抓取数据,并将其传递到调度器Scheduler单元。
  • 调度器Scheduler单元知道哪些着色器核心是可用的,就向其分配工作。
  • 工作完成后,分配器Distributer单元会知道这个工作是顶点处理或者片段处理
  • 如果是顶点处理工作,它就将结果发送到Primitive Assembly(图元组装) 单元。这条路径继续走到Rasterization(光栅化) 单元,然后再回到调度器Scheduler单元。
  • 如果是片段处理工作,它就将结果发送到色彩写入Color Writing单元。
  • 最后,着色过的像素被发送回到内存中。

前一阶段的图元处理是序列进行的,因为只有一个Primitive Assembly(图元组装) 单元,及一个Rasterization(光栅化) 单元。然而,一旦片段到达了调度器Scheduler单元,工作就可以被分叉forked(分割)成许多小的部分,每一部分被分配到可用的着色器核心上。

上百个甚至上千个核心现在在并行处理。当工作完成后,结果就会被接合joined(合并)并再次发送到内存中。

片段处理阶段是另一个可编程控制阶段。你将创建一个片段着色函数来接收顶点函数输出的光照,纹理坐标,深度和颜色信息。

片段着色器的输出是该片段的颜色。每一个片段都会为帧缓冲中的最终像素颜色做出贡献。每个片段的所有的属性是插值得到的。

例如,要渲染一个三角形,顶点函数会处理三个顶点,颜色分别为红,绿和蓝。正如图表显示的那样,组成三角形的每个片段都是三种颜色插值得到的。线性插值就是简单地根据两个端点的距离和颜色平均一下得到的。如果一个端点是红色的,另一个端点是绿色的,那么线段的中间点的颜色就是黄色的。依此类推。

插值方程的参数化形式如下,其中参数p是颜色分量的百分比(或从 0 到 1 的范围):

newColor = p * oldColor1 + (1 - p) * oldColor2
复制代码

颜色是很容易可视化的,但是所有其他顶点函数的输出也是类似的插值方式来得到各个片段。

注意:如果你不想一个顶点的输出被插值,就将属性[[ flat ]]添加到它的定义里。

Shader.Metal中,在文件末尾添加片段函数:

fragment float4 fragment_main() {
  return float4(1, 0, 0, 1);
}
复制代码

这可能是最简单的片段函数了。你返回了插值颜色float4。所有组成立方体的的片段都会是红色的。

GPU 接收片段并进行了一系列的后置处理测试:

  • 透明测试alpha-testing根据深度测试来确定哪个透明物体将被绘制,哪一个不会被绘制。
  • 在有透明物体的情况下,透明测试alpha-testing会将新物体的颜色与先前保存的颜色缓冲中的颜色进行混合。
  • 剪切测试scissor testing检查一个片段是否在一个特定的矩形框内;该测试对遮罩渲染非常有用。
  • 模板测试stencil testing检查片段所在的帧缓冲中的模板值,与我们选择的一个特定值之间的差别。
  • 在前一阶段中运行过了提前 Z 测试early-Z testing;现在late-Z testing也已经完成,以解决更多的可见性问题;模板和深度测试在环境光遮蔽与阴影中也非常有用。
  • 最后,反走样/反锯齿antialiasing也是在这里被计算的,这样最终显示在屏幕上的图像就不会看起来有锯齿了。

6-Framebuffer(帧缓冲)

一旦片段已经被处理成像素,分配器Distributer单元将他们发送到色彩写入Color Writing单元。这个单元负责将最终颜色写入到一个特殊的内存位置叫做framebuffer(帧缓冲)。从这里,视图得到了每一帧刷新时的带有颜色的像素。但是,颜色被写入帧缓冲是否意味着已经同时显示在屏幕上了呢?

一个叫做double-buffering(双重缓冲) 的技术用来解决这个问题。当第一个缓冲显示在屏幕上时,第二个在后台更新。然后,两个缓冲被交换,第二个缓冲被显示在屏幕上,第一个被更新,一直循环下去。

哟!这里要了解好多硬件信息啊。然而,你编写的代码用在每个 Metal 渲染器上,你就应该学会认识渲染的过程,尽管只是刚刚开始查看苹果的示例代码。

构建并运行 app,你的 app 将会渲染出一个红色的立方体。

你会注意到,立方体并不是正方形。记住,Metal 使用了标准化设备坐标Normalized Device Coordinates (NDC),x轴取值范围是-1 到 1。重设你的窗口尺寸,立方体将会保持与窗口成比例的大小。

发送数据到 GPU

Metal 就是用于华丽的图形及快速又平滑的动画。下一步,你将让你的立方体在屏幕上,上下来回移动。为了实现这个效果,你需要一个每帧更新的计时器,立方体的位置将依赖于这个计时器。你将在顶点函数中更新顶点的位置,这样就会将计时器数据发送到 GPU。

Renderer的上面,添加计时器属性:

var timer: Float = 0
复制代码

draw(in:)中,在下面代码前面:

renderEncoder.setRenderPipelineState(pipelineState)
复制代码

添加

// 1
timer += 0.05
var currentTime = sin(timer)
// 2
renderEncoder.setVertexBytes(&currentTime, 
                              length: MemoryLayout<Float>.stride, 
                              index: 1)
复制代码
  1. 添加计时器到每一帧中。你想要你的立方体上下移动,所以需要在-1~1 之间的一个值。使用sin()是很好的一个方法。
  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;
}
复制代码

这里,你的顶点函数 从 buffer 1 中接收了 float 格式的 timer。将 timer 的值加到 y 上,并返回新的位置。

构建并运行 app,现在你就得到了运动起来的立方体!

只加了几行代码,你学会了管线是如何工作的并且还添加了一点动画效果。

接下来做什么?

如果你想要查看本教程完成后的项目,你可以下载本教程资料,在final文件夹找到。

如果你喜欢在本教程所学到的东西,何不尝试一下我们的新书Metal by Tutorials呢?

这本书将会带你了解用 Metal 实现低级别的图形编程。当你学习该书时,你将会学到很多制作一个游戏引擎的基础知识,并逐步将其组装成你自己的引擎。

当你的游戏引擎完成时,你将能够组成 3D 场景并编码出自己的简单版 3D 游戏。因为你将从无到有构建你的 3D 游戏引擎,所以你将能够自定义屏幕上显示的任何内容。

但是除了技术上的定义处,Metal 还是使用 GPU 并行处理能力来可视化数据或解决数值难题的最理想方式。所以也被用于机器学习,图像/视频处理或者像本书中所写,图形渲染。

本书是那些想要学习 3D 图形或想要深入理解游戏引擎工作原理的,中级 Swift 开发者最好的学习资源。

如果你对本教程还有什么问题或意见,请在下面留言讨论!

资料下载

关注下面的标签,发现更多相似文章
评论