[譯]Metal 渲染管線教程

史前圖騰發表於2018-10-05

譯者注:本文是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 之間的主要差異。

[譯]Metal 渲染管線教程

CPU 有大容量快取及少量算術邏輯單元Arithmetic Logic Unit (ALU) 核心。CPU 上的低延遲快取是用於快速訪問臨時資源。GPU 沒有那麼大的快取,但有更多的 ALU 核心,它們只進行計算無需儲存中間結果到記憶體中。

同時,CPU 只有幾個核心,而 GPU 有上百個甚至上千個核心。有了更多的核心,GPU 可以將問題分割成許多小部分,每個部分並行執行在單獨的核心上,這樣隱藏了延遲。處理完成後,各部分的結果被組合起來,並將最終結果返回給 CPU。但是,核心數並不是惟一的關鍵因素!

GPU 核心除了經過精簡之外,還有一些特殊的電路用來處理幾何體,一般叫做shader cores(著色器核心)。這些著色器核心負責處理你在螢幕上看到的各種漂亮顏色。GPU 一次寫入一整幀來填滿整個渲染視窗。然後繼續處理下一幀以維持一個合理的幀率。

[譯]Metal 渲染管線教程

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

[譯]Metal 渲染管線教程

在右側檢查器中,將 view 從NSView改為MTKView

[譯]Metal 渲染管線教程

這樣就將主檢視作為了 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 一開始就建立的物件。

[譯]Metal 渲染管線教程

  • 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 渲染管線教程

圖形管線在多個階段都接收頂點,同時頂點會在多個空間座標系中進行變換。

做為一個 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 的架構:

[譯]Metal 渲染管線教程
從上到下,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 個核心。

[譯]Metal 渲染管線教程

注意: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 會變成可用狀態以處理下一批次。

頂點處理一旦完成,快取記憶體就會被清理,為下一批次頂點做好準備。此時,頂點已經被排序過,分組過了,準備被髮送到下一階段了。

[譯]Metal 渲染管線教程

回顧一下,CPU 將一個從模型的網格中建立的頂點緩衝傳送給 GPU。用一個頂點描述符來配置頂點緩衝,以此告訴 GPU 頂點資料是什麼結構的。在 GPU 上,你建立一個結構體來包裝頂點屬性。頂點著色器通過函式引數接收這個結構體,並通過[[ stage_in ]]修飾詞,知道了position是從 CPU 通過頂點緩衝中的[[ attribute(0) ]]位置傳遞過來。然後,頂點著色器處理所有的頂點並通過float4返回他們的位置。

一個特殊的硬體單元叫做分配器Distributer,將分組過的頂點資料塊傳送到下一個Primitive Assembly(圖元組裝) 階段。

3-Primitive Assembly(圖元組裝)

前一階段將頂點分組成資料塊傳送到本階段。需要注意的是,同一個幾何體形狀(圖元primitive)的頂點總是會在同一個塊中。這就意味著,一個頂點的點,或兩個頂點的線,或者三個頂點的三角形,總是會在同一個塊中,因此,再也不需要讀取第二個資料塊了。

[譯]Metal 渲染管線教程

與此同時,CPU 還在派發繪製呼叫draw call命令時,傳送了頂點的連線資訊過來,比如這樣:

renderEncoder.drawIndexedPrimitives(type: .triangle,
                          indexCount: submesh.indexCount,
                          indexType: submesh.indexType,
                          indexBuffer: submesh.indexBuffer.buffer,
                          indexBufferOffset: 0)
複製程式碼

繪製函式的第一個引數包含了最重要的頂點連線資訊。在本例中,它告訴 GPU 利用拿到的頂點緩衝繪製三角形。

Metal API 提供了五種基礎形狀:

[譯]Metal 渲染管線教程

  • point:為每個頂點光柵化一個點。你可以在頂點著色器中用屬性[[point_size]]來指定點的尺寸。
  • line:為每一對頂點光柵化出之間的線段。如果一個頂點已經包含在一條線上了,它就不能再被包含在另一條線上。如果頂點是奇數個,那麼最後的頂點會被忽略掉。
  • lineStrip:和前面的簡單直線類似,但是 line strip 連線了所有鄰近的頂點,形成了一個多段線。每個頂點(除了第一個)都連線到前一個頂點上。
  • trangle:為每三個連續頂點光柵化出一個三角形。如果最末尾的頂點不能構成三角形,它們將被忽略。
  • trangleStrip:和前面的簡單三角形類似,但是一個頂點可以和相鄰的三角形邊構成新的三角形。

其實還有另一種基礎形狀(圖元)叫做patch,但是它需要特殊處理,不能被用在帶有索引的繪製呼叫函式中。

管線指定了頂點的旋轉方向。如果旋轉方向是逆時針的,那麼三角形頂點的順序就是逆時針的面,就是正面。否則,這個面就是背面,可以被剔除,因為我們看不到他們的顏色和光照。

當被其他圖元遮擋時,該圖元將會被剔除,但是,如果他們只是部分在螢幕外,他們將會被裁剪。

[譯]Metal 渲染管線教程

為了效率,你應當指定旋轉方向並啟用背面剔除。

此時,圖元已經從頂點被完全組裝好了,並將進入到光柵化器。

4-Rasterization(光柵化)

當前,有兩種不同的渲染技術:光線追蹤ray tracing光柵化rasterization,當然有時候也會一起使用。它們差異非常大,各有優點和缺點。

當渲染內容是靜態的,距離較遠的時候,光線追蹤效果更好;當內容非常靠近鏡頭且不斷移動時,光柵化效果更好。

使用光線追蹤時,從螢幕上的每一個點,發射一條射線到場景中,看看是否和場景中的物體有交點。如果有,將螢幕上畫素的顏色改成距離螢幕最近的物體的顏色。

光柵化是另一種工作方式:從場景中的每一個物體,發射射線到螢幕上,看看哪些畫素被該物體覆蓋了。深度資訊也會像光線追蹤一樣被保留,所以,當有更近的物體出現時,會更新螢幕上畫素的顏色。

此時,上一階段中發過來的連線後的頂點,會根據 X 和 Y 座標被呈現在二維網格上。這一步就是三角形設定triangle setup

這裡,光柵化器需要計算任意兩個頂點間線段的斜率。當三個頂點間的三個斜率都已知後,三角形就可以同這三條邊構成。

下一步的處理叫做掃瞄轉換scan conversion,逐行掃瞄螢幕尋找交點,確定哪一部分是可見的,哪一部分是不可見的。要繪製螢幕上的點,只需它們的頂點和斜率就夠了。掃瞄演算法確定是否線段上的所有點或三角形內的所有點都是可見的,如果是可見的,就全都會被填充上顏色。

[譯]Metal 渲染管線教程

對移動裝置來說,光柵化可以充分利用 PowerVR GPU 的tiled架構優勢,可以並行光柵化一個 32x32 的圖塊網格。這樣一來,32 就是分配給圖塊的螢幕畫素的數量,該尺寸完美匹配了 USC 的核心數量。

如果一個物體躲在另一個物體後面會怎樣?光柵化器如何決定哪個物體要被渲染呢?這個隱藏表面的移除問題可以被解決,方法是通過使用儲存的深度資訊(提前 Z 測試)來決定任意一個點是否在場景中另一些點的前面。

在光柵化完成後,三個另外的硬體單元接管了任務:

  • 一個叫Hierarchical-Z的緩衝,負責移除那些被光柵化器標記為剔除的片段。
  • Z and Stencil Test單元接著對比片段與深度緩衝和模板緩衝,移除那些不可見的片段。
  • 最後,插值器Interpolator單元接收剩餘的可見片段,並從組裝好的三角形屬性中產生片段屬性。

此時,排程器Scheduler單元再次將任務排程給著色器核心,但是這一次,光柵化後的片段被髮送到Fragment Processing(片段處理) 階段。

5-Fragment Processing(片段處理)

是時候快速複習一下管線知識了。

[譯]Metal 渲染管線教程

  • 頂點獲取Vertex Fetch單元從記憶體中抓取資料,並將其傳遞到排程器Scheduler單元。
  • 排程器Scheduler單元知道哪些著色器核心是可用的,就向其分配工作。
  • 工作完成後,分配器Distributer單元會知道這個工作是頂點處理或者片段處理
  • 如果是頂點處理工作,它就將結果傳送到Primitive Assembly(圖元組裝) 單元。這條路徑繼續走到Rasterization(光柵化) 單元,然後再回到排程器Scheduler單元。
  • 如果是片段處理工作,它就將結果傳送到色彩寫入Color Writing單元。
  • 最後,著色過的畫素被髮送回到記憶體中。

前一階段的圖元處理是序列進行的,因為只有一個Primitive Assembly(圖元組裝) 單元,及一個Rasterization(光柵化) 單元。然而,一旦片段到達了排程器Scheduler單元,工作就可以被分叉forked(分割)成許多小的部分,每一部分被分配到可用的著色器核心上。

上百個甚至上千個核心現在在並行處理。當工作完成後,結果就會被接合joined(合併)並再次傳送到記憶體中。

片段處理階段是另一個可程式設計控制階段。你將建立一個片段著色函式來接收頂點函式輸出的光照,紋理座標,深度和顏色資訊。

片段著色器的輸出是該片段的顏色。每一個片段都會為幀緩衝中的最終畫素顏色做出貢獻。每個片段的所有的屬性是插值得到的。

[譯]Metal 渲染管線教程

例如,要渲染一個三角形,頂點函式會處理三個頂點,顏色分別為紅,綠和藍。正如圖表顯示的那樣,組成三角形的每個片段都是三種顏色插值得到的。線性插值就是簡單地根據兩個端點的距離和顏色平均一下得到的。如果一個端點是紅色的,另一個端點是綠色的,那麼線段的中間點的顏色就是黃色的。依此類推。

插值方程的引數化形式如下,其中引數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 渲染管線教程
你會注意到,立方體並不是正方形。記住,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,現在你就得到了運動起來的立方體!

[譯]Metal 渲染管線教程

只加了幾行程式碼,你學會了管線是如何工作的並且還新增了一點動畫效果。

接下來做什麼?

如果你想要檢視本教程完成後的專案,你可以下載本教程資料,在final資料夾找到。

[譯]Metal 渲染管線教程

如果你喜歡在本教程所學到的東西,何不嘗試一下我們的新書Metal by Tutorials呢?

這本書將會帶你瞭解用 Metal 實現低階別的圖形程式設計。當你學習該書時,你將會學到很多製作一個遊戲引擎的基礎知識,並逐步將其組裝成你自己的引擎。

當你的遊戲引擎完成時,你將能夠組成 3D 場景並編碼出自己的簡單版 3D 遊戲。因為你將從無到有構建你的 3D 遊戲引擎,所以你將能夠自定義螢幕上顯示的任何內容。

但是除了技術上的定義處,Metal 還是使用 GPU 並行處理能力來視覺化資料或解決數值難題的最理想方式。所以也被用於機器學習,影象/視訊處理或者像本書中所寫,圖形渲染。

本書是那些想要學習 3D 圖形或想要深入理解遊戲引擎工作原理的,中級 Swift 開發者最好的學習資源。

如果你對本教程還有什麼問題或意見,請在下面留言討論!

資料下載

相關文章