知道如何通過將頂點數據發送到 vertex 函數來渲染三角形、線條和點是一項非常巧妙的技能 — 尤其是因為您能夠使用簡單的單行片段函數為形狀著色。但是,片段著色器能夠執行更多操作。
? 打開網站 https://shadertoy.com,在那里您會發現大量令人眼花繚亂的社區創建的出色著色器。
這些示例可能看起來像復雜 3D 模型的渲染圖,但外觀具有欺騙性!您在此處看到的每個 “模型” 都是完全使用數學生成的,用 GLSL 片段著色器編寫。GLSL 是 OpenGL 的圖形庫著色語言 — 在本章中,您將開始了解所有著色高手使用的原理。
注意:每個圖形API都使用自己的著色器語言。原理是相同的,因此,如果您找到喜歡的GLSL著色器,則可以使用Metal MSL重新創建它。
起始項目
Starter 項目展示了一個示例,該示例將多個管線狀態與不同的頂點函數結合使用,具體取決于您渲染的是旋轉的火車還是全屏四邊形。
? 打開本章的入門項目。
? 構建并運行項目。(您可以選擇渲染火車或四邊形。您將先從四邊形開始。)
讓我們仔細看看代碼。
? 打開 Shaders 組中的 Vertex.metal,您將看到兩個頂點函數:
? vertex_main:此函數將呈現火車,就像在上一章中所做的那樣。
? vertex_quad:此函數使用著色器中定義的數組渲染全屏四邊形。
這兩個函數都輸出一個 VertexOut結構體,其中僅包含頂點的位置。
? 打開 Renderer.swift。
在 init(metalView:options:) 中,您將看到兩個管線狀態對象 (PSO)。兩個 PSO 之間的唯一區別是 GPU 在繪制時將調用的頂點函數。
根據 options.renderChoice 的值,draw(in:) 渲染火車模型或四邊形,并換入正確的管線狀態。SwiftUI 視圖處理 Options 的更新,而 MetalViewRepresentable 將當前選項傳遞給 Renderer。
? 在繼續之前,請確保您了解此項目的運作方式。
屏幕空間
片段函數可以執行的許多操作之一是創建復雜的模式,這些模式用來填充呈現的四邊形上的屏幕像素。目前,片段函數只有 vertex 函數的插值position輸出可供其使用。因此,首先,您將了解您可以利用此position做什么以及它的局限性是什么。
? 打開 Fragment.metal,將 fragment 函數內容改為:
float color;
in.position.x < 200 ? color = 0 : color = 1;
return float4(color, color, color, 1);
當光柵器處理頂點位置時,它會將它們從 NDC(標準化設備坐標)轉換為屏幕空間。您在 ContentView.swift 中將 Metal 視圖的寬度定義為 400點。使用新添加的代碼,您說如果 x 位置小于 200,則將顏色設為黑色。否則,將顏色設為白色。
注意:雖然您可以使用 if 語句,但編譯器可以更好地優化三元語句,因此使用它更有意義。
? 在您的 Mac 和 iPhone 15 Pro Max 模擬器上構建并運行該應用程序。
您是否預料到一半的屏幕是黑色的?視圖的寬是 400 點,所以這是合理的。但是您可能沒有考慮到一些事情:Apple Retina 顯示屏具有不同的像素分辨率或像素密度。例如,MacBook Pro 配備 2 倍 Retina 顯示屏,而 iPhone 15 Pro Max 配備 3 倍 Retina 顯示屏。這些不同的顯示屏意味著 MacBook Pro 上的 400 點, Metal 視圖可創建 800x800 像素的可繪制紋理,而 iPhone 視圖可創建 1200x1200 像素的可繪制紋理。
您的四邊形填滿了屏幕,您正在寫入視圖的可繪制渲染目標紋理(其大小與設備的顯示屏相匹配),但沒有簡單的方法可以在 fragment 函數中找出當前渲染目標紋理的大小。
? 打開 Common.h,并添加新的結構體:
typedef struct {uint width;uint height;
} Params;
此代碼包含可發送到 fragment 函數的參數。您可以根據需要向此結構體添加參數。
? 打開 Renderer.swift,并向 Renderer 添加一個新屬性:
var params = Params()
您將把當前渲染目標大小存儲在新屬性中。
? 將以下代碼添加到 mtkView(_:drawableSizeWillChange:) 的末尾:
params.width = UInt32(size.width)
params.height = UInt32(size.height)
size 包含視圖的可繪制紋理大小。換句話說,也就是視圖的bounds按設備的比例因子進行縮放后的尺寸。
? 在 draw(in:)中調用渲染模型或四邊形的方法之前,將參數發送到 fragment 函數:
renderEncoder.setFragmentBytes(¶ms,length: MemoryLayout<Params>.stride,index: 12)
請注意,您使用 setFragmentBytes(_:length:index:)將數據發送到片段函數的方式與之前使用 setVertexBytes(_:length:index:)的方式相同。
? 打開 Fragment.metal,將 fragment_main 的簽名更改為:
fragment float4 fragment_main(constant Params ¶ms [[buffer(12)]],VertexOut in [[stage_in]])
具有目標繪圖紋理大小的參數現在可用于 fragment 函數。
? 將設置 color 值的代碼(基于 in.position.x 的值)更改為:
in.position.x < params.width * 0.5 ? color = 0 : color = 1;
在這里,您將使用目標渲染大小進行計算。
? 在 macOS 和 iPhone 15 Pro Max 模擬器中運行該應用程序。
太棒了,現在兩種設備的渲染看起來都一樣。
Metal標準庫函數
除了標準的數學函數(如 sin、abs 和 length)之外,還有一些其他有用的函數。讓我們來看看:
step
如果 x 小于 edge,則 step(edge, x) 返回 0。否則,它將返回 1。此評估正是您對當前 fragment 函數執行的操作。
? 將 fragment 函數的內容替換為:
float color = step(params.width * 0.5, in.position.x);
return float4(color, color, color, 1);
此代碼生成的結果與以前相同,但代碼略少。
? 構建并運行。
結果是,左側為黑色,因為左側 step 的結果為 0。而右側為白色,因為右側step 的結果為 1 。
讓我們用棋盤格模式更進一步。
? 將 fragment 函數的內容替換為:
uint checks = 8;
// 1
float2 uv = in.position.xy / params.width;
// 2
uv = fract(uv * checks * 0.5) - 0.5;
// 3
float3 color = step(uv.x * uv.y, 0.0);
return float4(color, 1.0);
以下是正在發生的事情:
1. UV 坐標形成一個值介于 0 和 1 之間的網格。因此,中點位于 [0.5, 0.5],左上角位于 [0.0, 0.0]。UV 坐標通常與將頂點映射到紋理相關聯,如第 8 章 “紋理”所示。
2. fract(x)返回 x 的小數部分。將 UV 的小數值乘以checks值的一半,得到一個介于 0 和 1 之間的值。然后減去 0.5,使一半的值小于零。
3. 如果 xy 乘法的結果小于零,則結果為 1 或白色。否則,它是 0 或黑色。
例如:
float2 uv = (550, 50) / 800; // uv = (0.6875, 0.0625)
uv = fract(uv * checks * 0.5); // uv = (0.75, 0.25)
uv -= 0.5; // uv = (0.25, -0.25)
float3 color = step(uv.x * uv.y, 0.0); // x > -0.0625, so color
is 1
? 構建并運行應用程序。
length
創建正方形很有趣,但讓我們使用 length 函數創建一些圓。
? 將 fragment 函數替換為:
float center = 0.5;
float radius = 0.2;
float2 uv = in.position.xy / params.width - center;
float3 color = step(length(uv), radius);
return float4(color, 1.0);
? 構建并運行應用程序。
要調整形狀大小并在屏幕上移動形狀,請更改圓的中心和半徑。
smoothstep
smoothstep(edge0, edge1, x)返回介于 0 和 1 之間的平滑艾米插值。
?注意:edge1 必須大于 edge0,x 應該是 edge0 <= x <= edge1。
? 將片段函數改為:
float color = smoothstep(0, params.width, in.position.x);
return float4(color, color, color, 1);
color 包含介于 0 和 1 之間的值。當位置與屏幕寬度相同時,顏色為 0 或白色。當位置位于屏幕的最左側時,顏色為 0 或黑色。
? 構建并運行應用程序。
在兩種邊緣情況之間,顏色是在黑色和白色之間插值的漸變。在這里,您使用 smoothstep 來計算顏色,但您也可以使用它在任意兩個值之間進行插值。例如,您可以使用 smoothstep 為 vertex 函數中的位置設置動畫。
mix
mix(x, y, a)產生與 x + (y - x) * a 相同的結果。
? 將片段函數更改為:
float3 red = float3(1, 0, 0);
float3 blue = float3(0, 0, 1);
float3 color = mix(red, blue, 0.6);
return float4(color, 1);
混合 0 將產生全紅色。混合 1 產生全藍色。這些顏色共同產生 60% 的紅色和藍色混合。
? 構建并運行應用程序。
?
您可以將混合與 smoothstep 結合使用以產生顏色漸變。
? 將 fragment 函數替換為:
float3 red = float3(1, 0, 0);
float3 blue = float3(0, 0, 1);
float result = smoothstep(0, params.width, in.position.x);
float3 color = mix(red, blue, result);
return float4(color, 1);
此代碼使用result的插值,將其用作紅色和藍色的混合比例。
? 構建并運行應用程序。
normalize
規范化過程是指重新調整數據比例以使用標準范圍。例如,向量同時具有 direction 和 magnitude。在下圖中,向量 A 的長度為 2.12132,方向為 45 度。向量 B 的長度相同,但方向不同。向量 C 的長度不同,但方向相同。
如果兩個向量的大小相同,則更容易比較它們的方向,因此可以將向量標準化為單位長度。normalize(x)返回方向相同但長度為 1 的向量 x。
讓我們看看另一個規范化的例子。假設您希望使用顏色可視化頂點位置,以便更好地調試某些代碼。
? 將片段函數改為:
return in.position;
? 構建并運行應用程序。
片段函數應返回每個元素介于 0 和 1 之間的 RGBA 顏色。但是,由于位置位于屏幕空間中,因此每個位置在 [0, 0, 0] 和 [800, 800, 0] 之間變化,這就是四邊形呈現黃色的原因(它僅在左上角位于 0 和 1 之間)。
? 現在,將代碼更改為:
float3 color = normalize(in.position.xyz);
return float4(color, 1);
在這里,您將向量 in.position.xyz 標準化為長度為 1。現在,所有顏色都保證介于 0 和 1 之間。歸一化后,最右上角的位置 (800, 0, 0) 包含紅色的 1, 0, 0。
? 構建并運行應用程序以查看結果。
法線
盡管可視化位置有助于調試,但通常對創建 3D 渲染沒有幫助。但是,找到三角形的朝向對于著色很有用,而著色器正是法線發揮作用的地方。法線是表示頂點或表面朝向的向量。在下一章中,您將學習如何為模型增加光照。但首先,您需要了解法線。
從 Blender 捕獲的以下圖像顯示了指向的頂點法線。球體的每個頂點都指向不同的方向。
?
球體的著色取決于這些法線。如果法線指向光源,則 Blender 將更亮。
四邊形對于著色目的不是很有趣,因此請將默認渲染切換到火車。
? 打開 Options.swift,并將 renderChoice 的初始化更改為:
var renderChoice = RenderChoice.train
? 運行應用程序以檢查您的火車渲染。
?
與全屏四邊形不同,只有火車覆蓋的片段才會顯示。但是,每個片段的顏色仍然取決于片元的屏幕位置,而不是火車頂點的位置。
加載帶法線的火車模型
3D模型文件通常包含表面法線值,您可以和模型一起加載這些值。如果您的文件不包含Surface Formals,則Model I/O可以使用MDLMesh的addNormals(withAttributeNamed:creaseThreshold:),在導入時生成它們。
為頂點描述器增加法線
? 打開 VertexDescriptor.swift。
目前,您只加載 position 屬性。是時候將 normal 添加到頂點描述符。
? 在設置 offset 的代碼之后,在設置 layouts[0] 的代碼之前,將以下代碼添加到 MDLVertexDescriptor 的 defaultLayout:
vertexDescriptor.attributes[1] = MDLVertexAttribute(name: MDLVertexAttributeNormal,format: .float3,offset: offset,bufferIndex: 0)
offset += MemoryLayout<float3>.stride
這里,法線類型是 float3,并在緩沖區 0 中和position交錯放置。float3 是在 MathLibrary.swift 中定義的 SIMD3<Float> 類型的別名。每個頂點在索引0緩沖區中占用兩個 float3,即 32 字節。layouts[0] 描述帶有 stride 的索引0緩沖區。
更新 Shader 函數
? 打開 Vertex.metal。
火車模型的管線狀態使用此頂點描述符,以便頂點函數可以處理屬性,并將這些屬性與 VertexIn中的屬性匹配。
? 構建并運行應用程序,您會發現一切仍然按預期工作。即使您向頂點緩沖區添加了新屬性,管線也會忽略它。
因為您尚未將其作為attribute(n)包含在 VertexIn 中。是時候解決這個問題了。
? 在 VertexIn 中添加以下代碼:
float3 normal [[attribute(1)]];
在這里,您將 attribute(1) 與頂點描述符的屬性 1 匹配。現在你將能夠訪問 vertex 函數中的 normal 屬性。
? 接下來,將以下代碼添加到 VertexOut 中:
float3 normal;
通過在此處包含 normal,您現在可以將數據傳遞給 fragment 函數。
? 在 vertex_main 中,將賦值更改為 out:
VertexOut out {.position = position,.normal = in.normal
};
完美!通過該更改,您現在可以從 vertex 函數返回位置和法線。
? 打開 Fragment.metal,將 fragment_main 的內容替換為:
return float4(in.normal, 1);
別擔心,編譯錯誤是意料之中的。即使您在 Vertex.metal 中更新了 VertexOut,該結構體的作用域也僅在該文件中。
添加頭文件
在多個著色器文件中需要結構體和函數是很常見的。因此,就像您對 Swift 和 Metal 之間的橋接頭文件 Common.h 所做的那樣,您可以添加其他頭文件并將它們導入到著色器文件中。
? 使用 macOS 頭文件模板在 Shaders 組中創建一個新文件,并將其命名為 ShaderDefs.h。
? 將代碼替換為:
?
#include <metal_stdlib>
using namespace metal;
struct VertexOut {float4 position [[position]];float3 normal;
};
在這里,您可以在 metal 命名空間中定義 VertexOut。
? 打開 Vertex.metal,并刪除 VertexOut 結構。
? 導入 Common.h 后,添加:
#import "ShaderDefs.h"
? 打開 Fragment.metal,并刪除 VertexOut 結構。
? 同樣,在導入 Common.h 后,添加:
#import "ShaderDefs.h"
? 構建并運行應用程序。
哦,現在看起來有點奇怪!
您的法線看起來好像顯示正確 — 紅色法線位于火車的右側,綠色法線向上,藍色位于后面 — 但隨著火車旋轉,它的某些部分看起來幾乎是透明的。
這里的問題是光柵器會混淆頂點的深度順序。當你從前面看火車時,你不應該能看到火車的后面;它應該被遮擋。
深度
光柵器默認情況下不會處理深度順序,因此您需要以深度模板狀態為光柵器提供所需的信息。
您可能還記得第3章“渲染管道”,模板測試單元檢查渲染管道期間片段是否可見。如果確定片段在另一個片段后面,則將其丟棄。
讓我們給渲染編碼器一個MTLDepthStencilState屬性,以描述如何進行此測試。
?打開Renderer.swift。
?在init(metalView:options:)結束之前,設置metalView.clearColor之后,添加:
metalView.depthStencilPixelFormat = .depth32Float
該代碼告訴Metal View,您需要保留深度信息。默認的像素格式為.invalid,它告知視圖不需要創建深度和模板紋理。
渲染命令編碼器使用的管線狀態必須具有相同的深度像素格式。
?在init(metalView:options:)設置PipelinedEscriptor.colorattachments [0] .pixelformat之后,在do {之前添加:
pipelineDescriptor.depthAttachmentPixelFormat = .depth32Float
如果您現在要構建并運行該應用程序,那么您將獲得與以前相同的結果。但是,在幕后,視圖創建了紋理,光柵器可以在該紋理上寫入深度值。
接下來,您需要設置希望光柵器計算深度值的方式。?
?向渲染器添加新屬性:
let depthStencilState: MTLDepthStencilState?
該屬性具有正確的渲染設置,使其具有深度模板狀態。
? 在 Renderer 中創建此方法以實例化深度模板狀態:
static func buildDepthStencilState() -> MTLDepthStencilState? {
// 1let descriptor = MTLDepthStencilDescriptor()
// 2descriptor.depthCompareFunction = .less
// 3descriptor.isDepthWriteEnabled = truereturn Renderer.device.makeDepthStencilState(descriptor: descriptor)
}
瀏覽這段代碼:
1. 創建一個描述符,用于初始化深度模板狀態,就像您對管道狀態對象所做的那樣。
2. 指定如何比較當前和已處理的片段。使用 compare 函數 less 時,如果當前片段深度小于幀緩沖區中前一個片段的深度,則當前片段將替換前一個片段。
3. 說明是否寫入深度值。如果您有多個通道,如第 12 章 “渲染通道”中所述,有時您需要讀取已繪制的片段。在這種情況下,請將 isDepthWriteEnabled 設置為 false。請注意,當您繪制需要深度的對象時,isDepthWriteEnabled 始終為 true。
? 在 super.init() 之前從 init(metalView:options:) 調用方法:
depthStencilState = Renderer.buildDepthStencilState()
? 在 draw(in:) 中,將以下內容添加到方法頂部的 guard { } 之后:
renderEncoder.setDepthStencilState(depthStencilState)
? 構建并運行應用程序,以光彩奪目的 3D 形式查看您的火車。
當火車旋轉時,它會以紅色、綠色、藍色和黑色的陰影出現。
考慮一下你在這個渲染中看到的內容。法線當前位于對象空間中。因此,即使火車在世界空間中旋轉,顏色/法線也不會隨著模型旋轉的改變而改變。
當法線沿模型的 x 軸指向右側時,值為 [1, 0, 0]。這與 RGB 值中的紅色相同,因此對于指向右側的法線,片段為紅色。
指向上方的法線在 y 軸上為 1,因此顏色為綠色。
指向攝像機的法線為負數。當顏色為 [0, 0, 0] 或更小時,它們為黑色。當你看到火車旋轉的后部時,你可以看出指向 z 方向的車輪后部是藍色的 [0, 0, 1]。
現在,您在 fragment 函數中擁有了法線,您可以根據顏色的朝向開始操作顏色。當您開始使用光照時,操縱顏色非常重要。
半球光照
半球照明使用環境光。使用這種類型的照明,場景的一半使用一種顏色照明,另一半使用另一種顏色照明。例如,下圖中的球體使用半球照明。
請注意球體如何呈現從天空反射的顏色(頂部)和從地面反射的顏色(底部)。要查看這種類型的光照效果,您需要更改 fragment 函數,以便:
? 朝上的法線為藍色。
? 朝下的法線為綠色。
? 過渡值為藍色和綠色混合。
? 打開 Fragment.metal,并將 fragment_main 的內容替換為:
float4 sky = float4(0.34, 0.9, 1.0, 1.0);
float4 earth = float4(0.29, 0.58, 0.2, 1.0);
float intensity = in.normal.y * 0.5 + 0.5;
return mix(earth, sky, intensity);
mix(x, y, z) 根據第三個值在前兩個值之間進行插值,第三個值必須介于 0 和 1 之間。您的正常值介于 -1 和 1 之間,因此您可以在 0 和 1 之間轉換強度。
? 構建并運行應用程序以查看您閃亮的火車。請注意,火車的頂部是藍色的,而它的底部是綠色的。
片段著色器非常強大,允許您精確地為對象著色。在第 10 章 “光照基礎知識”中,您將使用法線的力量為場景提供更逼真的光照著色。在第19章“鑲嵌與地形”中,你將創建一個與此類似的效果,學習如何根據坡度在地形上放置雪。
挑戰
目前,您正在對所有緩沖區索引和屬性使用硬編碼的魔數。隨著應用程序的增長,跟蹤這些數字將變得越來越困難。所以,你在本章中的挑戰是尋找所有這些神奇的數字,并為它們起一個令人難忘的名字。對于此挑戰,您將在 Common.h 中創建一個枚舉。
以下是一些可幫助您入門的代碼:
?
typedef enum {VertexBuffer = 0,UniformsBuffer = 11,ParamsBuffer = 12
} BufferIndices;
現在,您可以在 Swift 和 C++ 著色器函數中使用這些常量:?
//Swift
encoder.setVertexBytes(&uniforms,length: MemoryLayout<Uniforms>.stride,index: Int(UniformsBuffer.rawValue))
// Shader Function
vertex VertexOut vertex_main(const VertexIn in [[stage_in]],constant Uniforms &uniforms [[buffer(UniformsBuffer)]])
您甚至可以在 VertexDescriptor.swift 中添加擴展來美化代碼:
extension BufferIndices {var index: Int {return Int(self.rawValue)}
}
使用此代碼,您可以使用 UniformsBuffer.index 而不是 Int(UniformsBuffer.rawValue)。
您可以在本章的 challenge 文件夾中找到完整的解決方案。