Da29ddbe597e6b4d1397d14cb5e708a8
基于 Metal 的现代渲染技术

Session 601: Modern Rendering with Metal

目录

  • 引言
  • 高级渲染技术
    • 正向渲染(Forward Render)
    • 延迟渲染(Deferred)
    • 可编程混合(Programmable Blending)
    • 平铺延迟渲染(Tiled Deferred)
    • 平铺正向渲染(Tiled Forward)
    • 集群正向渲染(Clustered Forward)
    • 可见性缓冲(Visibility Buffer)
    • 小结
  • GPU 驱动渲染
    • 传统渲染循环
    • GPU 驱动渲染循环
      • 准备数据
      • CPU 的任务
      • GPU 编码命令
      • 稀疏编码
      • GPU 驱动计算
    • 小结
  • 硬件差异
  • 结语
  • 参考文献

引言

Metal 是 Apple 开发的一款图形引擎,诞生至今已经五岁了。今年,苹果带来了 Metal 3。

本文将基于 Metal 介绍图形学上几大经典的优化技术,以及 Metal 独有的 GPU 驱动渲染,其中包含 Metal3 带来的少量新 Feature。

由于 Metal 是底层的图形引擎,因此阅读本文需要一定的图形学基础和 Metal 基础。本文假定读者已经具备一定图形学知识并对 Metal 的基本渲染流程熟悉。

高级渲染技术

本章节会介绍几个图形学上经典的优化技术。这些技术在实现思想上并不是新的,有的甚至已经有几十年历史了。

下面就来看看这些技术的思想,以及在 Metal 里是如何实现的。

正向渲染(Forward Render)

在介绍优化技术之前,先来看看不优化的情况下,渲染一个场景是怎么实现的。

要在场景里绘制一个物体,只需要获得物体的顶点数据(网格),然后画就行了。它看上去像这样:

这是个最简单的场景,仅仅只有顶点,最多包含贴图,它没有材质,没有光源,没有反射,没有阴影。整个场景是没血没肉的,大概二十年前的游戏长得就是这样。

接下来,给场景加上光源和材质。光源和材质无非就是一些数值,只要装在一个 Buffer 里丢给着色器去计算就行了,因此它现在的流程像这样:

只是多上传了一些数值而已,材质和反射的计算部分都在着色器里处理了,对性能似乎并不会造成什么影响,看上去似乎没有需要优化的。

然而事实并非如此。在正向渲染中会针对每一个图元进行 N 次光照运算(N为光源数量),当光源数量成百上千时,正向渲染就会遇到瓶颈。下图中共有 1874 个点光源,用正向渲染耗时是无法想象的。

图片来源:Hannes Nevalainen

图片来源:Hannes Nevalainen

以上就是传统的渲染流程,被称为正向渲染。

延迟渲染(Deferred)

为了解决多光源下的性能问题,延迟渲染诞生。

延迟渲染引入了Geometry Buffer(下文简称为G-Buffer)概念。G-Buffer 是一组大小和最终输出大小一致的缓冲区,在渲染时不立即计算出图元的最终颜色,而是将各个维度(位置、法线、反射等,其维度根据渲染的需求而定。如果需要渲染阴影,那么之中会有储存阴影遮挡状态的缓冲器等)的暂时记录在 G-Buffer 上,然后统一进行光照计算,确保只对最终显示在屏幕上的像素点进行光照运算。

其渲染流程为:

那么在 Metal 中,如何实现呢?

从图中可以看出,整个渲染过程被分为两个阶段:

  • G-Buffer Rendering
  • Lighting

因此开发者需要为这两个阶段分别创建不同的MTLRenderPassDescriptor

首先是 G-Buffer 的 RenderPassDescriptor:

func setupDeferred() {
    let geometryRenderPassDescriptor = MTLRenderPassDescriptor.init()

    // Depth
    geometryRenderPassDescriptor.depthAttachment.texture = depthTexture
    geometryRenderPassDescriptor.depthAttachment.loadAction = .clear
    geometryRenderPassDescriptor.depthAttachment.storeAction = .store

    // G-Buffer
    // Position
    geometryRenderPassDescriptor.colorAttachments[0].texture = positionTexture
    geometryRenderPassDescriptor.colorAttachments[0].loadAction = .dontCare
    geometryRenderPassDescriptor.colorAttachments[0].storeAction = .store
    // Albedo
    geometryRenderPassDescriptor.colorAttachments[1].texture = albedoTexture
    geometryRenderPassDescriptor.colorAttachments[1].loadAction = .dontCare
    geometryRenderPassDescriptor.colorAttachments[1].storeAction = .store
    // Normal
    geometryRenderPassDescriptor.colorAttachments[2].texture = normalTexture
    geometryRenderPassDescriptor.colorAttachments[2].loadAction = .dontCare
    geometryRenderPassDescriptor.colorAttachments[2].storeAction = .store
    // ……
}

Lighting 阶段的 RenderPassDescriptor:

func setupDeferred() {
    let lightingRenderPassDescriptor = MTLRenderPassDescriptor.init()

    lightingRenderPassDescriptor.colorAttachments[0].texture = lightingTexture
    lightingRenderPassDescriptor.colorAttachments[0].loadAction = .clear
    lightingRenderPassDescriptor.colorAttachments[0].storeAction = .store
}

渲染过程:

func render(command: MTLCommandBuffer) {
    // Phase 1: G-Buffer Rendering
    let geoEncoder = command.makeRenderCommandEncoder(descriptor: geometryRenderPassDescriptor)!

    // Render Scene in G-Buffer
    for mesh in scene.meshes {
        geoEncoder.drawPrimitives(……)
    }

    geoEncoder.endEncoding()

    // Phase 2: Lighting
    let lgtEncoder = command.makeRenderCommandEncoder(descriptor: lightingRenderPassDescriptor)!
    for light in scene.lights {
        // Setup G-Buffer Textures
        lgtEncoder.setFragmentTexture(……)
        // Lighting Rendering
        lgtEncoder.drawPrimitives(……)
    }
    lgtEncoder.endEncoding()
}

着色器方面,G-Buffer 阶段的片元着色器由于需要渲染一系列缓冲区,返回值不再是一个颜色值,而是类似于以下结构的一个结构体。

struct GBufferData
{
    half4 lighting        [[color(0), raster_order_group(0)]];
    half4 albedo_specular [[color(1), raster_order_group(1)]];
    half4 normal_shadow   [[color(2), raster_order_group(1)]];
    float depth           [[color(3), raster_order_group(1)]];
};

代码中raster_order_group为光栅化顺序,指定光栅化顺序的特性由 Metal 2 引入,由于 GPU 光栅化是高度并行的,这个特性用于解决竞争问题。对这个概念陌生的同学可以访问这里查看 Apple 对于Raster Order Groups的详细解释。

Lighting 阶段显然已经不需要原始物体的顶点数据了,而是直接从已经准备好的 G-Buffer 中取出各个维度的数据进行光照渲染。

fragment float4 Shade(LightingVertexInput in                    [[stage_in]],
                      depth2d<float, access::read> depth_tex    [[texture(0)]],
                      texture2d<float, access::read> color_tex  [[texture(1)]],
                      texture2d<float, access::read> normal_tex  [[texture(2)]]) {
    float depth = depth_tex.read(in.pixelPos);
    float4 color = color_tex.read(in.pixelPos);
    uint normal = normal_tex.read(in.pixelPos);
    return lightingFunction(color, normal, depth, ......)
}

从 Lighting 阶段的着色可以看出最终计算光照的像素点仅仅是最终显示在屏幕上的点而已,大大节约了不必要的计算。

以上为延时渲染的流程,它避免了大量不必要的光照计算,大大提升了多光源场景下的渲染性能,。

对于延迟渲染的实现有其他疑问的同学可以访问这里获取 Apple 为开发者们准备的 Sample Code。这个 Demo 包含了完整的延时渲染过程,而且十分精美。

可编程混合(Programmable Blending)

上面提到的延时渲染技术引入了 G-Buffer,这是一组存储各个维度信息的缓冲区,必然会带来大量的显存消耗。每一个维度的缓冲区和渲染目标的大小一致,这就意味着每一个维度都会带来数兆的显存开销,一组 G-Buffer 可能会带来十几兆额外的显存开销,这是开发者们不愿意看到的。

那么如何来优化这组临时缓冲区呢?在原来的流程中,第一阶段开发者们向 G-Buffer 写入了大量数据,而在第二阶段中又原模原样读了出来。这个过程是不必要的。如果第一阶段的输出能够直接传递给第二阶段,那就皆大欢喜了。

顺着这个思路,Metal 允许开发者在不真正分配 G-Buffer 这一组临时缓冲区显存的情况下实现延时渲染。这一技术被称为Programmable Blending

首先,在创建 G-Buffer 使用的临时纹理时,不再需要真正分配空间:

textureDescriptor.storageMode = .memoryless

其次,在创建 G-Buffer 各个维度的 RenderPassDescriptor 时,不再需要存储结果:

geometryRenderPassDescriptor.colorAttachments[n].storeAction = .dontcare

最后,在着色器代码中,传入参数不再是纹理,而是颜色值:

fragment float4 Shade(LightingVertexInput in                    [[stage_in]],
                      float depth [[color(0)]],
                      float4 color [[color(1)]],
                      float4 normal [[color(2)]]) {
    // ......
}

现在,G-Buffer 不消耗额外的显存空间了。

平铺延迟渲染(Tiled Deferred)

平铺延迟渲染旨在解决多光源场景下的性能问题。

其思想是进一步减少多光源场景下的无效着色。在多光源场景下,并不是每个光源都对每个图元都有影响。在渲染一个图元时,仅计算对图元有影响的光源,剔除无效的光源是提升性能的有效途径。

每一个分块都被看做一个视锥,通过遍历各个光源计算光源和视锥是否相交来决定这个光源是否被剔除。在剔除光源时,分块之间是没有影响的,因此可以并行进行。在 Metal 中,这一步使用kernel function来实现。

kernel void CullLights(device Light *all_lights [[buffer(0)]]
                       threadgroup uint32_t &active_light_list [[threadgroup(0)]]
                       threadgroup float2 &depth_bounds [[threadgroup(1)]]) {
    active_light_mask = 0;
    for (uint i = tid; i < MAX_LIGHTS; ++i) {
        if (IntersectLightWithTileFrustum(all_lights[i], depth_bounds) {
            active_light_list |= (1u << i);
        }
    }
}

看完了 Shader,回头再来看看 Metal API 的使用。

func setupDeferredTiled() {
    let tileDescriptor = MTLTileRenderPipelineDescriptor.init()

    tileDescriptor.colorAttachments[0].pixelFormat = .rgba16Uint
    tileDescriptor.colorAttachments[1].pixelFormat = .r32Float

    // ......
}

首先生成一个MTLTileRenderPipelineDescriptor并使用这个 Descriptor 生成 Pipeline State,随后在编码阶段编入 CommandBuffer。

encoder.setRenderPipelineState(tileCullPipeline)
encoder.setTileBuffer(sceneLights, offset:0 atIndex:0)
encoder.setThreadgroupMemoryLength(MemoryLayout<LightList>.size, offset:0 atIndex:0)
encoder.dispatchThreadsPerTile(MTLSizeMake(encoder.tileWidth, encoder.tileHeight, 1))

在获得每个分块对应的光源激活情况后进行光源着色即可。由于部分光源被剔除,能够提升性能是显而易见的。

平铺正向渲染(Tiled Forward)

基于对分块延迟的了解,现在把视角放在 G-Buffer 阶段。

在 G-Buffer 阶段渲染了各个维度的缓冲区,但事实上 Light Culling 阶段需要的仅仅是深度信息(depth bounds),因此完全没有必要在那个时候渲染这么多维度的内容。

在最初的阶段只进行深度渲染,然后进行光源剔除。在光源剔除以后,一个图元通常不会被太多光源照亮,因此此时可以直接使用前向渲染进行渲染。

集群正向渲染(Clustered Forward)

集群正向渲染是平铺正向渲染的优化。

传统的光源剔除算法基于二维的分块(Tile),所得到的是基于二维分块的光源列表。而新的光源剔除算法直接对三维空间进行分块,直接生成基于三维空间的光源列表。

top Created with Sketch.