Metal学习笔记十二:渲染通道

到目前为止,您创建的都是只有一个渲染通道的项目。换句话说,您仅使用一个渲染命令编码器将所有绘制调用提交给GPU。在更复杂的应用程序中,您通常需要在一个通道中将内容渲染到一个离屏纹理中,并在随后的通道中使用该结果,最后才将纹理呈现给屏幕。

您需要使用多通道的原因有很多,可能是:
•阴影:在下一章中,您将创建一个阴影通道并从方向灯光呈现深度图,以帮助在随后的通道中计算阴影。
•延迟光照:您呈现几种具有颜色,位置和法线值的纹理。然后,在最终通道中,您可以使用这些纹理来计算光照。
•反射:从反射表面的角度捕获场景成纹理,然后将纹理与最终渲染结合在一起。
•后处理:完成最终渲染图像后,您可以通过添加泛光(bloom),屏幕空间环境光屏蔽或为最终图像着色以增强整个图像,用来在应用程序中添加特定的心情或样式。

渲染通道

一个渲染通道由向命令编码器发送的一系列命令组成。当您结束该命令编码器上的编码时,通道结束。
在设置渲染命令编码器时,使用渲染通道描述符。到目前为止,您已经使用了 MTKView 的currentRenderPassDescriptor,但您可以定义自己的描述符或更改当前的渲染通道描述符。渲染通道描述符描述 GPU 将渲染的所有纹理。管线状态告诉 GPU 纹理的预期像素格式。

例如,以下渲染通道写入四个纹理。有三个颜色附件纹理和一个深度附件纹理。

对象拾取

要开始使用多通道渲染,您需要创建一个简单的渲染通道,用于向应用程序添加对象拾取功能。当您单击场景中的模型时,该模型将使用略有不同的阴影进行渲染。
有几种方法可以对渲染的对象进行命中测试。例如,您可以进行数学运算,将 2D 触摸位置转换为 3D 光线,然后执行光线相交以查看哪个对象与光线相交。Warren Moore 在他的 Picking and Hit-Testing in Metal (https://bit.ly/3rlzm9b) 文章中描述了这种方法。或者,您可以渲染一个纹理,其中每个对象都以不同的颜色或对象 ID 渲染。然后,您根据屏幕触摸位置计算纹理坐标并读取纹理以查看被点击的对象。
您将在一个渲染通道中将模型的对象 ID 存储到纹理中。然后,您将触摸位置发送到第二个渲染通道中的片段着色器,并从第一个通道中读取纹理。如果正在渲染的片段来自所选对象,您将以不同的颜色渲染该片段。

起始APP

➤在XCode中,打开本章的启动应用程序并检查代码。它与上一章相似,但进行了重构。
•在Render Passes group中,ForwardRenderPass.swift包含渲染代码,该代码曾经与管线状态和深度模板状态初始化一起在Render中。分开此代码将使多渲染通道变得更容易,因为您可以专注于使每个通道的管线状态和纹理都正确设置。在Render中,draw(scene:in:)更新uniforms,然后告诉前向渲染通道以绘制场景。

•Pipelines.swift包含管线状态的创建。后面,PipelineStates将包含更多的管线状态。

•在Game group中,GamesScene在场景中设置了新模型。
•在Geometry group中,模型现在具有一个objectId。当GameScene创建模型时,在createModel(name:)中,它分配了唯一的对象 ID. Model。模型更新参数及其对片段函数的objectId。ground的对象ID为零。

• 在 SwiftUI 视图组中,MetalView 有一个手势,当用户单击或点击屏幕时,该手势会将鼠标或触摸位置转发到 InputController。
• 在 Shaders 组中,Common.h 在 Params 中具有一些额外的属性,用于将触摸位置传递给片段函数。Renderer 在 params 中初始化设备的比例系数。大多数 Retina 设备的比例系数为 2,但是 iPhone Pro Max 的比例系数为 3。
➤ 构建并运行应用程序,并熟悉代码。

设置渲染通道

由于您将有多个渲染通道执行类似的过程,因此使用具有一些默认方法的协议是有意义的。
➤ 在 Render Passes组中,创建一个名为 RenderPass.swift 的新 Swift 文件,并将代码替换为:

import MetalKit
protocol RenderPass {
  var label: String { get }
  var descriptor: MTLRenderPassDescriptor? { get set }
  mutating func resize(view: MTKView, size: CGSize)
  func draw(
    commandBuffer: MTLCommandBuffer,
    scene: GameScene,
    uniforms: Uniforms,
    params: Params
    ) 
}
extension RenderPass {
}

所有渲染通道都将具有渲染通道描述符。通道可能会创建自己的描述符或使用视图当前的渲染通道描述符。当用户调整窗口大小时,它们都需要调整渲染纹理的大小。所有渲染通道都需要一种绘制方法。
扩展程序将保存默认渲染通道方法。
➤打开 ForwardRenderPass.swift,将ForwardRenderPass声明为遵守RenderPass协议:

struct ForwardRenderPass: RenderPass {


➤将buildDepthStencilState()从ForwardRenderPass剪切,然后将其粘贴到RenderPass的扩展中。
多个渲染通道将使用此深度模板状态初始化方法。
 

创建UInt32纹理

纹理不仅可以保存颜色。存在许多像素格式(https://apple.co/ 3EBY9OD)。到目前为止,您已经使用了RGBA8unorm,该颜色形式包含四个8位整数,用于红色,绿色,蓝色和alpha。
Model的ObjectID是UInt32,取代模型的颜色,您将其ID呈现为纹理。您将在一个新渲染通道中创建一个持有UInt32的纹理。
➤在Render Passes组中,创建一个名为ObjectIdRenderPass.swift的新的Swift文件,并用以下方法替换代码

import MetalKit
struct ObjectIdRenderPass: RenderPass {
  let label = "Object ID Render Pass"
  var descriptor: MTLRenderPassDescriptor?
  var pipelineState: MTLRenderPipelineState
  mutating func resize(view: MTKView, size: CGSize) {
  }
  func draw(
    commandBuffer: MTLCommandBuffer,
    scene: GameScene,
    uniforms: Uniforms,
    params: Params
  ){} 
}

在这里,您将创建具有符合 RenderPass 所需的属性和方法的渲染通道,以及管线状态对象。
➤ 打开 Pipelines.swift,并向 PipelineStates 添加一个方法以创建管线状态对象:

static func createObjectIdPSO() -> MTLRenderPipelineState {
  let pipelineDescriptor = MTLRenderPipelineDescriptor()
  // 1
  let vertexFunction =
    Renderer.library?.makeFunction(name: "vertex_main")
  let fragmentFunction =
    Renderer.library?.makeFunction(name: "fragment_objectId")
  pipelineDescriptor.vertexFunction = vertexFunction
  pipelineDescriptor.fragmentFunction = fragmentFunction
  // 2
  pipelineDescriptor.colorAttachments[0].pixelFormat = .r32Uint
  // 3
  pipelineDescriptor.depthAttachmentPixelFormat = .invalid
  pipelineDescriptor.vertexDescriptor =
    MTLVertexDescriptor.defaultLayout
  return Self.createPSO(descriptor: pipelineDescriptor)
}

 您熟悉此代码的大部分内容,但有一些细节需要注意:
1. 您可以使用与渲染模型相同的顶点函数,因为您将在相同的位置渲染顶点。但是,您需要不同的 fragment 函数才能将 ID 写入纹理。
2. 颜色附件的纹理像素格式为 32 位无符号整数。GPU 将希望您以这种格式为其提供纹理。
3. 您将返回并添加深度附件,但现在,将其设为invalid,这意味着 GPU 不需要深度纹理。

➤ 打开 ObjectIdRenderPass.swift,并创建一个初始化器:

init() {
  pipelineState = PipelineStates.createObjectIdPSO()
  descriptor = MTLRenderPassDescriptor()
}

在这里,您将初始化管线状态和渲染通道描述符。
大多数渲染通道都需要您创建纹理,因此您将创建一个采用多个不同参数的纹理。
➤ 打开 RenderPass.swift,并向扩展添加一个新方法:

static func makeTexture(
  size: CGSize,
  pixelFormat: MTLPixelFormat,
  label: String,
  storageMode: MTLStorageMode = .private,
  usage: MTLTextureUsage = [.shaderRead, .renderTarget]
) -> MTLTexture? {
}

除了大小之外,您还将提供纹理:
• 像素格式,如 rgba8Unorm。在此渲染通道中,您为其指定 r32Uint。
• 默认情况下,存储模式为 private,这意味着纹理存储在内存中只有 GPU 才能访问的位置。
• 用途。您必须将渲染通道描述符使用的纹理配置为渲染目标。渲染目标是内存缓冲区或纹理,允许在渲染的像素不需要进入帧缓冲区的情况下进行离屏渲染。您还需要在着色器函数中读取纹理,因此您也设置了该默认能力。

➤ 将此代码添加到makeTexture(size:pixelFormat:label:storageMode:usage:):

let width = Int(size.width)
let height = Int(size.height)
guard width > 0 && height > 0 else { return nil }
let textureDesc =
  MTLTextureDescriptor.texture2DDescriptor(
    pixelFormat: pixelFormat,
    width: width,
    height: height,
    mipmapped: false)
textureDesc.storageMode = storageMode
textureDesc.usage = usage
guard let texture =
  Renderer.device.makeTexture(descriptor: textureDesc) else {
    fatalError("Failed to create texture")
  }
texture.label = label
return texture

您可以使用给定的参数配置纹理描述符,并从描述符创建纹理。
➤ 打开 ObjectIdRenderPass.swift,并向 ObjectIdRenderPass 添加一个渲染纹理的新属性:

var idTexture: MTLTexture?

➤ 添加此代码到resize(view:size:):

idTexture = Self.makeTexture(
  size: size,
  pixelFormat: .r32Uint,
  label: "ID Texture")

每次视图大小更改时,您都需要重新构建纹理以匹配视图的大小。现在是绘制。

➤ 将此代码添加到 draw(commandBuffer:scene:uniforms:params:):

guard let descriptor = descriptor else {
return
}
descriptor.colorAttachments[0].texture = idTexture
guard let renderEncoder =
  commandBuffer.makeRenderCommandEncoder(descriptor: descriptor)
else { return }

将 idTexture 分配给描述符的第一个颜色附件。然后,使用此描述符创建渲染命令编码器。在为管线状态对象配置颜色附件时,像素格式必须与渲染目标纹理匹配。在本例中,您将它们都设置为 r32Uint。
➤ 在您刚刚添加的代码之后添加此代码:

 renderEncoder.label = label
renderEncoder.setRenderPipelineState(pipelineState)
for model in scene.models {
    model.render(
    encoder: renderEncoder,
    uniforms: uniforms,
    params: params)
}
renderEncoder.endEncoding()

在这里,您设置管线状态并渲染模型。

添加渲染通道到Renderer

➤打开Renderer.Swift,并添加新的渲染通道属性:

var objectIdRenderPass: ObjectIdRenderPass

➤在init(metalView:options:)中,在super.init()之前添加此代码以初始化渲染通道:

objectIdRenderPass = ObjectIdRenderPass()

➤将此代码添加到mtkView(_:drawableSizeWillChange:):

objectIdRenderPass.resize(view: view, size: size)

在这里,您确保idTexture的大小与视图大小相匹配。渲染器的初始化器调用mtkView(_:drawableSizeWillChange:),因此您在渲染通道中的纹理是初始化的,并且调整为适当尺寸。
➤将此代码添加到draw(scene:in:)中紧接updateUniforms(scene: scene)之后:

objectIdRenderPass.draw(
  commandBuffer: commandBuffer,
  scene: scene,
  uniforms: uniforms,
  params: params)

太好了,您已经设置了渲染通道。现在,您要做的就是创建片段着色器函数以写入idTexture。

添加着色函数

Object ID 渲染通道会将当前渲染模型的对象 ID 写入纹理。您不需要 fragment 函数中的任何顶点信息。
➤ 在着色器中,创建一个名为 ObjectId.metal 的新 Metal 文件并添加:

#import "Common.h"
// 1
struct FragmentOut {
  uint objectId [[color(0)]];
};
// 2
fragment FragmentOut fragment_objectId(
  constant Params &params [[buffer(ParamsBuffer)]])
{
// 3
  FragmentOut out {
    .objectId = params.objectId
  };
  return out; 
}

浏览此代码:
1. 创建与渲染过程描述符颜色附件匹配的结构体。
颜色附件 0 包含对象 ID 纹理。
2. fragment 函数接受 params,其中你只需要 object ID。
3. 创建一个 FragmentOut 实例并将当前对象 ID 写入该实例。然后,您从 fragment 函数返回它,GPU 将 fragment 写入给定的纹理。

➤ 构建并运行应用程序。

您不会在渲染中看到差异。目前,您不会将对象 ID 纹理传递给第二个渲染通道。

➤ 通过单击捕获 GPU 工作负载 Metal 图标并单击弹出窗口中的Capture。

➤ 单击命令缓冲区,您将看到两个渲染通道。Object ID 渲染通道位于左侧,具有 R32Uint 像素格式纹理。通常的前向渲染过程位于右上角,具有颜色纹理和深度纹理。
 
绿色的 Present 是呈现在屏幕上的 Metal 可绘制对象纹理。Object ID 渲染通道未将任何信息传递给 Present。

➤ 双击 Object ID 渲染通道纹理两次,然后单击显示的 Color 0 附件。这是 idTexture。
当您将光标移动到像素上时,它会显示该像素的值。在 fragment 函数中,您将片段设置为显示对象 ID,但几乎所有纹理都显示对象 ID 为零。对象 ID 为零的地面正渲染在其他对象之上。

注: 您的纹理可能会显示较少的红色。目前,纹理不会在渲染通道开始时清除,因此任何未在片段着色器中设置的纹理都可能包含任何值。


要获得正确的对象 ID,请务必丢弃位于其他模型后面的模型片段。因此,您需要使用深度纹理进行渲染。

添加深度附件

➤ 打开 ObjectIdRenderPass.swift,并向 ObjectIdRenderPass 添加新属性:

 var depthTexture: MTLTexture?

到目前为止,您已经使用了当前可绘制对象的默认深度纹理。接下来,您将创建一个要维护的深度纹理。

➤ 添加此代码到resize(view:size):

depthTexture = Self.makeTexture(
  size: size,
  pixelFormat: .depth32Float,
  label: "ID Depth Texture")

在这里,您将创建具有正确大小和像素格式的深度纹理。此像素格式必须与渲染管线状态深度纹理格式匹配。
➤ 打开 Pipelines.swift,然后在createObjectIdPSO()中,将 pipelineDescriptor.depthAttachmentPixelFormat = .invalid更改为:

   pipelineDescriptor.depthAttachmentPixelFormat = .depth32Float

现在像素格式将匹配。
➤ 返回 ObjectIdRenderPass.swift。在draw(commandBuffer:scene:uniforms:params:) 中,设置颜色附件纹理后,添加:

   descriptor.depthAttachment.texture = depthTexture

您创建并存储了深度纹理。如果您现在构建并运行并捕获 GPU 工作负载,您将看到深度纹理,但尚未完成 GPU 深度渲染的设置。

深度模板状态

➤ 在 ObjectIdRenderPass 中创建新属性:

var depthStencilState: MTLDepthStencilState?

➤ 将此代码添加到 init() 的末尾:

depthStencilState = Self.buildDepthStencilState()

您可以使用通常的深度渲染设置深度模板状态对象。

➤ 在 draw(commandBuffer:scene:uniforms:params:) 中,设置渲染管线状态后添加以下代码:

   renderEncoder.setDepthStencilState(depthStencilState)

在这里,您可以让 GPU 知道您想要渲染的深度设置。

➤ 构建并运行应用程序。捕获 GPU 工作负载,并立即查看您的 Object ID 颜色纹理。

您的纹理可能看起来很暗。这是因为 Debugger 将对象 ID 0 到 4 显示为颜色。白色将是最大可能的值,即 4,292,442,372。所以 4 几乎是黑色的。您可以使用视图左上角的图标重新映射颜色。

现在,当您在每个对象上运行放大镜时,您将清楚地看到对象 ID。
您可能会在渲染的顶部看到一些随机像素。加载纹理时,渲染过程将执行加载操作。纹理的当前加载操作是 dontCare,因此只要您没有渲染对象,像素将是随机的。

您需要在渲染之前清除纹理,以准确了解您单击选择的区域中的对象 ID。

 
注: 实际上,在此示例中是否清除 on load 并不重要。正如您稍后将看到的,拾取的对象上每个片段的颜色变化只会发生在 fragment 函数期间。由于屏幕顶部的非渲染像素不是通过 fragment 函数处理的,因此永远不会发生颜色变化。但是,最好了解纹理中发生的情况。在某些时候,您可能决定将纹理传回 CPU 进行进一步处理。

加载&存储action

每当渲染通道在写入附件纹理之前加载附件纹理时,都会执行加载操作。store操作确定附件纹理是否可用。
您可以在渲染通道描述符附件中设置加载和存储操作。
➤ 打开 ObjectIdRenderPass.swift。在draw(commandBuffer:scene:uniforms:params:)中,设置 descriptor.colorAttachments[0].texture 后,添加:

descriptor.colorAttachments[0].loadAction = .clear
descriptor.colorAttachments[0].storeAction = .store

load操作可以是 clear、load 或 dontCare。最常见的 store操作是 store 或 dontCare。
仅在你需要时清除纹理。如果您的 fragment 函数写入屏幕上显示的每个 fragment,则通常不需要清除。例如,如果渲染全屏四边形,则无需清除。
➤ 构建并运行应用程序,并再次捕获 GPU 工作负载。重新检查对象 ID 纹理。

屏幕顶部的像素现在用零清除。如果需要非零清除值,请设置 colorAttachments[0].clearColor。 

读取Object ID纹理

您现在有一个选择。您可以在 CPU 上读取纹理,并使用触摸位置作为坐标来提取对象 ID。如果您需要存储所选对象以进行其他处理,那么这就是您必须做的。但是,在 GPU 和 CPU 之间传输数据时,您总是会遇到同步问题,因此将纹理保留在 GPU 上并在那里进行测试会更容易、更快捷。

➤ 打开 ForwardRenderPass.swift,并将此新属性添加到 ForwardRenderPass:

weak var idTexture: MTLTexture?

idTexture 将保存对象 ID 渲染通道中的 ID 纹理。

➤打开Renderer.swift。在draw(scene:in:)中,在objectIdRenderPass.draw(...)之后添加此代码:

   forwardRenderPass.idTexture = objectIdRenderPass.idTexture

您将ID纹理从一个渲染通道传递到下一个通道。 

➤打开 ForwardRenderPass.swift。在draw(commandBuffer:scene:uniforms:params:)中,在渲染循环之前,添加:

renderEncoder.setFragmentTexture(idTexture, index: 11)

您将idTexture传递到前向渲染通道的片段函数。小心您的索引号。您可能需要像使用早期索引一样重命名这个。
您还需要将触摸位置发送到片段着色器,以便可以使用它来读取ID纹理。

➤在上一个代码之后,添加:

let input = InputController.shared
var params = params
params.touchX = UInt32(input.touchLocation?.x ?? 0)
params.touchY = UInt32(input.touchLocation?.y ?? 0)

input.touchLocation是MetalView上的最后一个位置。 Swiftui手势在MetalView上对其进行了更新。
➤打开片段。并将此代码添加到fragment_main的参数:

texture2d<uint> idTexture [[texture(11)]]

注意您传递的纹理类型和索引编号。 

➤在设置material.baseColor的条件之后补充:

if (!is_null_texture(idTexture)) {
  uint2 coord = uint2(
    params.touchX * params.scaleFactor,
    params.touchY * params.scaleFactor);
  uint objectID = idTexture.read(coord).r;
  if (params.objectId != 0 && objectID == params.objectId) {
    material.baseColor = float3(0.9, 0.5, 0);
  }
}

在这里,您使用传入的触摸坐标读取 idTexture。idTexture 的大小与视图的可绘制对象大小相同。此大小是视图的像素分辨率,而不是视图的点大小。
有时,为了节省资源,将纹理大小减半是值得的。您当然可以在此处执行此作,只要您记得在 fragment 函数中读取纹理时将坐标减半即可。
请注意,read 与 sample 不同。读取使用像素坐标,而不是规范化坐标。您不需要采样器来读取纹理,但在使用 read 时也不能使用各种采样器选项。
如果当前渲染的对象 ID 不为零,并且对象 ID 与 idTexture 中的片段匹配,请将材质的底色更改为橙色。
➤ 构建并运行应用程序,并测试您的对象拾取是否适用于不同的分辨率。iPhone Pro Max 具有 3 倍分辨率系数。

您选择的对象将变为橙色。单击对象 ID 为零的天空或地面时,不会拾取任何对象。
这是测试对象是否被选取的一种简单方法。这也是学习简单渲染通道纹理链路的好方法。但是,在大多数情况下,您需要将纹理传递回 CPU,因此按照本章开头的说明执行光线拾取会更有效。

➤使用应用程序运行,捕获GPU工作负载,然后单击命令缓冲区以查看帧图。

帧图反映了对象ID渲染通道将idTexture发送到前向渲染通道,该通道绘制到视图的可绘制纹理。该图以紫色指出任何缺失的纹理和冗余绑定错误,在您绑定已经绑定的纹理的情况下。
现在,您知道如何在不同的渲染通道中渲染纹理,您可以继续进行更复杂的渲染并在下一章中添加一些阴影。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值