到目前為止,您已經完成了 3D 模型和圖形管道。現在,是時候看看 Metal 中兩個可編程階段中的第一個階段,即頂點階段,更具體地說,是頂點函數。
著色器函數
定義著色器函數時,可以為其指定一個屬性。您將在本書中學到這些屬性:
? vertex:頂點函數:計算頂點的位置。
? fragment: 片段函數: 計算片段的顏色。
? kernel:內核功能:用于通用的并行計算,例如圖像處理。
在本章中,您將只關注 vertex 函數。在第 7 章 “片段函數”中,您將探索如何控制每個片段的顏色。在第 16 章 “GPU 計算編程” 中,您將了解如何使用具有多個線程的并行編程來寫入緩沖區和紋理。
到目前為止,您應該已經熟悉頂點描述符,以及如何使用它們來描述如何從加載的 3D 模型中排列頂點屬性。回顧一下:
? MDLVertexDescriptor:使用Model I/O 頂點描述符讀取 USD 文件。Model I/O 會創建緩存區,緩存區會按我們所需的布局,存放屬性值,例如位置、法線和紋理坐標。
? MTLVertexDescriptor:在創建管線狀態時使用 Metal 頂點描述符。GPU 頂點函數使用 [[stage_in]] 屬性將傳入數據與管線狀態中的頂點描述符進行匹配。
在學習本章時,您將在不使用頂點描述符情況下,構建自己的頂點網格并將頂點發送到 GPU。您將學習如何在頂點函數中控制這些頂點,然后升級到使用頂點描述符。在此過程中,您將看到如何使用 Model I/O 導入網格,從而為您完成許多繁重的工作。
開始項目
? 打開本章的初始項目。
此 SwiftUI 項目包含一個簡化的 Renderer,以便您可以添加自己的網格,并且著色器函數是缺失的,因此您可以構建它們。您尚未進行任何繪圖,因此在運行應用程序時看不到任何內容。
渲染一個四邊形
您可以使用兩個三角形創建一個四邊形。每個三角形有 3 個頂點,總共有 6 個頂點。
? 創建一個名為 Quad.swift 的新 Swift 文件。
? 將現有代碼替換為:
import MetalKit
struct Vertex {var x: Floatvar y: Floatvar z: Float
}
struct Quad {var vertices: [Vertex] = [Vertex(x: -1, y: 1, z: 0),Vertex(x: 1, y: -1, z: 0),Vertex(x: -1, y: -1, z: 0),Vertex(x: -1, y: 1, z: 0),Vertex(x: 1, y: 1, z: 0),Vertex(x: 1, y: -1, z: 0)
] }
// triangle 1
// triangle 2
您可以創建一個結構體來組成一個具有 x、y 和 z 值的頂點。在這里,頂點的環繞順序 (頂點順序) 是順時針方向的,這很重要。
? 向 Quad 添加新的頂點緩存區property并初始化它:
let vertexBuffer: MTLBuffer
init(device: MTLDevice, scale: Float = 1) {vertices = vertices.map {Vertex(x: $0.x * scale, y: $0.y * scale, z: $0.z * scale)}guard let vertexBuffer = device.makeBuffer(bytes: &vertices,length: MemoryLayout<Vertex>.stride * vertices.count,options: []) else {fatalError("Unable to create quad vertex buffer")
}self.vertexBuffer = vertexBuffer
}
?使用此代碼,您可以使用頂點數組初始化 Metal 緩存區。將每個頂點乘以 scale,這樣就可以在初始化期間設置四邊形的大小。
? 打開 Renderer.swift,并為 quad 網格添加一個新property:
lazy var quad: Quad = {Quad(device: Self.device, scale: 0.8)
}()
在這里,您將使用 Renderer 的設備初始化 quad。您必須延遲初始化 quad,因為在運行 init(metalView:) 之前,device 不會初始化。您還可以調整四邊形的大小,以便可以清楚地看到它。
注意:如果您將比例保持在默認的1.0下,四邊形將覆蓋整個屏幕。覆蓋屏幕對于全屏繪圖很有用,因為您只能在渲染幾何圖形的區域繪制片段。
在 draw(in:) 中,在 // do drawing here 之后,添加:
renderEncoder.setVertexBuffer(quad.vertexBuffer,offset: 0,index: 0)
您在渲染命令編碼器上創建一個命令,將頂點緩沖區在緩沖區參數表中的索引設置為 0。
? 添加 draw 調用:
renderEncoder.drawPrimitives(type: .triangle,vertexStart: 0,vertexCount: quad.vertices.count)
?在這里,您將繪制四邊形的六個頂點。
? 打開 Shaders.metal。
? 將 vertex 函數替換為:
vertex float4 vertex_main(constant float3 *vertices [[buffer(0)]],uint vertexID [[vertex_id]])
{float4 position = float4(vertices[vertexID], 1);return position;
}
此代碼存在錯誤,您將很快觀察并修復該錯誤。
GPU 為每個頂點執行頂點函數。在繪制調用中,您指定了有6個頂點。因此,頂點函數將執行六次。
將指針傳遞到 vertex 函數時,必須指定地址空間,constant 或 device。constant 經過優化,可在多個頂點函數上并行訪問同一變量。device 最適合通過并行函數訪問緩沖區的不同部分,例如使用交錯頂點和顏色數據的緩沖區時。
[[vertex_id]] 是一個屬性限定符,它為您提供當前頂點。您可以將其用作訪問vertices 持有數組的入口。
您可能會注意到,您正在向 GPU 發送一個緩沖區,其中填充了一個 Vertexs 數組,該數組由 3 個 Float 組成。在頂點函數中,您讀取的緩沖區與 float3 數組相同,從而導致顯示錯誤。
盡管您可能會獲得不同的渲染,但頂點位于錯誤的位置,因為 float3 類型比具有三個 Float 類型成員的 Vertex 占用更多的內存。Float 長 4 字節,Vertex 長 12 字節。SIMD float3 類型是帶填充的,占用與 float4 類型相同的內存,即 16 字節。將此參數更改為 packed_float3 將修復錯誤,因為 packed_float3占用 12 個字節。
注意:您可以在https://apple.co/2UT993x查看Metal著色語言規范中類型的大小。
在 vertex 函數中,將第一個參數中的 float3 改為 packed_float3。
編譯并運行。
四邊形現在顯示正確了。或者,您可以將 Float 數組頂點定義為 simd_float3 數組。在這種情況下,您將在頂點函數中使用 float3,因為這兩種類型都需要 16 個字節。但是,每個頂點發送 16 個字節的效率略低于每個頂點發送 12 個字節的效率。
計算位置
Metal不但支持絢麗的色彩,也支持快速平滑的動畫。在下一步,我們會讓我們的四邊形上下移動。為了做到這個,我們需要一個計時器,每幀都更新四邊形的位置。頂點shader函數就是我們更新頂點位置的地方,我們會發送計時器數據到GPU。
在Renderer的頭部,添加如下屬性:
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: 11)
1,每幀都更新計時器,如果你希望你的四邊形上下移動,你需要使用一個在-1和1之間的值,使用sin()函數是一個很好的限制值在-1到1之間的方法。你可以通過更改每幀中給timer增加的值,來更改動畫的速度。
2,如果你發送少量的數據(小于4kb)給GPU,setVertexBytes(_:length:index:)是一個創建MTLBuffer的較好選擇。這里你將currentTime設置給緩存參數表中索引為11的緩存。為頂點屬性(例如頂點位置)保留緩沖區 1 到 10 有助于記住哪些緩沖區保存哪些數據。
在Shader.metal,把vertex_main函數改成這樣:
vertex float4 vertex_main(constant packed_float3 *vertices [[buffer(0)]],constant float &timer [[buffer(11)]],uint vertexID [[vertex_id]])
{float4 position = float4(vertices[vertexID], 1);position.y += timer;return position;
}
您在緩沖區 11 中以浮點數的形式接收單值timer。您將 timer 值添加到 y 位置,并從函數返回新位置。
在下一章中,您將開始學習如何使用矩陣乘法將頂點投影到 3D 空間中。但是,您并不總是需要矩陣乘法來移動頂點;在這里,您可以使用簡單的加法來實現沿著Y 軸平移。
? 構建并運行應用程序,您將看到一個可愛的動畫四邊形。
?
更高效的渲染
目前,您正在使用 6 個頂點來渲染兩個三角形。
在這些頂點中,0 和 3 位于同一位置,1 和 5 也是如此。如果您渲染具有數千個甚至數百萬個頂點的網格,則盡可能減少重復是非常重要的。您可以使用索引渲染來實現。
僅為不同的頂點位置創建結構體,然后使用 indices 獲取頂點的正確位置。
? 打開 Quad.swift,并將頂點重命名為 oldVertices。
? 將以下結構添加到 Quad:
var vertices: [Vertex] = [Vertex(x: -1, y: 1, z: 0),Vertex(x: 1, y: 1, z: 0),Vertex(x: -1, y: -1, z: 0),Vertex(x: 1, y: -1, z: 0)
]
var indices: [UInt16] = [0, 3, 2,
0, 1, 3 ]
vertices 現在以任意順序保存四邊形的唯一四個點。indices 以正確的頂點順序保存每個頂點的索引。請參閱 oldVertices 以確保您的索引正確無誤。?
? 添加新的 Metal 緩沖區來保存索引:
let indexBuffer: MTLBuffer
在 init(device:scale:) 的末尾,添加:
guard let indexBuffer = device.makeBuffer(bytes: &indices,length: MemoryLayout<UInt16>.stride * indices.count,options: []) else {fatalError("Unable to create quad index buffer")
}
self.indexBuffer = indexBuffer
?創建索引緩沖區的方式與創建頂點緩沖區的方式相同。
? 打開 Renderer.swift,在 draw(in:) 中,在 draw 調用之前,添加:
renderEncoder.setVertexBuffer(quad.indexBuffer,offset: 0,index: 1)
在這里,您將索引緩沖區發送到 GPU。
? 將 draw 調用更改為:?
renderEncoder.drawPrimitives(type: .triangle,vertexStart: 0,vertexCount: quad.indices.count)
使用索引計數來表示要渲染的頂點數;而不是頂點計數。
? 打開 Shaders.metal,并將頂點函數更改為:?
vertex float4 vertex_main(constant packed_float3 *vertices [[buffer(0)]],constant ushort *indices [[buffer(1)]],constant float &timer [[buffer(11)]],uint vertexID [[vertex_id]])
{ushort index = indices[vertexID];float4 position = float4(vertices[index], 1);return position;
}
此處,vertexID 是緩存區中的索引,該緩存區保存了四邊形的索引。使用索引緩存區中的值,在頂點緩存區中正確索引頂點。?
? 構建并運行。
當然,你的四邊形的位置與以前相同,但現在你向 GPU 發送的數據更少。
從數組中的條目數量來看,您實際上似乎在發送更多數據 — 但事實并非如此!oldVertices 的內存占用為 72 字節,而 vertices + indices 的內存占用為 60 字節。
頂點描述器
使用索引渲染頂點時,可以使用更高效的繪制調用。但是,您首先需要在管道中設置頂點描述符。
使用頂點描述符始終是一個好主意,因為大多數情況下,您不僅會向 GPU 發送位置。您還將發送法線、紋理坐標和顏色等屬性。當您可以布置自己的頂點數據時,您可以更好地控制引擎處理模型網格的方式。
? 創建一個名為 VertexDescriptor.swift 的新 Swift 文件。
? 將代碼替換為:
import MetalKit
extension MTLVertexDescriptor {static var defaultLayout: MTLVertexDescriptor {let vertexDescriptor = MTLVertexDescriptor()vertexDescriptor.attributes[0].format = .float3vertexDescriptor.attributes[0].offset = 0vertexDescriptor.attributes[0].bufferIndex = 0let stride = MemoryLayout<Vertex>.stridevertexDescriptor.layouts[0].stride = stridereturn vertexDescriptor}
}
在這里,您將設置一個只有一個屬性的頂點布局。此屬性描述每個頂點的位置。
頂點描述符包含屬性和緩存區布局的數組。
? attributes:對于每個屬性,指定從緩沖區開頭開始的第一項的類型格式和偏移量(以字節為單位)。您還可以指定保存該屬性的緩沖區的索引。
? buffer layout:指定每個緩沖區中組合的所有屬性的步幅長度。這里可能會讓人感到困惑,因為你正在使用索引 0 來索引布局和屬性,但布局索引 0 對應于屬性使用到的 bufferIndex 0。?
注意: stride 描述了每個實例之間的字節數。由于內部填充和字節對齊,此值可能與 size 不同。有關大小、步幅和對齊的精彩解釋,請查看 Greg Heo 的文章,網址為https://bit.ly/2V3gBJl.
對于 GPU,vertexBuffer 現在如下所示:
? 打開 Renderer.swift,并在 init(metalView:) 中找到創建管道狀態的位置。
? 在 do {} 中創建管道狀態之前,將以下代碼添加到管道狀態描述符中:
pipelineDescriptor.vertexDescriptor =MTLVertexDescriptor.defaultLayout
GPU 現在期望頂點按描述符描述的格式存放。
? 在 draw(in:) 中,刪除:?
renderEncoder.setVertexBuffer(quad.indexBuffer,offset: 0,index: 1)
您將在 draw 調用中包含索引緩沖區。
? 將 draw 調用更改為:
renderEncoder.drawIndexedPrimitives(type: .triangle,indexCount: quad.indices.count,indexType: .uint16,indexBuffer: quad.indexBuffer,indexBufferOffset: 0)
此繪圖調用期望索引緩沖區使用 UInt16,這就是你在 Quad 中描述 indices 數組的方式。你沒有顯式地將 quad.indexBuffer 發送到 GPU,因為這個 draw 調用會為你做這件事。
? 打開 Shaders.metal。
? 將 vertex 函數替換為:
vertex float4 vertex_main(float4 position [[attribute(0)]] [[stage_in]],constant float &timer [[buffer(11)]])
{return position;
}
?你為 Swift 端的布局做了所有繁重的工作,所以頂點函數的大小大大減小了。
您可以使用 [[stage_in]] 屬性描述每個逐頂點的輸入。GPU 現在查看管道狀態的頂點描述符。
[[attribute(0)]] 是頂點描述符中描述位置的屬性。即使您將原始頂點數據定義為包含三個浮點數的頂點類型,也可以在此處將位置定義為 float4。GPU 可以進行轉換。
值得注意的是,當 GPU 將 w 信息添加到 xyz 位置時,它會添加為1.0。正如您將在以下章節中看到的那樣,這個 w 值在柵格化過程中非常重要。
GPU 現在擁有計算每個頂點位置所需的所有信息。
? 構建并運行應用程序以確保一切仍然有效。生成的渲染將與以前相同。
?
添加另一個頂點屬性
您可能永遠不會只有一個屬性,因此讓我們為每個頂點添加一個 color 屬性。
您可以選擇是使用兩個緩沖區還是在每個頂點位置之間交錯顏色。如果您選擇交錯,您將設置一個結構來保存位置和顏色。但是,在此示例中,添加新的顏色緩沖區以匹配每個頂點會更容易。
? 打開 Quad.swift,并添加新數組:
var colors: [simd_float3] = [[1, 0, 0], // red[0, 1, 0], // green[0, 0, 1], // blue[1, 1, 0] // yellow
]
現在,您有四種 RGB 顏色來匹配這四個頂點。
? 創建一個新的緩沖區屬性:?
let colorBuffer: MTLBuffer
? 在 init(device:scale:) 的末尾添加:?
guard let colorBuffer = device.makeBuffer(bytes: &colors,length: MemoryLayout<simd_float3>.stride * colors.count,options: []) else {fatalError("Unable to create quad color buffer")}
self.colorBuffer = colorBuffer
初始化 colorBuffer 的方式與前兩個緩沖區相同。
? 打開 Renderer.swift,然后在 draw(in:) 中,在 draw 調用之前添加:?
renderEncoder.setVertexBuffer(quad.colorBuffer,offset: 0,index: 1)
使用緩沖區索引 1 將顏色緩沖區發送到 GPU,該索引必須與頂點描述符布局中的索引匹配。
? 打開 VertexDescriptor.swift,并在返回之前將以下代碼添加到 defaultLayout:
vertexDescriptor.attributes[1].format = .float3
vertexDescriptor.attributes[1].offset = 0
vertexDescriptor.attributes[1].bufferIndex = 1
vertexDescriptor.layouts[1].stride =MemoryLayout<simd_float3>.stride
在這里,您將描述緩沖區索引 1 中顏色緩沖區的布局。
?? 打開 Shaders.metal。
? 您只能在一個參數上使用 [[stage_in]],因此請創建一個新結構體
在 Vertex 函數之前:
struct VertexIn {float4 position [[attribute(0)]];float4 color [[attribute(1)]];
};
?? 將 vertex 函數更改為:
vertex float4 vertex_main(VertexIn in [[stage_in]],constant float &timer [[buffer(11)]])
{return in.position;
}
這段代碼仍然簡短明了。GPU 知道如何從緩沖區中檢索位置和顏色,因為結構中的 [[attribute(n)]] 限定符查看管道狀態的頂點描述符。
? 構建并運行以確保您的藍色象限仍然渲染。
fragment 函數確定每個渲染片段的顏色。您需要將頂點的顏色傳遞給 fragment 函數。您將在第 7 章 “片段函數” 中了解有關 fragment 函數的更多信息。
? 仍在 Shaders.metal 中,在 vertex 函數之前添加以下結構:?
struct VertexOut {float4 position [[position]];float4 color;
};
現在,您不僅可以從 vertex 函數返回 position,還可以返回 position 和 color。您可以指定 position 屬性,讓 GPU 知道此結構中的哪個屬性是 position。
? 將 vertex 函數替換為:?
vertex VertexOut vertex_main(VertexIn in [[stage_in]],constant float &timer [[buffer(11)]]) {VertexOut out {.position = in.position,.color = in.color};
return out; }
?現在,您返回 VertexOut 而不是 float4。
? 將片段功能改為:
fragment float4 fragment_main(VertexOut in [[stage_in]]) {return in.color;
}
[[stage_in]] 屬性指示 GPU 應從頂點函數獲取 VertexOut 輸出,并將其與柵格化片段匹配。在這里,您將返回頂點顏色。請記住第 3 章 “渲染管道” 中,每個片段的輸入都會進行插值。
? 構建并運行應用程序,您將看到以美麗的顏色渲染的四邊形。?
?
渲染成點狀
您可以渲染點和線,而不是渲染三角形。
? 打開 Renderer.swift,然后在 draw(in:) 中更改
renderEncoder.drawIndexedPrimitives(type: .triangle,
為:
renderEncoder.drawIndexedPrimitives(type: .point,
如果您現在構建并運行,GPU 將渲染點,但它不知道要使用什么大小的點,因此它會在各種點大小上閃爍。要解決此問題,您還將在從 vertex 函數返回數據時,返回點尺寸。
? 打開 Shaders.metal,并將此屬性添加到 VertexOut:?
float pointSize [[point_size]];
[[point_size]] 屬性將告訴 GPU 要使用什么尺寸的點。
? 在 vertex_main 中,將 out 的初始化替換為:?
VertexOut out {.position = in.position,.color = in.color,.pointSize = 30
};
?在這里,您將分配點尺寸為30。
? 構建并運行以查看使用頂點顏色渲染的點:
?
?
挑戰
到目前為止,您已將頂點位置發送到數組緩沖區中的 GPU。但這并不完全必要。GPU 需要知道的是要繪制多少個頂點。您的挑戰是刪除頂點和索引緩沖區,并在一個圓圈中繪制 50 個點。以下是您需要采取的步驟的概述,以及一些幫助您入門的代碼:
1. 在 Renderer 中,從管道中刪除頂點描述符。
2. 替換 Renderer 中的 draw 調用,使其不使用索引,但繪制 50個頂點。
3. 在 draw(in:) 中,刪除所有 setVertexBuffer 命令。
4. GPU 需要知道總點數,因此請以與緩沖區 0 中的 timer 相同的方式發送此值。
5. 將 vertex 函數替換為:
vertex VertexOut vertex_main(constant uint &count [[buffer(0)]],constant float &timer [[buffer(11)]],uint vertexID [[vertex_id]])
{float radius = 0.8;float pi = 3.14159;float current = float(vertexID) / float(count);float2 position;position.x = radius * cos(2 * pi * current);position.y = radius * sin(2 * pi * current);VertexOut out {.position = float4(position, 0, 1),.color = float4(1, 0, 0, 1),.pointSize = 20
};
return out; }
請記住,這是一個練習,可幫助您了解如何在 GPU 上定位點,而無需在 Swift 端保存任何等效數據。所以,不要太擔心數學。您可以使用當前頂點 ID 的正弦和余弦來繪制圓周圍的點。
請注意,GPU 上沒有 pi 的內置值。您將看到 50 個點被繪制成一個圓圈。
嘗試通過將 timer 添加到 current 來為點添加動畫。
如果你有任何困難,你可以在本章的項目挑戰目錄中找到解決方案。?
參考
https://zhuanlan.zhihu.com/p/385638027