Metal 学习笔记三:渲染管线

现在,您对 3D 模型和渲染有了更多的了解,是时候了解一下渲染管线了。在本章中,您将创建一个渲染红色立方体的 Metal 应用程序。在学习本章时,您将仔细了解负责将 3D 对象转换为屏幕上看到的像素的硬件。首先是 GPU 和 CPU。

GPU和CPU

每台计算机都配备了图形处理单元(GPU)和中央处理单元(CPU)。

GPU是一种专业的硬件组件,可以非常快速地处理图像,视频和大量数据。在处理大量数据时,我们会用吞吐量,来衡量在一个指定时间单位内处理的数据量。另一方面,CPU管理资源并负责计算机的运作。尽管CPU无法像GPU这样处理大量数据,但它可以非常快速地处理许多顺序任务(一个接一个)。处理一个任务所需的时间称为延迟。

理想的设置包括低延迟和高吞吐量。低延迟允许连续执行排队的任务,因此CPU可以执行命令而无需系统变得缓慢或无响应 - 高吞吐量使GPU异步渲染视频和游戏而不会使CPU失速。由于GPU具有高度平行的体系结构,特别适合需要反复执行,而数据传输很少或没有数据传输的任务,因此它可以处理大量数据。

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

CPU 具有较大的高速缓存和少量的算术逻辑单元(ALU) 内核。相比之下,GPU 的缓存内存较小,ALU 内核很多。CPU 上的低延迟缓存用于快速访问临时资源。GPU 上的 ALU 内核处理计算,而无需将部分结果保存到内存中。

CPU 通常只有几个内核,而 GPU 有数百个甚至数千个内核。因为有更多内核,GPU 可以将问题拆分为许多较小的部分,每个部分在单独的内核上并行运行,这有助于掩盖延迟。在处理结束时,将合并各部分结果,并将最终结果返回到 CPU。但内核并不是唯一重要的事情。

除了比CPU精简之外,GPU 内核还具有用于处理几何体的特殊电路,通常称为着色器内核。这些着色器核心负责您在屏幕上看到的多彩的像素。GPU 会一次写入整个帧以适应整个渲染窗口;然后,它会尽快渲染下一帧,以便保持可观的帧速率。

CPU 会持续向 GPU 发出命令,确保 GPU 始终有工作要做。但是有时候,CPU会在完成发送命令给GPU后停下来等待,或者GPU会完成处理命令后停下来等待。为避免停顿,CPU 上的 Metal 会在命令缓冲区中将多个命令排队,并按顺序为下一帧发出新命令,而无需等待 GPU 完成上一帧。这意味着,无论谁先完成工作,总会有更多的工作要做。

图形管线的 GPU 部分在 GPU 收到所有命令和资源后启动。要开始使用渲染管线,您需要在新项目中设置这些命令和资源。

Metal项目

你已经用Playground学习Metal了。Playground是学习和测试新概念的绝妙场所,对于理解如何使用SwiftUI创建一个完整的Metal项目也是至关重要的。

在 Xcode 中,使用 Multiplatform App 模板创建新项目。

➤ 将您的项目命名为 Pipeline,并填写您的团队和组织标识符。将 Storage setting设为 None,并取消选中所有复选框选项。

选择新项目的位置。

太好了,您现在拥有了一个花哨的新 SwiftUI 应用程序。ContentView.swift 是应用程序的主要视图。这是您将调用 Metal 视图的地方。

MetalKit 框架包含一个 MTKView,这是一个特殊的 Metal 渲染视图。它是一种 iOS 上的 UIView 或 macOS 上的 NSView。要与 UIKit 或 Cocoa UI 元素交互,您将使用位于 SwiftUI 和 MTKView 之间的 Representable 协议。

此配置有点复杂,因此在本章的 resources 文件夹中,您将找到预先编写好的 MetalView.swift。

➤ 将此文件拖到您的项目中,确保选中所有复选框,以便复制文件并将其添加到应用程序的target。

打开 MetalView.swift。

MetalView 是一个 SwiftUI 视图结构体,它包含一个 MTKView 属性并托管 Metal 视图。MetalViewRepresentable 是 UIView 或 NSView 之间的接口,依赖于操作系统。

➤ 打开 ContentView.swift,并将 body 的内容更改为:

Swift 复制代码
VStack {
  MetalView()
    .border(Color.black, width: 2)
  Text("Hello, Metal!")
} .padding()

这里你将MetalView放到视图层级中,并给它添加一个border。

➤ 使用 macOS 目标或 iOS 目标构建和运行您的应用程序。

您将看到托管的 MTKView。使用 SwiftUI 的优势在于,将 UI 元素(例如此处的"Hello Metal"文本)分层相对容易地置于 Metal 视图下方。

您现在有一个选择。您可以对 MTKView 进行子类化,并将 MetalView 中的 MTKView 替换为子类化的 MTKView。在这种情况下,子类的 draw(_:) 将在每一帧中被调用,你将你的绘制代码放在该方法中。但是,在本书中,您将设置一个符合 MTKViewDelegate 的 Renderer 类,并将 Renderer 设置为 MTKView 的委托。MTKView 每帧调用一个委托方法,您将在此处放置必要的绘制代码。

Renderer类

创建一个Swift文件,命名为Renderer.swift,并添加如下代码:

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(_: drawableSizeWillChange:): 一旦窗口改变大小,都会调用这个函数,让你有机会更新你的渲染纹理大小和相机映射。

draw(in: ): 逐帧调用,是你编写渲染代码的地方。

打开MetalView.swift,在MetalView中添加一个属性来持有Renderer对象。

Swift 复制代码
@State private var renderer: Renderer?

将body修改为:

Swift 复制代码
var body: some View {
  MetalViewRepresentable(metalView: $metalView)
.onAppear {
      renderer = Renderer(metalView: metalView)
    }
}

这里,当Metal View第一次出现的时候,你初始化renderer属性。

初始化

就像你第一节做的那样,你需要设置好Metal环境。

Metal比起OpenGL有一个较大的改进就是,你可以预先实例化一些对象,而不用每一帧都创建它们。下图展示了一些你可以在app初始化时候就预先创建好的对象。

MTLDevice: GPU硬件设备的软件引用。

MTLCommandQueue: 负责创建与组织每帧的MTLCommandBuffer。

MTLLibrary: 包含你的顶点和片段着色函数的源代码。

MTLRenderPipelineState: 设置渲染相关状态信息,比如需要使用哪个着色函数,颜色格式以及深度格式,以及怎么读取顶点数据等。

MTLBuffer: 持有数据,类似顶点数据等,并且组织成一个你可以直接发送给GPU的形式。

通常,在你的app中只有一个MTLDevice,一个MTLCommandQueue,以及一个MTLLibrary对象。你可以有多个MTLRenderPipelineState对象,用来定义各种管线状态,另外你也会用多个MTLBuffer去持有不同的数据。

在你使用这些对象之前,你需要初始化它们。添加这些属性到Renderer类中:

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

为方便起见,所有这些属性目前都是隐式解包的可选值,但如果您愿意,可以稍后添加错误检查。

您正在使用设备、命令队列和库类型的类属性,以确保只存在一个对象。在极少数情况下,您可能需要多个,但在大多数应用程序中,一个就足够了。

➤ 还是在 Renderer.swift 中,在 super.init()之前添加以下代码到 init(metalView:):

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

这些代码初始化GPU并创建了命令队列。

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

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

此代码将 metalView.clearColor 设置为奶油色。它还将 Renderer 设置为 metalView 的代理,以便视图调用 MTKViewDelegate 绘制方法。

➤ 构建并运行应用程序以确保一切正常。如果一切正常,您将像以前一样看到 SwiftUI 视图,并且在调试控制台中,您将重复看到"draw"一词。使用此控制台语句可验证您的应用程序是否为每一帧调用 draw(in:)。

注意,我们这里还没有看到metalView的奶油色,是因为我们还没有开始用GPU来绘制东西。

创建网格

您已经使用 Model I/O 创建了一个球体和一个圆锥体;现在是时候创建一个立方体了。

在init(metalView:)中,super.init()前面插入如下代码:

Swift 复制代码
// create the mesh
let allocator = MTKMeshBufferAllocator(device: device)
let size: Float = 0.8
let mdlMesh = MDLMesh(
  boxWithExtent: [size, size, size],
  segments: [1, 1, 1],
  inwardNormals: false,
  geometryType: .triangles,
  allocator: allocator)
do {
  mesh = try MTKMesh(mesh: mdlMesh, device: device)
} catch {
  print(error.localizedDescription)
}

这个函数会返回一个立方体,就像之前绘制球体时,创建球体网格差不多的代码。

然后,设置MTLBuffer,它包含即将发送到 GPU 的顶点数据。

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

这会把网格数据放到一个MTLBuffer里面。现在,你需要创建好管线状态对象,以便GPU知道如何渲染这些顶点数据。

设置Metal Library

首先,创建一个MTLLibrary,并确保顶点和片段着色器函数已经提供好了。

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

Swift 复制代码
// create the shader function library
let library = device.makeDefaultLibrary()
Self.library = library
let vertexFunction = library?.makeFunction(name: "vertex_main")
let fragmentFunction =
  library?.makeFunction(name: "fragment_main")

在这里,您将使用一些着色器函数指针设置默认Library。在本章后面,您将创建这些着色器函数。与 OpenGL 着色器不同,这些函数是在编译项目时编译的,这比动态编译函数更有效。现在这些编译结果已经保存在library中了。

创建管线状态

现在创建管线状态对象:

要配置 GPU 的状态,需要创建一个管线状态对象 (PSO)。此管线状态可以是用于渲染顶点的渲染管线状态,也可以是用于运行计算内核的计算管线状态。

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

Swift 复制代码
// create the pipeline state object
let pipelineDescriptor = MTLRenderPipelineDescriptor()
pipelineDescriptor.vertexFunction = vertexFunction
pipelineDescriptor.fragmentFunction = fragmentFunction
pipelineDescriptor.colorAttachments[0].pixelFormat =
  metalView.colorPixelFormat
pipelineDescriptor.vertexDescriptor =
  MTKMetalVertexDescriptorFromModelIO(mdlMesh.vertexDescriptor)
do {
  pipelineState =
    try device.makeRenderPipelineState(
      descriptor: pipelineDescriptor)
} catch {
  fatalError(error.localizedDescription)
}

PSO 保存 GPU 的潜在状态。GPU 需要知道其完整状态,然后才能开始管理顶点。在这里,您设置 GPU 即将调用的两个着色器函数以及 GPU 即将写入的纹理的像素格式。您还可以设置管线的顶点描述符;这就是 GPU 知道如何解析您将在网格数据 MTLBuffer 中呈现的顶点数据的方式。

注意:如果您需要使用不同的数据缓冲区布局或调用不同的顶点或片段函数,则需要额外的管线状态。创建管线状态相对耗时,因此需要提前执行此操作,但在每帧期间切换管线状态既快速又高效。

初始化完成,您的工程将可以编译。接下来,您将开始绘制模型。

渲染帧

MTKView 为每一帧调用 draw(in:);这是设置 GPU 渲染命令的地方。

➤ 在 draw(in:)中,将 print 语句替换为:

Swift 复制代码
guard
  let commandBuffer = Self.commandQueue.makeCommandBuffer(),
  let descriptor = view.currentRenderPassDescriptor,
  let renderEncoder =
    commandBuffer.makeRenderCommandEncoder(
      descriptor: descriptor) else {
return
}

您将向 GPU 发送一系列包含在命令编码器中的命令。在一个帧中,您可能有多个命令编码器,命令缓冲区管理这些编码器。

您可以使用渲染通道描述符创建渲染命令编码器。它包含用于GPU 绘制的渲染目标纹理。在复杂的应用程序中,很可能在一个帧中具有多个渲染通道、多个目标纹理。稍后您将学习如何将渲染通道链接在一起。

继续添加如下代码:

Swift 复制代码
// drawing code goes here
// 1
renderEncoder.endEncoding()
// 2
guard let drawable = view.currentDrawable else {
return
}
commandBuffer.present(drawable)
// 3
commandBuffer.commit()

下面仔细查看代码:

  1. 将 GPU 命令添加到命令编码器后,结束其编码。

  2. 将视图的可绘制纹理呈现给 GPU。

  3. 提交命令缓冲区时,将编码的命令发送到 GPU 执行。

绘制

现在是时候设置 GPU 绘制帧所需的命令列表了。换句话说,您将:

• 设置管线状态以配置 GPU 硬件。

• 为 GPU 提供顶点数据。

• 使用网格的子网格组发出绘制调用。

➤ 仍在 draw(in:) 中,替换注释:

Swift 复制代码
// drawing code goes here

替换为:

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

太好了,您设置了 GPU 命令来设置管线状态和顶点缓冲区,并在网格的子网格上执行绘制调用。当您在 draw(in:) 结束时提交命令缓冲区时,您是在告诉 GPU 数据和管线已准备就绪,是时候让 GPU 接管了。

渲染管线

您准备好研究 GPU管线了吗?太好了,让我们开始吧!在下图中,您可以看到管线的各个阶段。

图形管线将顶点带入多个阶段,在此期间,顶点的坐标在各种空间之间转换。

注意:本章介绍即时模式渲染 (IMR) 体系结构。自 A11 起,Apple 的 iOS 芯片和适用于 macOS 的 Apple Silicon 都使用基于图块的渲染 (TBR)。新的 Metal 特性能够利用 TBR。但是,为简单起见,您将从对一般 GPU 架构的基本了解开始。如果您想预览一些差异,请观看 Apple 的 WWDC 2020 视频将您的 Metal 应用程序带到搭载 Apple 芯片的 Mac (https:// developer.apple.com/videos/play/wwdc2020/10631/)。

作为 Metal 程序员,您只关心顶点和片段处理阶段,因为它们是此管线中仅有的两个可编程阶段。在本章的后面部分,您将编写顶点着色器和片段着色器。对于所有不可编程的管线阶段,例如顶点获取、基元组装和光栅化,GPU 专门设计了硬件单元来为这些阶段提供服务。

1-顶点获取

此阶段的名称因不同的图形应用程序编程接口 (API) 而异。例如DirectX中称它为输入装配。

要开始渲染 3D 内容,您首先需要一个场景。场景由具有顶点网格的模型组成。最简单的模型之一是立方体,它有 6 个面(12 个三角形)。正如您在上一章中看到的,您使用顶点描述符来定义读取顶点及其属性(例如位置、纹理坐标、法线和颜色)的方式。您可以选择不使用顶点描述符,而只在 MTLBuffer 中发送顶点数组,但是,如果您决定不使用顶点描述符,则需要提前了解顶点缓冲区的组织方式。

当 GPU 提取顶点缓冲区时,MTLRenderCommandEncoder 绘制调用会告知 GPU 缓冲区是否使用索引。如果缓冲区不使用索引,则 GPU 会假定缓冲区是一个数组,并且它会按顺序一次读取一个顶点元素。

在上一章中,您了解了 Model I/O 如何导入 USD 文件并设置由子网格索引的缓冲区。此索引非常重要,因为顶点被缓存以供重用。例如,一个立方体有 12 个三角形和 8 个顶点(在角处)。如果不使用索引,则必须为每个三角形指定顶点,并将 36 个顶点发送到 GPU。这听起来可能并不多,但在具有数千个顶点的模型中,顶点缓存非常重要。

还有用于着色顶点的第二个缓存,以便多次访问的顶点仅着色一次。着色顶点是已应用颜色的顶点。但这会在下一阶段发生。

称为调度器的特殊硬件单元将顶点及其属性发送到顶点处理阶段。

2-顶点处理

在顶点处理阶段,将单独处理每个顶点。您编写代码来计算每个顶点的光照和颜色。更重要的是,您可以通过各种坐标空间发送顶点坐标,以到达它们在最终帧缓冲区中的位置。

在第一章《Hello, Metal!》中,您简要了解了着色器功能和 Metal Shading Language (MSL) 。现在,是时候看看硬件层面的引擎盖下发生了什么。

看看这张AMD GPU架构图:

自上而下,GPU 具有:

• 1 个图形命令处理器:用于协调工作流程。

• 4 个着色器引擎 (SE):一个 SE 是 GPU 上的一个组织单位,可以为整个管线提供服务。每个 SE 都有一个几何处理器、一个光栅器和计算单元。

• 9 个计算单元 (CU):一个 CU 只不过是一组着色器核心。

• 64 个着色器核心:着色器核心是 GPU 的基本构建块,所有着色工作在其中完成。

这 36 个 CU 总共有 2,304 个着色器核心。将其与 8 核 CPU 中的核心数进行比较。

对于移动设备,情况略有不同。为了进行比较,请查看下图,其中显示了与最新 iOS 设备中的 GPU 类似的 GPU。PowerVR GPU 没有 SE 和 CU,而是具有统一着色集群 (USC)。

这种特殊的 GPU 模型有 6 个 USC 和每个 USC 32 个内核,总共只有 192 个内核。

注意,如上架构仅供参考,iPhoneX之后的机型的GPU是完全由苹果设计,并且不对外公开技术细节了。

那么,你能用这么多内核做什么呢?由于这些核心专门用于顶点和片段着色,因此要做的一个明显的事情是让所有核心并行工作,以便更快地完成顶点或片段的处理。不过,有一些规则。

在 CU 中,您只能处理顶点或片段,并且一次只能处理其中一种。(幸好有 36 个!)另一个规则是每个 SE 只能处理一个着色器函数。拥有 4 个 SE 可以让您以有趣和有用的方式组合工作。例如,您可以同时在一个 SE 上运行一个片段着色器,同时在第二个 SE 上运行第二个片段着色器。或者,您可以将顶点着色器与片段着色器分开,让它们在不同的 SE 上并行运行。

创建顶点着色器

是时候看看顶点处理的实际应用了。您将要编写的顶点着色器是最小的,但它封装了您在本章和后续章节中需要的大部分必要顶点着色器语法。

➤ 使用 Metal File 模板创建一个新文件,并将其命名为 Shaders.metal。然后,在文件末尾添加以下代码:

Swift 复制代码
// 顶点输入结构:描述你在之前设置的顶点描述的各个属性,
// 在这个例子,只有position属性
struct VertexIn {
  float4 position [[attribute(0)]];
};

// 实现一个顶点shader函数,它接受VertexIn输入的顶点数据,
// 并返回float4格式的顶点位置
vertex float4 vertex_main(const VertexIn vertexIn [[ stage_in ]]) {
  return vertexIn.position;
}

记住顶点数据在顶点缓冲被索引。顶点着色器利用[[stage_in]]属性获取当前索引,并解码当前索引的顶点数据到VertexIn结构体。

计算单元(CU)一次可以批量处理多个顶点,最大可以到它所含有的着色器核心的个数,比如AMD的一个CU有64个shader核心。这种批操作可以完全适配CU缓存,并且当需要时顶点可以被重用。批处理会一直保持CU繁忙,直到本次顶点处理阶段完成。这样的CU会变成可用状态,以等待下一次批处理。

一旦顶点处理完成,缓冲会清空,以便给下次顶点的批处理。至此,顶点是有序而且分组好了,准备发送到图元装载阶段了。

我们总结一下,CPU端通过创建一个模型的mesh,发送一个顶点缓冲给GPU。我们用一个顶点描述器配置顶点缓冲,并告诉GPU这些顶点数据是怎么组织的。在GPU,我们创建一个vertexIn结构来概述顶点属性。顶点着色器把这个结构当作输入数据,用作一个函数参数,然后通过[[stage_in]]修饰符,让我们知道posiontion是从CPU来的,并且它的[[attribute(0)]]就是顶点数据的位置属性。顶点shader函数然后处理所有顶点数据,并且返回它们的位置,使用float4格式。

注意当我们使用一个带属性的顶点描述时,并不一定要完全匹配类型。在MTLBuffer中位置是float3格式,而在shader里面的VertexIn结构可以把它读做一个float4格式。

一个特定硬件单元分发器(Distributer)会把分组块的顶点发送到图元装载阶段。

3-图元装配

上一阶段将分组为数据块的已处理顶点发送到此阶段。要记住的重要一点是,属于同一几何形状(图元)的顶点总是在同一个块中。这意味着一个点的一个顶点,或者一条线的两个顶点,或者一个三角形的三个顶点,将始终位于同一个块中,因此不需要获取第二个块。

除了顶点,CPU 在发出 draw call 命令时还会发送顶点连接信息,如下所示:

Swift 复制代码
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:与单条直线相同,不同之处在于,lineStrip连接所有相邻顶点并形成一条折线。每个顶点(第一个顶点除外)都连接到前一个顶点。

• triangle:对于每三个顶点的序列,栅格化一个三角形。如果最后一个顶点无法形成另一个三角形,则忽略它们。

• triangleStrip:与简单三角形相同,但相邻顶点也可以连接到其他三角形。

还有一种称为 patch 的原始类型,但这需要特殊处理。您将在第19章"镶嵌与地形"中阅读更多关于patch的信息。

正如您在上一章中所读到的,管线指定了顶点的环绕顺序。如果环绕顺序是逆时针方向,三角形顶点顺序是逆时针方向,则顶点组成的平面是朝向正面的;否则,朝向背面,可以被剔除,因为您看不到它们的颜色和光照。当图元完全被其他图元遮挡时,它们将被剔除。

图元如果不在屏幕也将会被剔除,如果它只是一部分在屏幕外,则会被裁剪。

为了提高效率,您应该在 pipeline 状态中设置环绕顺序并启用背面剔除。

此时,已经完全从连接的顶点组装成图元,并准备好继续传递到光栅化器。

4-光栅化

当前有两个不同分支的现代渲染技术,但有时一起使用:光线追踪和光栅化。它们完全不同,各有利弊。光线追踪(您将在第 27 章"使用光线渲染"中阅读更多内容)在渲染静态且距离较远的内容时是首选,而当内容更靠近摄像机且更具动态性时,则首选光栅化。

对于光线跟踪,对屏幕上的每个像素,它都要发送一个射线到场景中,以看它是否和一个物体相交,如果是的话,改变像素的颜色为那个物体的颜色,不过只有当那个物体足够靠近屏幕,并超过之前保存的这个像素对应的物体。

光栅化是另一种方法:对于场景的每个物体,发送射线回屏幕并检测这个物体覆盖了哪些像素,深度信息就像光线跟踪那样保存,所以只要这个对象比之前保存的对象离屏幕更近,则会更新屏幕中这个像素的颜色。

此时,从上一阶段发送的所有已连接顶点,都需要使用其 X 和 Y 坐标在二维网格上表示。此步骤称为三角形设置。在这里,光栅器需要计算任意两个顶点之间线段的斜率或陡度。

当三个顶点构成的三条直线的斜率已知时,三角形可以从这三条边形成。

接下来,一个称为扫描转换的过程运行在屏幕上的每一行,寻找交叉点,并确定哪些像素是可见的,哪些是不可见的。此时要在屏幕上绘图,您只需要顶点和它们确定的斜率。

扫描算法确定线段上的所有点或三角形内部的所有点是否可见,在这种情况下,三角形完全被颜色填充。

对于移动设备,光栅化利用了 PowerVR GPU 的平铺块架构,方法是在 32x32 平铺网格上并行栅格化图元。在这种情况下,32 是分配给一个tile的屏幕像素数,但此大小与 USC 中的内核数完美契合。

如果一个对象位于另一个对象后面怎么办?光栅器如何确定要渲染的对象?这个隐藏面移除问题可以通过使用存储的深度信息(早期 Z 测试)来确定每个点是否位于场景中其他点的前面来解决。

栅格化完成后,三个更专用的硬件单元将登台:

• 称为分层-Z的缓冲区负责删除被光栅器标记为剔除的片段。

• 然后,通过将不可见的片段与深度和模板缓冲区进行比较,Z 和模板测试单元删除这些片段。

• 最后,插值单元获取剩余的可见片段,并从装配的三角形属性生成片段属性。

此时,Scheduler 单元再次将工作分派给着色器核心,但这次是发送进行片段处理的光栅化片段。

5-片元处理

是时候复习下管线了。

• 顶点获取单元从内存中获取顶点并将其传递给调度器单元。

• 调度器单元知道哪些着色器核心可用,因此它会给它们分派工作。

• 当着色器工作完成后,调度器单元知道此工作是顶点还是片元处理。如果工作是顶点处理,它会将结果发送到图元装配单元。这个分支下一个步骤是光栅化单元,然后返回到调度器单元。如果工作是片元处理,它会将结果发送到颜色写入单元。

• 最后,彩色像素被发送回内存。

前面阶段的图元处理是连续的,因为只有一个图元装配单元和一个光栅化单元。但是,一旦片段到达调度器单元,工作就可以分叉(划分)为许多小部分,并且每个部分都分配给一个可用的着色器核心。

数百甚至数千个内核现在正在进行并行处理。当工作完成后,结果将被连接(合并)并再次按顺序发送到内存。

片段处理阶段是另一个可编程阶段。您可以创建一个片段着色器函数,该函数将接收顶点函数输出的光照、纹理坐标、深度和颜色信息。片段着色器输出是该片段的单一颜色。每一个片段都将影响帧缓冲区中最终像素的颜色。每个片元的每个属性都是经过插值形成的。

例如,要渲染这个三角形,顶点函数将处理三个顶点,它们的颜色分别为红色、绿色和蓝色。如图所示,组成此三角形的每个片段都是从这三种颜色中插值的。线性插值只是对两个端点之间线上每个点的颜色进行平均。如果一个端点为红色,另一个端点为绿色,则它们之间线上的中点将为黄色等等。

插值公式如下,其中参数p是它在线段中的位置的百分比(范围是 0到1):

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

颜色比较容易可视,顶点函数输出的其他属性(比如 法线 和 UV坐标等)都是会插值并输出给每个片元的。

注意:如果你不想你的顶点输出插值的话,可以给它的定义添加[[flat]]。

创建片元着色器

在Shaders.Metal,添加片元着色器代码:

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

这是最简单的片段函数。您以 float4 的形式返回内插颜色red,它以 RGBA 格式描述颜色。组成立方体的所有片段都将是红色的。GPU 获取片段并执行一系列后处理测试:

• alpha 测试根据深度测试确定绘制 (和不绘制) 哪些不透明对象。

• 对于半透明对象,alpha 混合会将新对象的颜色与之前保存在颜色缓冲区中的颜色合并。

• 裁剪测试检查片段是否位于指定矩形内;此测试对于蒙版渲染非常有用。

• 模板测试检查存储片段的帧缓冲区中的模板值与我们选择的指定值的比较情况。

• 在前一阶段,运行早期Z 测试;现在,进行了后期Z 测试以解决更多的可见性问题;模板和深度测试也可用于环境光遮蔽和阴影。

• 最后,此处还计算了抗锯齿,以便到达屏幕的最终图像看起来不会出现锯齿状。

您将在第 20 章 "片段后处理"中了解有关后处理测试的更多信息。

6-帧缓存

当片元被处理成像素后,分配器单元会发送它们到"颜色写入"单元。这个单元负责写入最终颜色到一个指定的内存位置,即帧缓冲。至此,视图获取到它的像素颜色,并每帧刷新。但是是否意味着帧缓冲会直接把它显示到屏幕上?

有一种技术叫做双缓冲,可以用来避免像素一边写入,一边显示到屏幕带来的闪烁问题。当我们的第一个缓冲显示在屏幕中时,第二个缓冲在后台被写入颜色。然后两个缓冲互换,第二个缓冲会显示在屏幕上,循环往复。

哦!这些就是我们要理解的一些硬件信息。目前我们所写的所有代码在其他Metal程序中也会用到,尽管这里只是一个起点,你以后看苹果的实例代码就能认出渲染流程了。

编译和运行这个程序,你会看到一个红色的立方体:

注意这个立方体目前还不是一个正方形的,记得Metal使用的是Normalized Device Coordinates(NDC),X轴是从-1到1,改变你的窗口大小,这个立方体会跟着拉伸。下一节,我们就能把物体精确地放到屏幕中了。

您在渲染管线中经历了多么不可思议的旅程。在下一章中,您将更详细地探索顶点和片段着色器。

挑战

使用此项目的 resources 文件夹中的 train.usdz 模型,将立方体替换为此 train。导入模型时,请务必选择 Create Groups 并记住将模型添加到Target。

使用以下代码在顶点函数中更改模型的垂直位置:

Swift 复制代码
float4 position = vertexIn.position;
position.y -= 1.0;
return position;

最后,将您的火车涂成蓝色。

如果需要帮助,请参阅上一章了解Asset加载和顶点描述符代码。此挑战的完成代码位于本章的项目挑战目录中。

参考

https://zhuanlan.zhihu.com/p/385638027

相关推荐
二流小码农10 小时前
鸿蒙开发:实现一个标题栏吸顶
android·ios·harmonyos
season_zhu10 小时前
iOS开发:关于日志框架
ios·架构·swift
Digitally14 小时前
如何在电脑上轻松访问 iPhone 文件
ios·电脑·iphone
安和昂14 小时前
【iOS】YYModel源码解析
ios
pop_xiaoli14 小时前
UI学习—cell的复用和自定义cell
学习·ui·ios
大熊猫侯佩15 小时前
SwiftUI 中如何花样玩转 SF Symbols 符号动画和过渡特效
swiftui·swift·apple
大熊猫侯佩16 小时前
SwiftData 共享数据库在 App 中的改变无法被 Widgets 感知的原因和解决
swiftui·swift·apple
大熊猫侯佩16 小时前
使用令牌(Token)进一步优化 SwiftData 2.0 中历史记录追踪(History Trace)的使用
数据库·swift·apple
大熊猫侯佩16 小时前
SwiftUI 在 iOS 18 中的 ForEach 点击手势逻辑发生改变的解决
swiftui·swift·apple
Daniel_Coder17 小时前
Xcode 16.4 + iOS 18 系统运行时崩溃:___cxa_current_primary_exception 符号丢失的原因与解决方案
ios·xcode·ios 18·dyld·libc++abi