65.9K
CodeProject 正在变化。 阅读更多。
Home

Diligent Engine 中 GPU 资源更新策略的比较

starIconstarIconstarIconstarIconstarIcon

5.00/5 (1投票)

2018年9月12日

CPOL

11分钟阅读

viewsIcon

8094

本文描述了 Diligent Engine(一个现代化的底层图形库)中更新 GPU 资源的几种策略,以及每种方法相关的内部细节和性能影响。

免责声明:本文使用了 Diligent Engine 网站上发布的内容。

引言

有效地向图形处理单元 (GPU) 传输数据对于 3D 渲染器或任何利用现代 GPU 功能的应用程序至关重要。由于 CPU 和 GPU 通常拥有独立的内存系统并在不同的时间线上执行操作,因此并不总是像在给定地址写入字节那样简单。实际上,最佳方法取决于预期的使用场景。本文描述了 Diligent Engine 中更新资源的各种方法,以及每种方法相关的内部细节和性能影响。

背景

Diligent Engine 是一个现代化的跨平台底层图形库,它拥有 Direct3D11、Direct3D12、OpenGL/GLES 和 Vulkan 后端,并支持 Windows、Linux、MacOS、iOS 和 Android 平台。 本文 介绍了该引擎。

缓冲区 (Buffers)

缓冲区代表线性内存,是最基本的资源类型。数据可以在初始化期间写入缓冲区,也可以在使用本段所述方法之一的情况下在运行时写入。

缓冲区初始化

将数据提供给缓冲区的最基本方法是在缓冲区初始化期间提供它

// Create index buffer
BufferDesc IndBuffDesc;
IndBuffDesc.Name = "Cube index buffer";
IndBuffDesc.Usage = USAGE_STATIC;
IndBuffDesc.BindFlags = BIND_INDEX_BUFFER;
IndBuffDesc.uiSizeInBytes = sizeof(Indices);
BufferData IBData;
IBData.pData = Indices;
IBData.DataSize = sizeof(Indices);
pDevice->CreateBuffer(IndBuffDesc, IBData, &m_CubeIndexBuffer);

缓冲区的用法定义了其内容预期的更改频率。USAGE_STATIC 缓冲区在创建后无法更新,并且必须始终提供初始数据。USAGE_DEFAULT 缓冲区预计会偶尔更新,而 USAGE_DYNAMIC 缓冲区则针对频繁更新进行了优化。

为了初始化缓冲区,Diligent Engine 会执行以下步骤。

OpenGL/GLES 后端

此操作直接转换为 glBufferData

Direct3D11 后端

在 Direct3D11 后端,初始数据通过 ID3D11Device::CreateBuffer 方法传递

Direct3D12/Vulkan 后端

在下一代后端中,USAGE_STATICUSAGE_DEFAULT 缓冲区分配在 GPU 专用内存中,CPU 无法直接访问。为了初始化缓冲区,Diligent Engine 会创建一个临时暂存缓冲区,该缓冲区分配在 CPU 可见的内存中,将数据复制到此临时缓冲区,然后发出 GPU 端的复制命令。一旦命令完成,临时缓冲区就会被释放,暂存内存会返回给系统。USAGE_DYNAMIC 无法通过此方式初始化,将在本段后面进行介绍。

使用 IBuffer::UpdateData() 更新缓冲区

在运行时更新缓冲区内容的第一个方法是使用 IBuffer::UpdateData() 方法。只有使用 USAGE_DEFAULT 标志创建的缓冲区才能使用此方法。此方法将新数据写入指定的缓冲区子区域,如下例所示

m_CubeVertexBuffer[BufferIndex]->UpdateData(
    m_pImmediateContext, // Device context to use for the operation
    FirstVertToUpdate * sizeof(Vertex), // Start offset in bytes
    NumVertsToUpdate  * sizeof(Vertex), // Data size in bytes
    Vertices // Data pointer
);

在底层,Diligent Engine 将此调用转换为以下操作

OpenGL/GLES 后端

此操作直接转换为 glBufferSubData

Direct3D11 后端

此操作直接转换为 ID3D11DeviceContext::UpdateSubresource

Direct3D12/Vulkan 后端

默认缓冲区分配在仅 GPU 可访问的内存中,因此无法直接写入数据。为了执行此操作,引擎首先在 CPU 可见内存中分配临时存储,将数据复制到此临时存储,然后发出 GPU 命令将数据从存储复制到最终目标。它还执行必要的资源状态转换(例如,从着色器资源转换为复制目标)。

性能

IBuffer::UpdateData() 目前是更新默认(仅 GPU)缓冲区中数据的唯一方法。该操作涉及两次复制。然而,此方法的主要且不太明显的性能问题是状态转换。每次当缓冲区在复制操作中使用时,它都需要转换为复制目标状态。每次在着色器中使用时,它都需要转换为着色器资源状态。来回切换状态会使 GPU 流水线停顿并急剧降低性能。

此方法应仅在缓冲区内容大部分时间保持不变,并且只需要偶尔更新时使用,通常频率不超过每帧一次,例如,在重用现有缓冲区写入新的网格数据(顶点/索引)时。此方法不应用于高频更新,如动画或常量缓冲区更新。

限制

对于频繁更新效率低下。

通过映射更新动态缓冲区

当缓冲区内容需要频繁更新(每帧一次或多次)时,应使用 USAGE_DYNAMIC 标志创建缓冲区。动态缓冲区无法使用 IBuffer::UpdateData() 更新。相反,需要将其映射以获取一个指针,该指针可用于直接写入缓冲区数据,如下例所示

Vertex* Vertices = nullptr;
VertexBuffer->Map(m_pImmediateContext, MAP_WRITE, MAP_FLAG_DISCARD,
                  reinterpret_cast<PVoid&>(Vertices));
for(Uint32 v=0; v < _countof(CubeVerts); ++v)
{
    const auto& SrcVert = CubeVerts[v];
    Vertices[v].uv  = SrcVert.uv;
    Vertices[v].pos = SrcVert.pos;
}
VertexBuffer->Unmap(m_pImmediateContext, MAP_WRITE, MAP_FLAG_DISCARD);

OpenGL/GLES 后端

该操作转换为具有 GL_MAP_WRITE_BITGL_MAP_INVALIDATE_BUFFER_BIT 标志的 glMapBufferRange

Direct3D11 后端

在 Direct3D11 后端,此调用直接转换为具有 D3D11_MAP_WRITE_DISCARD 标志的 ID3D11DeviceContext::Map

Direct3D12/Vulkan 后端

当在 Direct3D12 或 Vulkan 后端创建动态缓冲区时,不会分配内存。相反,两个后端都有特殊的动态存储,这是一个在 CPU 可访问内存中创建的、并且一直被映射的缓冲区。当映射动态缓冲区时,会在此缓冲区中保留一个区域。此操作归结为 简单地移动当前偏移量,成本非常低。然后返回一个引用该内存的指针,应用程序可以直接写入数据,避免所有复制。当动态缓冲区用于渲染时,会绑定内部动态缓冲区并应用正确的偏移量。内部动态缓冲区被预先转换为只读状态,并且在运行时永远不会执行转换。引擎负责同步,确保缓冲区中的一个区域在被 GPU 使用时永远不会被提供给应用程序。

性能

在 Direct3D12/Vulkan 后端,使用 MAP_FLAG_DISCARD 标志映射动态缓冲区非常便宜,因为它只涉及更新当前偏移量。很难确切地说 Direct3D11 和 OpenGL 在底层做了什么,但很可能是类似的。然而,有一个显著的区别:Direct3D11 和 OpenGL 会在帧之间保留动态缓冲区的内容,而 Direct3D12 和 Vulkan 后端则不会。因此,在下一代后端中,映射效率要高得多。

动态缓冲区应用于内容频繁更改的对象,通常每帧多次。最常见的例子是常量缓冲区,它在每次绘制调用之前都用不同的变换矩阵进行更新。动态缓冲区不应用于永不更改的常量数据。

限制

目前只能使用 MAP_FLAG_DISCARD 标志映射整个缓冲区。

在 Direct3D12 和 Vulkan 后端,所有动态资源的内容将在每帧结束时丢失。动态缓冲区必须在每帧首次使用之前映射。

CPU 可访问内存的总量可能有限。此外,与 GPU 专用内存相比,GPU 访问速度可能较慢,因此不应使用动态缓冲区来存储常量或不经常更改的资源。

流式缓冲区 (Streaming Buffer)

流式缓冲区不是 API 对象,而是一种策略,允许以高效的方式将可变数量的数据上传到 GPU。流式缓冲区的理念可以概括如下

  • 创建一个足够大的动态缓冲区,以容纳可以上传到 GPU 的最大数据量。
  • 首次,使用 MAP_FLAG_DISCARD 标志映射缓冲区。
    • 这将丢弃先前缓冲区的内容并分配新内存。
  • 将当前缓冲区偏移量设置为零,将数据写入缓冲区并相应地更新偏移量。
  • 取消映射缓冲区并发出绘制命令。
    • 请注意,在 Direct3D12 和 Vulkan 后端,不需要取消映射缓冲区,可以安全地跳过以提高性能。
  • 下次映射缓冲区时,检查剩余空间是否足够容纳新的多边形数据。
  • 如果空间足够,使用 MAP_FLAG_DO_NOT_SYNCHRONIZE 标志映射缓冲区。
    • 这将告知系统返回先前分配的内存。应用程序负责不覆盖 GPU 正在使用的内存。
    • 在当前偏移量处写入新数据(这保证了之前写入且当前被 GPU 使用的字节不会受到影响),并更新偏移量。
  • 如果空间不足,将偏移量重置为零,并使用 MAP_FLAG_DISCARD 标志映射缓冲区以请求新的内存块。

纹理

虽然缓冲区只是线性内存区域,但纹理经过优化,可用于高效采样操作,并使用应用程序通常无法访问的不透明布局。因此,只有驱动程序知道如何写入纹理数据。Direct3D12 和 Vulkan 允许线性纹理布局,但效率较低。

纹理初始化

与缓冲区类似,可以在创建时为纹理提供初始数据。对于 USAGE_STATIC 纹理,这是唯一的方法。

TexDesc TexDesc;
TexDesc.Type = RESOURCE_DIM_TEX_2D;
TexDesc.Format = TEX_FORMAT_RGBA8_UNORM_SRGB;
TexDesc.Width = 1024;
TexDesc.Height = 1024;
TexDesc.MipLevels = 1;
TexDesc.BindFlags = BIND_SHADER_RESOURCE;
TexDesc.Usage = USAGE_STATIC;

TextureData InitData;
// Pointer to subresouce data, one for every mip level
InitData.pSubResources = subresources;
InitData.NumSubresources = _countof(subresources);

RefCntAutoPtr<ITexture> Texture;
Device->CreateTexture(TexDesc, InitData, &Texture);

纹理初始化执行方式类似于缓冲区初始化。在 Direct3D11 和 OpenGL/GLES 后端,有相应的原生 API 调用。在 Direct3D12/Vulkan 后端,引擎会在 CPU 可写内存中创建一个临时暂存纹理,将数据复制到该内存,然后发出 GPU 复制命令。

使用 ITexture::UpdateData() 更新纹理

在运行时更新纹理的第一个方法是使用 ITexture::UpdateData() 方法。该方法的工作方式类似于 IBuffer::UpdateData(),并将新数据写入指定的纹理区域

Box UpdateBox;
Uint32 Width = 128;
Uint32 Height = 64;
UpdateBox.MinX = 16;
UpdateBox.MinY = 32;
UpdateBox.MaxX = UpdateBox.MinX + Width;
UpdateBox.MaxY = UpdateBox.MinY + Height;

TextureSubResData SubresData;
SubresData.Stride = Width * 4;
SubresData.pData = Data.data();
Uint32 MipLevel = 0;
Uint32 ArraySlice = 0;
Texture->UpdateData(m_pImmediateContext, MipLevel,
                    ArraySlice, UpdateBox, SubresData);

在底层,这映射到以下原生 API 命令

OpenGL/GLES 后端

此操作直接转换为 glTexSubImage** 函数系列。

Direct3D11 后端

与缓冲区更新一样,在 Direct3D11 后端,此调用直接映射到 ID3D11DeviceContext::UpdateSubresource

Direct3D12/Vulkan 后端

与缓冲区一样,为了更新纹理,下一代后端首先在 CPU 可访问内存中分配区域并将客户端数据复制到该区域。然后,它们执行必要的状态转换,并发出 GPU 复制命令,该命令使用 GPU 特定布局将像素写入纹理。

性能

使用场景类似于缓冲区更新:应将此操作用于内容大部分保持不变且仅偶尔需要更新的纹理。

限制

由于该操作涉及两次复制和状态转换,因此对于频繁的纹理更新效率不高。

映射纹理

映射纹理是更新其内容的第二种方法。从 API 角度来看,映射纹理看起来与映射缓冲区类似

Uint32 MipLevel = 0;
Uint32 ArraySlice = 0;
MappedTextureSubresource MappedSubres;
Box MapRegion;
Uint32 Width = 128;
Uint32 Height = 256;
MapRegion.MinX = 32;
MapRegion.MinY = 64;
MapRegion.MaxX = MapRegion.MinX + Width;
MapRegion.MaxY = MapRegion.MinY + Height;
Texture->Map(m_pImmediateContext, MipLevel, ArraySlice,
             MAP_WRITE, MAP_FLAG_DISCARD,
             &MapRegion, MappedSubres);
WriteTextureData((Uint8*)MappedSubres.pData, Width,
                 Height, MappedSubres.Stride);
Texture.Unmap(m_pImmediateContext, 0, 0);

底层发生的情况与缓冲区有很大不同。

OpenGL/GLES 后端

目前在 OpenGL/GLES 后端不支持映射纹理。

Direct3D11 后端

在 Direct3D11 后端,此调用直接映射到具有 D3D11_MAP_WRITE_DISCARD 标志的 ID3D11DeviceContext::Map

Direct3D12/Vulkan 后端

下一代后端中没有像动态缓冲区那样的动态纹理。虽然可以通过绑定父缓冲区并应用偏移量从另一个缓冲区对缓冲区进行子分配,但纹理没有类似的方法。因此,即使所需的内存是从动态缓冲区子分配的,也没有办法将其视为纹理。将内存绑定到现有纹理也是不允许的。因此,在 Direct3D12/Vulkan 后端映射纹理与使用 ITexture::UpdateData() 更新纹理没有显著区别。映射纹理时,引擎直接返回 CPU 可访问内存的指针,从而避免了一次复制。然而,GPU 端复制以及最重要的状态转换仍然执行。

性能

尚不清楚 Direct3D11 在底层做了什么。最可能的两个选项是,在每次调用 Map 时,创建一个线性布局纹理并从 CPU 可访问内存中子分配它,或者执行与 Diligent 的下一代后端相同的操作。

映射动态纹理的效率不如映射动态缓冲区,并且典型的使用场景与 ITexture::UpdateData() 类似。

目前没有简单的方法可以在所有 API 中实现高频纹理更新,因此 Diligent 期望这将由应用程序使用低级 API 互操作性来实现。对于 Direct3D12 和 Vulkan 后端,一种可能的方法是创建一些 CPU 可写内存中的线性布局纹理,并以循环方式使用它们。由于此方法高度特定于应用程序,因此 Diligent Engine 不会通过通用 API 公开它。

限制

纹理映射目前在 OpenGL/GLES 后端未实现。

在 Direct3D11 中,只能使用 D3D11_MAP_WRITE_DISCARD 标志映射整个纹理级别。

在 Direct3D12/Vulkan 后端,映射动态纹理的效率不如映射动态缓冲区。实际上,它与使用 ITexture::UpdateData() 更新纹理非常相似,并且只避免了一次 CPU 端复制。

摘要

下表总结了缓冲区的更新方法

更新场景 用法 更新方法 注释
常量数据 USAGE_STATIC 不适用 数据只能在缓冲区初始化期间写入
< 每帧一次 USAGE_DEFAULT IBuffer::UpdateData()  
>= 每帧一次 USAGE_DYNAMIC IBuffer::Map() 动态缓冲区的内容将在每帧结束时失效

下表总结了纹理的更新方法

更新场景 用法/更新方法 注释
常量数据 USAGE_STATIC / n/a 数据只能在纹理初始化期间写入
< 每帧一次

USAGE_DEFAULT + ITexture::UpdateData()USAGE_DYNAMIC + ITexture::Map()

 
>= 每帧一次 由应用程序实现 动态纹理无法以与动态缓冲区相同的方式实现

源代码

完整的引擎源代码可在 GitHub 下载。

以下教程说明了本文所述的思路

教程 10 - 数据流

教程 11 - 资源更新

© . All rights reserved.