在本章中,您将了解阴影。阴影表示表面上没有光。当另一个表面或对象使对象与光线相遮挡时,您会看到对象上的阴影。在项目中添加阴影可使您的场景看起来更逼真,并提供深度感。
阴影贴图
阴影贴图是包含场景阴影信息的纹理。当光线照射到物体上时,它会在物体后面的物体上投下阴影。
通常,您从摄像机的位置渲染场景。但是,要构建阴影贴图,您需要从光源的位置(在本例中为太阳)渲染场景。
左侧的图像显示了从摄像机位置进行的渲染,其中定向光指向下方。右侧的图像显示了从定向光位置进行的渲染。眼睛表示相机在第一张图像中的位置。
你会用到两个渲染通道:
•第一个通道:您将从光的角度渲染。由于太阳是定向的,因此您将使用正交摄像机,而不是透视相机。您只对太阳可以看到的物体的深度感兴趣,因此您不会呈现颜色纹理。在此通道中,您只会将阴影贴图渲染为深度纹理。它是灰度纹理,灰色值表示深度。黑色靠近光线,白色距离更远。
•第二个通道:您将像往常一样使用场景摄像机进行渲染,但是您将相机片段与每个阴影贴图片段进行比较。如果相机片段的深度小于该位置处的阴影贴图片段,则片段在阴影中。灯光可以在上图中看到蓝色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: MTLRenderPipelineState
var 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 = vertexFunction
pipelineDescriptor.colorAttachments[0].pixelFormat = .invalid
pipelineDescriptor.depthAttachmentPixelFormat = .depth32Float
pipelineDescriptor.vertexDescriptor = .defaultLayout
return 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 float4
vertex_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包含一些有关改善阴影的通用技术的文章。