在本章中,您將了解陰影。陰影表示表面上沒有光。當另一個表面或對象使對象與光線相遮擋時,您會看到對象上的陰影。在項目中添加陰影可使您的場景看起來更逼真,并提供深度感。
陰影貼圖
陰影貼圖是包含場景陰影信息的紋理。當光線照射到物體上時,它會在物體后面的物體上投下陰影。
通常,您從攝像機的位置渲染場景。但是,要構建陰影貼圖,您需要從光源的位置(在本例中為太陽)渲染場景。
左側的圖像顯示了從攝像機位置進行的渲染,其中定向光指向下方。右側的圖像顯示了從定向光位置進行的渲染。眼睛表示相機在第一張圖像中的位置。
你會用到兩個渲染通道:
?第一個通道:您將從光的角度渲染。由于太陽是定向的,因此您將使用正交攝像機,而不是透視相機。您只對太陽可以看到的物體的深度感興趣,因此您不會呈現顏色紋理。在此通道中,您只會將陰影貼圖渲染為深度紋理。它是灰度紋理,灰色值表示深度。黑色靠近光線,白色距離更遠。
?第二個通道:您將像往常一樣使用場景攝像機進行渲染,但是您將相機片段與每個陰影貼圖片段進行比較。如果相機片段的深度小于該位置處的陰影貼圖片段,則片段在陰影中。燈光可以在上圖中看到藍色X,因此它不在陰影中。
為什么您在這里需要兩個通道?在這種情況下,您將從光源位置而不是從相機位置,渲染陰影貼圖。您可以將輸出保存到陰影紋理中,并將其傳遞到下一個渲染通道,將陰影與場景的其余部分結合在一起以制作最終圖像。
入門項目
?在Xcode中,打開本章的入門項目。
入門項目中的代碼幾乎與上一章相同,但沒有對象ID和拾取對象代碼。現在,現場有一個可見的陽光,它是唯一的燈光,在現場的中心旋轉。 (請記住,太陽是一個定向矢量。場景中的太陽模型比實際的太陽更近!)
渲染太陽的代碼位于DebugModel.swift的公用utility group中。您將與ForwardRenderPass中的場景分開渲染太陽,以使太陽模型不會被其余的場景所遮蔽。
有一些包含該應用程序不需要的代碼的額外文件。您將在本章期間了解這些文件。
?構建并運行應用程序。
?
沒有陰影,這種渲染中的火車和樹木似乎漂浮在地面上方。
添加新的陰影通道的過程類似于添加上一章的對象選擇渲染通道:
1。創建新的渲染通道結構體并配置渲染通道描述符,深度模板狀態和管道狀態。
2。在渲染器中聲明并繪制渲染通道。
3。在渲染通道中設置繪圖代碼。
4。從光的位置設置正交攝像頭并計算必要的矩陣。
5。創建頂點著色器函數,以從光的位置繪制頂點。
盡管您在該應用程序中給太陽一個位置,但是正如您在第10章中學到的“光照基本原理”,但定向光具有方向而不是位置。因此,在這里,您將使用太陽的位置作為方向。
注意:如果您想查看調試太陽方向的方向線,就像您在前一章中所做的那樣,請在 renderEncoder.endEncoding() 之前將 DebugLights.draw(lights: scene.lighting.lights, encoder: renderEncoder, uniforms: uniforms)添加到 ForwardRenderPass。
是時候添加新的陰影通道了。
1. 創建新的渲染通道
? 在渲染通道group中,創建一個名為 ShadowRenderPass.swift 的新 Swift 文件,并將代碼替換為:
import MetalKit
struct ShadowRenderPass: RenderPass {let label: String = "Shadow Render Pass"var descriptor: MTLRenderPassDescriptor?= MTLRenderPassDescriptor()var depthStencilState: MTLDepthStencilState?= Self.buildDepthStencilState()var pipelineState: MTLRenderPipelineStatevar shadowTexture: MTLTexture?mutating func resize(view: MTKView, size: CGSize) {}func draw(commandBuffer: MTLCommandBuffer,scene: GameScene,uniforms: Uniforms,params: Params
){
} }
此代碼創建符合 RenderPass 的渲染通道,其中包含陰影貼圖的管道狀態和 texture 屬性。
? 打開 Pipelines.swift,并創建一個新方法來創建管道狀態對象:
static func createShadowPSO() -> MTLRenderPipelineState {let vertexFunction =Renderer.library?.makeFunction(name: "vertex_depth")let pipelineDescriptor = MTLRenderPipelineDescriptor()pipelineDescriptor.vertexFunction = vertexFunctionpipelineDescriptor.colorAttachments[0].pixelFormat = .invalidpipelineDescriptor.depthAttachmentPixelFormat = .depth32FloatpipelineDescriptor.vertexDescriptor = .defaultLayoutreturn createPSO(descriptor: pipelineDescriptor)
}
?在這里,您可以創建一個沒有顏色附件或片段函數的管線狀態。您只對陰影的深度信息感興趣,而不是顏色信息 - 因此,將顏色附件像素格式設置為無效。您仍然會渲染模型,因此您仍然需要保留頂點描述符,并在頂點函數中轉換所有模型的頂點。
?打開Shadowrenderpass.swift,創建初始化器:
init() {pipelineState =PipelineStates.createShadowPSO()shadowTexture = Self.makeTexture(size: CGSize(width: 2048,height: 2048),pixelFormat: .depth32Float,label: "Shadow Depth Texture")
}
該代碼初始化管道狀態對象,并使用所需像素格式構建深度紋理。與其他需要匹配視圖尺寸的渲染通道不同,陰影貼圖通常采用正方形尺寸以適配光源的立方體正交投影相機,因此當窗口尺寸變化時無需重新調整紋理大小。該分辨率應與您的游戲資源預算允許產生更清晰的陰影一樣多。
2.聲明和繪制渲染通道
?在Game group中,打開Renderer.swift,并將新的渲染通道屬性添加到渲染器:
var ShadowrenderPass:ShadowrenderPass
?在init(MetalView:options :)中,在調用super.init()之前初始化渲染通道:
Shadowrenderpass = Shadowrenderpass()
?在mtkView(_:drawableSizeWillChange:),添加:
shadowRenderPass.resize(view: view, size: size)
目前,您尚未調整ShadowRenderPass中的任何紋理,但是由于您已經創建了resize(view:size:)作為遵循RenderPass協議的必需方法,并且您可以稍后添加紋理,因此您應該在此處調用該方法。
? 在 draw(scene:in:) 中,在updateUniforms(scene: scene)后,添加:
shadowRenderPass.draw(commandBuffer: commandBuffer,scene: scene,uniforms: uniforms,params: params)
此代碼執行渲染通道。
3. 設置 Render Pass 繪制代碼
? 打開 ShadowRenderPass.swift,并將以下代碼添加到draw(commandBuffer:scene:uniforms:params:):
guard let descriptor = descriptor else { return }
descriptor.depthAttachment.texture = shadowTexture
descriptor.depthAttachment.loadAction = .clear
descriptor.depthAttachment.storeAction = .store
guard let renderEncoder =commandBuffer.makeRenderCommandEncoder(descriptor: descriptor)
else {
return
}
renderEncoder.label = "Shadow Encoder"
renderEncoder.setDepthStencilState(depthStencilState)
renderEncoder.setRenderPipelineState(pipelineState)
for model in scene.models {renderEncoder.pushDebugGroup(model.name)model.render(encoder: renderEncoder,uniforms: uniforms,params: params)renderEncoder.popDebugGroup()
}
renderEncoder.endEncoding()
在這里,您可以在描述符的深度附件上設置深度附件紋理。GPU 將在加載紋理時清除紋理并將其存儲,以便后續渲染通道可以使用它。然后,使用描述符創建渲染命令編碼器,并照常渲染場景。
使用 renderEncoder.pushDebugGroup(_:) 和 renderEncoder.popDebugGroup() 包圍模型渲染,它們在 GPU 工作負載捕獲時將渲染命令收集到分組中。現在,您可以更輕松地調試正在發生的事情。
4. 設置 Light Camera
在陰影通道期間,您將從太陽的角度進行渲染,因此您需要新的攝像機和一些新的著色器矩陣。
? 在 Common.h 的 Shaders 組中,將這些屬性添加到 Uniforms:
matrix_float4x4 shadowProjectionMatrix;
matrix_float4x4 shadowViewMatrix;
在這里,您持有陽光的投影和視圖矩陣。
? 打開 Renderer.swift,并向 Renderer 添加一個新屬性:
?? var shadowCamera = OrthographicCamera()
在這里,您將創建一個正交相機。您之前在第 9 章 “導航 3D 場景”中使用了正交攝像機從上方渲染場景。因為陽光是平行光,所以這是由陽光引起的陰影的正確投影類型。但是,如果希望聚光燈產生陰影,則應使用視野與聚光燈的錐體角度相匹配的透視攝像機。
? 在 updateUniforms(scene:) 的末尾添加以下代碼:
shadowCamera.viewSize = 16
shadowCamera.far = 16
let sun = scene.lighting.lights[0]
shadowCamera.position = sun.position
使用此代碼,您可以設置立方體視圖體積為 16 個單位的正交相機。
? 繼續編寫更多代碼:
uniforms.shadowProjectionMatrix = shadowCamera.projectionMatrix
uniforms.shadowViewMatrix = float4x4(eye: sun.position,center: .zero,up: [0, 1, 0])
shadowViewMatrix 是一個 lookAt 矩陣,可確保太陽注視場景的中心。float4x4(eye:center:up) 在 MathLibrary.swift 中定義。它采用攝像機的位置、攝像機應注視的點以及攝像機的上方向矢量。此矩陣通過提供這些參數來旋轉相機以查看目標。
注意:這是一個有用的調試提示。在 updateUniforms(scene:) 結束時,臨時將 uniforms.viewMatrix 設置為 uniforms.shadowViewMatrix,將 uniforms.projectionMatrix 設置為 uniforms.shadowProjectionMatrix。開發人員通常會弄錯陰影矩陣,通過光線可視化場景渲染非常有用。
5. 創建 Shader 函數
你可能已經注意到,當你在 Pipelines.swift 中設置陰影管道狀態對象時,它引用了一個名為 vertex_depth 的著色器函數,該函數尚不存在。
? 在著色器group中,使用 Metal 文件模板,創建一個名為 Shadow.metal 的新文件。請務必檢查target。
? 將以下代碼添加到新文件中:
#import "Common.h"
struct VertexIn {float4 position [[attribute(0)]];
};
vertex float4vertex_depth(const VertexIn in [[stage_in]],constant Uniforms &uniforms [[buffer(UniformsBuffer)]])
{matrix_float4x4 mvp =uniforms.shadowProjectionMatrix * uniforms.shadowViewMatrix* uniforms.modelMatrix;return mvp * in.position;
}
此代碼接收頂點位置,通過您在 Renderer 中設置的光源投影和視圖矩陣對其進行轉換,并返回轉換后的位置。
? 構建并運行應用程序。
?這看起來不錯,但陰影在哪里?
? 捕獲 GPU 工作負載并檢查幀捕獲。
您當前未轉發 Shadow Encoder Pass 的結果。
? 雙擊陰影編碼器傳遞紋理結果,以在其他資源面板中顯示紋理。
這是從光源位置渲染的場景。您使用了陰影管道狀態,并將其配置為沒有片段著色器,因此此處根本不處理顏色信息,而是純粹的深度。較亮的顏色距離較遠,而較暗的顏色較接近。
主通道
現在,您已將陰影貼圖保存到紋理中,只需將其發送到主通道,即可在片段函數的光照計算中使用該紋理。
? 打開 ForwardRenderPass.swift,并添加一個新屬性:
weak var shadowTexture: MTLTexture?
? 在 draw(commandBuffer:scene:uniforms:params:) 中,在模型渲染 for 循環之前,添加:
renderEncoder.setFragmentTexture(shadowTexture, index: 15)
傳入陰影紋理并將其發送到 GPU。
? 打開 Renderer.swift,在繪制前向渲染通道之前,將這段代碼添加到 draw(scene:in:) 中:
forwardRenderPass.shadowTexture = shadowRenderPass.shadowTexture
將陰影紋理從上一個陰影通道傳遞到前向渲染通道。
? 在 Shaders 組中,打開 ShaderDefs.h,并為 VertexOut 添加新成員:float4 shadowPosition;
這保存了陰影矩陣轉換的頂點位置。
? 打開 Vertex.metal,并在創建時將這一行添加到 vertex_main:
.shadowPosition =uniforms.shadowProjectionMatrix * uniforms.shadowViewMatrix* uniforms.modelMatrix * in.position
每個頂點都持有兩個變換的位置。一個從攝像機的視角在場景中變換,另一個從燈光的視角變換。您將能夠將陰影位置與陰影貼圖中的片段進行比較。
? 打開 Fragment.metal。
此文件是進行光照的地方,因此其余的陰影工作將發生在fragment_main。
? 首先,在 aoTexture 后面再添加一個函數參數:
depth2d<float> shadowTexture [[texture(15)]]
與您過去使用的紋理不同,這些紋理具有 texture2d 類型,Depth 紋理的 texture type 為 depth2d。
? 在fragment_main結尾,在返回之前,添加:
// shadow calculation
// 1
float3 shadowPosition= in.shadowPosition.xyz / in.shadowPosition.w;
// 2
float2 xy = shadowPosition.xy;
xy = xy * 0.5 + 0.5;
xy.y = 1 - xy.y;
xy = saturate(xy);
// 3
constexpr sampler s(coord::normalized, filter::linear,address::clamp_to_edge,compare_func:: less);
float shadow_sample = shadowTexture.sample(s, xy);
// 4
if (shadowPosition.z > shadow_sample) {diffuseColor *= 0.5;
}
下面是一個代碼分解:
1. in.shadowPosition 表示從光源的角度來看頂點的位置。當您從光源的角度渲染時,GPU 在將片段寫入陰影紋理之前執行透視分割。此處將 xyz 除以 w 將匹配相同的透視分割,以便您可以將當前樣本的深度值與陰影紋理中的深度值進行比較。
2. 從陰影位置確定一個坐標對,以用作陰影紋理上的屏幕空間像素定位器。然后,將坐標從 [-1, 1] 重新縮放為 [0, 1] 以匹配 uv 空間。最后,您將 Y 坐標反轉,因為它是倒置的。
3. 創建一個用于陰影紋理的采樣器,并在您剛剛創建的坐標處對紋理進行采樣。獲取當前處理像素的深度值。您創建一個新的采樣器,因為在函數頂部初始化的 textureSampler 會重復紋理(如果它是從邊緣采樣的)。稍后嘗試使用 textureSampler 查看場景后面重復的額外陰影。
4. 使深度大于紋理中存儲的陰影值的像素的漫反射顏色變暗。例如,如果 shadowPosition.z 為 0.5,shadow_sample 存儲的深度紋理為 0.2,則從太陽的角度來看,當前片段比存儲的片段更遠。由于太陽看不到片段,所以它在陰影中。
? 構建并運行應用程序,您最終會看到帶有陰影的模型。
影子痤瘡
在上圖中,當太陽旋轉時,您會注意到很多閃爍。這稱為陰影痤瘡或表面痤瘡。由于缺少浮點精度,表面是自陰影的,其中采樣的紋素與計算值不匹配。
您可以通過向陰影紋理添加偏差,增加 z 值,從而使存儲的片段更接近來緩解這種情況。
? 將上面 // 4 處的條件測試更改為:
if (shadowPosition.z > shadow_sample + 0.001) {
? 構建并運行應用程序。
表面痤瘡現在已經消失了,當太陽圍繞場景旋轉時,您會有清晰的陰影。
發現問題
看看之前的渲染,你會看到一個問題。實際上,有兩個問題。平面上的大片深灰色區域似乎處于陰影中,但不應如此。
如果你捕捉到這個場景,這是該太陽位置的深度紋理:
圖像的下四分之一是白色的,這意味著該位置的深度是最遠的。光的正交相機切斷了平面的那部分,使它看起來好像在陰影中。
第二個問題是在讀取陰影貼圖。
?打開 Fragment.metal。在 fragment_main 中,在 xy = saturate(xy); 之前,添加:
if (xy.x < 0.0 || xy.x > 1.0 || xy.y < 0.0 || xy.y > 1.0) {return float4(1, 0, 0, 1);
}
xy 紋理坐標應該從 0 到 1 來在紋理上。所以如果坐標不在紋理上,則返回紅色。
? 構建并運行應用程序。
紅色區域不在深度紋理中。
您可以通過設置燈光的正交攝像機來封閉場景攝像機捕捉的所有內容,從而解決這兩個問題。
可視化問題
在 Utility 組中,DebugCameraFrustum.swift 將通過渲染各種攝像機視錐體的線框來幫助您可視化此問題。運行應用程序時,您可以按各種鍵進行調試:
? 1:場景的前視圖。
? 2:太陽圍繞場景旋轉的默認視圖。
? 3:渲染場景攝像機視錐體的線框。
? 4:渲染輕型攝像機視錐體的線框。
? 5:渲染場景攝像機邊界球體的線框。
此關鍵代碼位于 GameScene 的 update(deltaTime:) 中。
? 打開 ForwardRenderPass.swift,并將以下內容添加到 draw(commandBuffer:scene:uniforms:params:) 的末尾,在 renderEncoder.endEncoding() 之前:
DebugCameraFrustum.draw(encoder: renderEncoder,scene: scene,uniforms: uniforms)
此代碼設置調試代碼,以便上述按鍵正常工作。
? 打開 GameScene.swift,然后在 init() 的頂部添加:
camera.far = 5
攝像機遠平面的默認值為 100,很難可視化。5 的遠平面非常接近且易于可視化,但會暫時切斷很多場景。
? 構建并運行應用程序。
您可以看到,由于較近的遠平面,大部分場景都丟失了。
? 按字母上方鍵盤上的數字 3 鍵。
在這里,您將暫停太陽的旋轉并創建一個新的透視弧球攝像機,該攝像機從遠處俯視場景,并以藍色線框渲染原始透視場景攝像機視錐體。
使用鼠標或觸控板拖動場景以旋轉并檢查它。您將看到第三棵樹位于藍色視錐體之外,因此未渲染。您還將看到陰影紋理覆蓋場景的位置。紅色區域位于陰影紋理之外。
? 按數字 4 鍵。
正交燈光的 Camera View Volume 線框顯示為黃色。
光源的視圖體積的一條邊來自灰色平面的角。這是光線的視錐體無法到達的地方,并在陰影貼圖紋理上顯示為白色。
? 在 GameScene 中,將 camera.far = 5 更改為:camera.far = 10
? 構建并運行應用程序。當您看到一塊紅色平面時,按 3 鍵,然后按 4 鍵。
旋轉場景,您將看到藍色線框延伸到紅色區域。黃色線框應該包含該區域,但目前沒有。
? 按數字 5 鍵。
這將顯示一個包圍場景攝像機視錐體的白色邊界球體。
光體積應包含白色邊界球體,以獲得最佳陰影。
解決問題
? 在 Game 組中,打開 ShadowCamera.swift。此文件包含用于計算攝像機視錐體拐角的各種方法。createShadowCamera(using:lightPosition:) 創建一個包含指定相機的正交相機。
? 打開 Renderer.swift。在 updateUniforms(scene:) 中,將 shadowCamera.viewSize = 16?
let sun = scene.lighting.lights[0]
shadowCamera = OrthographicCamera.createShadowCamera(using: scene.camera,lightPosition: sun.position)
uniforms.shadowProjectionMatrix = shadowCamera.projectionMatrix
uniforms.shadowViewMatrix = float4x4(eye: shadowCamera.position,center: shadowCamera.center,up: [0, 1, 0])
在這里,您將創建一個正交光源攝像機,其視圖體積完全包裹 scene.camera 的視錐體。
? 構建并運行應用程序。
現在,Light Camera Volume 將整個場景封閉起來,因此您不會看到任何紅色錯誤或錯誤的灰色補丁。
? 打開 GameScene.swift。在 init() 中,刪除:camera.far = 10
刪除對 camera.far 的分配,這會將默認 camera.far 恢復為 100。
? 構建并運行應用程序。
由于光照視圖體積巨大,陰影非常塊狀。下圖在右側顯示渲染的陰影紋理。你幾乎看不到任何細節。
您可以將 5 或 20 更改為 5 或 20 并捕獲 GPU 工作負載以比較陰影紋理質量。
在這種情況下,作為游戲設計師,您必須決定陰影質量。最好的結果是,在離攝像機較近的陰影上使用遠值 5,對較遠的陰影使用遠值 20,因為分辨率并不重要。?
級聯的陰影貼圖
現代游戲使用一種稱為級聯的陰影貼圖的技術來幫助平衡性能和陰影深度。在第8章“紋理”中,您了解了MIP地圖,GPU使用的不同尺寸的紋理取決于與攝像機的距離。級聯的影子地圖采用了類似的想法。
使用級聯的陰影貼圖,您將場景渲染為深度紋理陣列的幾個陰影貼圖,并使用近距離平面的不同平面。如您所見,較小的遠值會產生較小的光量,從而產生更詳細的陰影貼圖。您可以從陰影貼圖中采樣陰影,較小的光量為靠近場景攝像機的片段采樣。
更遠的地方,您不需要太多的精度,因此您可以從更大的淺色片段中采樣陰影,從而吸收更多場景。缺點是,您必須為每個陰影貼圖多次渲染場景。
陰影可能需要大量的計算和處理時間。您必須決定給他們多少幀時間限額。在本章的資源文件夾中,references.markdown包含一些有關改善陰影的通用技術的文章。