Diligent Engine:入门网格着色器
介绍可用于现代 GPU 的新可编程阶段——放大着色器和网格着色器,以及如何使用 Diligent Engine API 在 GPU 上使用它们来实现视锥剔除和对象 LOD 选择。
免责声明:本文使用 Diligent Graphics 官方 GitHub 仓库中的信息。
背景
本文假定您已了解 3D 图形编程 API(Direct3D、OpenGL、Vulkan 等)的先验知识。
引言
图形处理单元 (GPU) 已经取得了长足的进步,从能够执行狭窄操作集的专用加速器,发展成为非常通用的高性能计算设备。它们仍然包含一组固定功能的阶段,例如光栅化器或混合阶段,但总体趋势是越来越多的这些阶段正在转向通用计算单元。
GPU 演进的最新步骤之一是顶点处理管线的通用化,用两个新的完全可编程的阶段——网格着色器和放大着色器——取代了它。
最初,硬件只能对输入顶点执行固定数量的操作。应用程序只能设置不同的变换矩阵(例如世界、相机、投影等),并指示硬件如何用这些矩阵变换输入顶点。这在应用程序可以对顶点做什么方面非常有限,因此为了通用化该阶段,引入了顶点着色器。
顶点着色器比固定功能的顶点变换阶段有了巨大的改进,因为现在开发人员可以自由实现任何顶点处理算法。但是,有一个很大的限制——顶点着色器接收一个顶点作为输入,并输出一个顶点。实现更复杂的算法,这些算法需要处理整个图元或在 GPU 上完全生成它们,是不可能的。
几何着色器就是在这种情况下引入的,它是顶点着色器之后的可选阶段。几何着色器接收整个图元作为输入,并可能输出零个、一个或多个图元。开发人员最初对这个阶段非常热情,并试图用它来处理几何体(细节级别选择、细分等),但很快就发现该阶段在性能方面非常差,因此对于任何这些任务来说几乎都是不切实际的。事实上,自从引入以来,该阶段在图形社区中的采用率一直很低。
为了给应用程序提供更大的灵活性并实现 GPU 上的高效几何处理,后来添加了两个新的可编程阶段和一个固定功能的阶段:曲面细分着色器和域着色器,以及一个能够产生预定数量细分的固定功能细分器阶段。曲面细分着色器作用于表面补丁(线性、三角形或正方形)。它的职责是作为一个整体处理补丁,变换其控制点,并指示后续的固定功能细分器如何将补丁细分成基本图元(三角形或线条)。域着色器在细分器之后运行,在某种程度上类似于顶点着色器,它作用于一个顶点并输出细分器生成的每个顶点。
在其演进过程中,顶点处理管线吸收了以下阶段:
- 图元装配阶段,在此阶段从一个或多个顶点缓冲区获取顶点数据,将它们组装在一起并传递给顶点着色器。
- 顶点着色器,在此阶段通常将顶点从对象空间变换到相机视图空间,但也可以进行动画处理或以任何其他方式进行处理。顶点着色器接收一个顶点作为输入,并输出一个顶点。
- 曲面细分阶段(可选),在此阶段使用一组预定义的规则将输入图元(四边形或三角形补丁)分解成更小的图元。
- 曲面细分着色器处理补丁控制点并定义细分因子。
- 固定功能的细分器将补丁域分解为基本图元。
- 域着色器针对细分器生成的细分中的每个新顶点进行调用。
- 几何着色器阶段(可选),在此阶段处理整个图元(三角形、线条或点),并输出其他图元。
现在应该清楚,顶点处理管线变成了一堆多个可编程和固定功能的阶段,这些阶段在某个时候是为了解决某个特定问题而设计的,并且需要进一步的通用化才能使其更通用和高效。
网格着色器管线
这就是网格着色器发挥作用的地方。网格着色器管线用两个新的可编程着色器阶段取代了以上所有阶段,其中一个是可选的。网格着色器通过 Direct3D12 和 Vulkan API 公开,并且在撰写本文时得到了 Nvidia RTX GPU 的支持。
必需的阶段称为网格着色器,并且位于我们稍后将讨论的可选放大阶段之后。仅网格着色器就可以在很大程度上取代旧顶点处理管线中的所有阶段。它可以变换顶点、生成细分、处理整个图元。网格着色器调用以计算组的形式运行,非常类似于计算着色器调用。计算组中的线程可以相互通信并共享结果。但是,与计算着色器不同,计算组中的线程数量是有限的(例如,在 Direct3D12 中不超过 128 个线程)。
第一个阶段是可选的,称为放大(或任务着色器)。它也以非常类似于计算着色器的方式在计算组中运行。此阶段的目标是生成网格着色器调用。放大着色器可用于执行诸如视锥剔除、细节级别选择等任务。
在本文的其余部分,我们将介绍 Diligent Engine 中的网格着色器,它是一个现代的跨平台低级图形库,并了解如何使用新的阶段在 GPU 上实现基本的视锥剔除和对象 LOD 选择。有关 Diligent Engine 的介绍,请参阅本文。
放大着色器
正如我们上面讨论的,放大着色器是第一个新的可编程阶段,在我们的应用程序中,它将执行视锥剔除和细节级别 (LOD) 选择。
该着色器类似于计算着色器,每个组执行 32 个线程。
#define GROUP_SIZE 32
[numthreads(GROUP_SIZE,1,1)]
void main(in uint I : SV_GroupIndex,
in uint wg : SV_GroupID)
{
由于与传统的顶点管线不同,网格着色器和放大着色器除了线程 ID 和组 ID 之外没有内置的输入属性,我们将使用结构化缓冲区将绘制命令参数传递给它们。实际应用程序可能为每个对象使用变换、网格 ID、材质 ID 和其他属性。在我们的例子中,为了简单起见,我们将只提供 2D 网格中的对象位置 (BasePos
)、其比例 (Scale
) 和用于着色器动画的时间偏移量 (TimeOffset
)。
struct DrawTask
{
float2 BasePos;
float Scale;
float TimeOffset;
};
StructuredBuffer<DrawTask> DrawTasks;
在着色器中,我们将需要使用一些全局数据:视图矩阵和相机视场角 (FOV) 的一半的余切值,以计算立方体的细节级别 (LOD);六个视锥平面用于视锥剔除;经过的时间用于动画化立方体位置。这些信息存储在常规的常量缓冲区中。
struct Constants
{
float4x4 ViewMat;
float4x4 ViewProjMat;
float4 Frustum[6];
float CoTanHalfFov;
float ElapsedTime;
uint FrustumCulling;
uint Padding;
};
cbuffer cbConstants
{
Constants g_Constants;
}
放大着色器将使用的另一条信息是对象几何体,通过另一个常量缓冲区提供。着色器只需要外接球体的半径,它将用于视锥剔除。
struct CubeData
{
float4 SphereRadius;
...
};
cbuffer cbCubeData
{
CubeData g_CubeData;
}
一个读写缓冲区 Statistics
将用于计算视锥剔除后可见立方体的数量。该值不会在着色器中使用,但会在 CPU 端读取以在 UI 中显示计数器。
RWByteAddressBuffer Statistics;
放大着色器调用将处理的数据(顶点位置、比例、LOD)
将存储在共享内存中。组中的每个线程将处理自己的元素。
struct Payload
{
float PosX[GROUP_SIZE];
float PosY[GROUP_SIZE];
float PosZ[GROUP_SIZE];
float Scale[GROUP_SIZE];
float LODs[GROUP_SIZE];
};
groupshared Payload s_Payload;
为了在每个线程中获得一个唯一的索引,我们将使用共享变量 s_TaskCount
。在着色器开始时,我们将计数器重置为零。我们只从第一个线程写入该值以避免数据争用,然后发出一个屏障,使该值对其他线程可见,并确保所有线程都处于同一阶段。
groupshared uint s_TaskCount;
[numthreads(GROUP_SIZE,1,1)]
void main(in uint I : SV_GroupIndex,
in uint wg : SV_GroupID)
{
// Reset the counter from the first thread in the group
if (I == 0)
{
s_TaskCount = 0;
}
// Flush the cache and synchronize
GroupMemoryBarrierWithGroupSync();
...
着色器读取特定于线程的值,并使用其绘制任务参数计算对象位置。gid
是绘制任务数据的全局索引。
const uint gid = wg * GROUP_SIZE + I;
DrawTask task = DrawTasks[gid];
float3 pos = float3(task.BasePos, 0.0).xzy;
float scale = task.Scale;
float timeOffset = task.TimeOffset;
// Simple animation
pos.y = sin(g_Constants.CurrTime + timeOffset);
然后,它使用对象位置执行视锥剔除,如果对象可见,则原子地增加共享的 s_TaskCount
值并计算 LOD。InterlockedAdd
返回的 index
变量存储访问负载数组的索引。该索引保证对所有线程都是唯一的,因此它们将处理不同的数组元素。
if (g_Constants.FrustumCulling == 0 || IsVisible(pos, g_CubeData.SphereRadius.x * scale))
{
uint index = 0;
InterlockedAdd(s_TaskCount, 1, index);
s_Payload.PosX[index] = pos.x;
s_Payload.PosY[index] = pos.y;
s_Payload.PosZ[index] = pos.z;
s_Payload.Scale[index] = scale;
s_Payload.LODs[index] = CalcDetailLevel(pos, g_CubeData.SphereRadius.x * scale);
}
IsVisible()
函数计算每个视锥平面到球体的符号距离,并计算
其可见性,方法是比较距离与球体半径。LOD 计算(CalcDetailLevel
函数)基于计算对象包围球在屏幕空间中的半径。有关算法的详细描述,请参阅“参考文献”部分中的链接。
在写入负载后,我们需要发出另一个屏障来等待所有线程达到同一点。
之后,我们可以安全地读取 s_TaskCount
值。组中的第一个线程将此值原子地加到全局 Statistics
计数器上。请注意,这比每个线程都增加计数器要快得多,因为它最大限度地减少了对全局内存的访问。
放大着色器的最后一步是使用组数量和将生成 s_TaskCount
个网格着色器调用的负载调用 DispatchMesh()
函数。DispatchMesh()
函数必须在每个放大着色器中调用一次,并结束放大着色器组的执行。
GroupMemoryBarrierWithGroupSync();
if (I == 0)
{
// Update statistics from the first thread
uint orig_value;
Statistics.InterlockedAdd(0, s_TaskCount, orig_value);
}
DispatchMesh(s_TaskCount, 1, 1, s_Payload);
网格着色器
在此示例中,网格着色器的目的是计算顶点位置,非常类似于传统管线中的顶点着色器,并且还输出图元。然而,与顶点着色器不同的是,网格着色器调用以计算组的形式运行,非常类似于计算着色器,并且可以共享线程之间的数据。
我们将使用 32 个可用线程中的 24 个。我们将生成 24 个顶点和 12 个图元,其中包含 36 个索引。SV_GroupIndex
表示网格着色器调用索引(在本例中为 0 到 23)。SV_GroupID
表示放大着色器输出(0
到 `s_TaskCount-1
)。
[numthreads(24,1,1)]
[outputtopology("triangle")]
void main(in uint I : SV_GroupIndex,
in uint gid : SV_GroupID,
in payload Payload payload,
out indices uint3 tris[12],
out vertices PSInput verts[24])
{
// Only the input values from the first active thread are used.
SetMeshOutputCounts(24, 12);
我们使用组 ID (gid
) 来读取放大着色器的输出。
float3 pos;
float scale = payload.Scale[gid];
float LOD = payload.LODs[gid];
pos.x = payload.PosX[gid];
pos.y = payload.PosY[gid];
pos.z = payload.PosZ[gid];
网格着色器使用与放大着色器相同的立方体常量缓冲区,但它还使用立方体顶点属性和索引。每个网格着色器线程仅处理由组索引 I
标识的一个输出顶点。
与常规顶点着色器非常相似,它使用视图-投影矩阵来变换顶点。
verts[I].Pos = mul(float4(pos + g_CubeData.Positions[I].xyz * scale, 1.0),
g_CubeData.ViewProjMat);
verts[I].UV = g_CubeData.UVs[I].xy;
在我们的示例中,LOD 不影响顶点计数,我们只是将其显示为颜色。实际的放大着色器会希望根据 LOD 值调整细分。
float4 Rainbow(float factor)
{
float h = factor / 1.35;
float3 col = float3(abs(h * 6.0 - 3.0) - 1.0, 2.0 - abs(h * 6.0 - 2.0),
2.0 - abs(h * 6.0 - 4.0));
return float4(clamp(col, float3(0.0, 0.0, 0.0), float3(1.0, 1.0, 1.0)), 1.0);
}
...
verts[I].Color = Rainbow(LOD);
最后,我们输出图元(6 个立方体面,共 12 个三角形)。只有前 12 个线程才写入索引。请注意,我们不能访问数组边界之外的内容。
if (I < 12)
{
tris[I] = g_CubeData.Indices[I].xyz;
}
准备立方体数据
为了使着色器能够访问立方体几何体,我们将将其放入常量缓冲区。如果数据量很大,实际应用程序可能需要使用结构化缓冲区或无序访问缓冲区。请注意,常量缓冲区中的所有数组元素都必须是 16 字节对齐的。
struct CubeData
{
float4 sphereRadius;
float4 pos[24];
float4 uv[24];
uint4 indices[36 / 3];
};
const float4 CubePos[] =
{
float4(-1,-1,-1,0), float4(-1,+1,-1,0), float4(+1,+1,-1,0), float4(+1,-1,-1,0),
...
};
const float4 CubeUV[] =
{
float4(0,1,0,0), float4(0,0,0,0), float4(1,0,0,0), float4(1,1,0,0),
...
};
const uint4 Indices[] =
{
uint4{2,0,1,0}, uint4{2,3,0,0},
...
};
CubeData Data;
Data.sphereRadius = float4{length(CubePos[0] - CubePos[1]) * std::sqrt(3.0f) * 0.5f, 0, 0, 0};
std::memcpy(Data.pos, CubePos, sizeof(CubePos));
std::memcpy(Data.uv, CubeUV, sizeof(CubeUV));
std::memcpy(Data.indices, Indices, sizeof(Indices));
BufferDesc BuffDesc;
BuffDesc.Name = "Cube vertex & index buffer";
BuffDesc.Usage = USAGE_STATIC;
BuffDesc.BindFlags = BIND_UNIFORM_BUFFER;
BuffDesc.uiSizeInBytes = sizeof(Data);
BufferData BufData;
BufData.pData = &Data;
BufData.DataSize = sizeof(Data);
m_pDevice->CreateBuffer(BuffDesc, &BufData, &m_CubeBuffer);
初始化管线状态
放大和网格着色器的初始化与初始化其他类型的着色器在很大程度上是相同的(请参阅本文)。唯一的区别是新的着色器类型(SHADER_TYPE_AMPLIFICATION
和 SHADER_TYPE_MESH
)。管线状态初始化也是如此。还要注意新的 PIPELINE_TYPE_MESH
管线类型。GraphicsPipeline
结构的一些字段,如 LayoutElements
和 PrimitiveTopology
,对于网格着色器来说是不相关的,并且会被忽略。网格着色器管线状态使用与传统顶点管线相同的像素着色器阶段。
PSODesc.PipelineType = PIPELINE_TYPE_MESH;
RefCntAutoPtr<IShader> pAS;
{
ShaderCI.Desc.ShaderType = SHADER_TYPE_AMPLIFICATION;
ShaderCI.EntryPoint = "main";
ShaderCI.Desc.Name = "Mesh shader - AS";
ShaderCI.FilePath = "cube.ash";
m_pDevice->CreateShader(ShaderCI, &pAS);
}
RefCntAutoPtr<IShader> pMS;
{
ShaderCI.Desc.ShaderType = SHADER_TYPE_MESH;
ShaderCI.EntryPoint = "main";
ShaderCI.Desc.Name = "Mesh shader - MS";
ShaderCI.FilePath = "cube.msh";
m_pDevice->CreateShader(ShaderCI, &pMS);
}
RefCntAutoPtr<IShader> pPS;
{
ShaderCI.Desc.ShaderType = SHADER_TYPE_PIXEL;
ShaderCI.EntryPoint = "main";
ShaderCI.Desc.Name = "Mesh shader - PS";
ShaderCI.FilePath = "cube.psh";
m_pDevice->CreateShader(ShaderCI, &pPS);
}
...
PSODesc.GraphicsPipeline.pAS = pAS;
PSODesc.GraphicsPipeline.pMS = pMS;
PSODesc.GraphicsPipeline.pPS = pPS;
m_pDevice->CreatePipelineState(PSOCreateInfo, &m_pPSO);
绘制任务数据初始化
正如我们之前讨论的,放大着色器从结构化缓冲区读取其参数。我们通过在 2D 网格上分布立方体并生成随机比例和动画时间偏移量来初始化绘制任务数据。
struct DrawTask
{
float2 BasePos;
float Scale;
float TimeOffset;
};
const int2 GridDim{128, 128};
FastRandReal<float> Rnd{0, 0.f, 1.f};
std::vector<DrawTask> DrawTasks;
DrawTasks.resize(GridDim.x * GridDim.y);
for (int y = 0; y < GridDim.y; ++y)
{
for (int x = 0; x < GridDim.x; ++x)
{
int idx = x + y * GridDim.x;
auto& dst = DrawTasks[idx];
dst.BasePos.x = (x - GridDim.x / 2) * 4.f + (Rnd() * 2.f - 1.f);
dst.BasePos.y = (y - GridDim.y / 2) * 4.f + (Rnd() * 2.f - 1.f);
dst.Scale = Rnd() * 0.5f + 0.5f; // 0.5 .. 1
dst.TimeOffset = Rnd() * PI_F;
}
}
我们使用结构化缓冲区,因为数据大小可能大于常量缓冲区支持的大小。
BufferDesc BuffDesc;
BuffDesc.Name = "Draw tasks buffer";
BuffDesc.Usage = USAGE_DEFAULT;
BuffDesc.BindFlags = BIND_SHADER_RESOURCE;
BuffDesc.Mode = BUFFER_MODE_STRUCTURED;
BuffDesc.uiSizeInBytes = sizeof(DrawTasks[0]) * static_cast<Uint32>(DrawTasks.size());
BufferData BufData;
BufData.pData = DrawTasks.data();
BufData.DataSize = BuffDesc.uiSizeInBytes;
m_pDevice->CreateBuffer(BuffDesc, &BufData, &m_pDrawTasks);
渲染
要发出 draw
命令,我们首先准备网格着色器所需的数据:我们计算相机的视场角 (FOV) 和半个 FOV 的余切值,因为这些值用于构建投影矩阵并在着色器中计算 LOD。
const float m_FOV = PI_F / 4.0f;
const float m_CoTanHalfFov = 1.0f / std::tan(m_FOV * 0.5f);
并将这些值上传到常量缓冲区。
MapHelper<Constants> CBConstants(m_pImmediateContext, m_pConstants, MAP_WRITE, MAP_FLAG_DISCARD);
CBConstants->ViewMat = m_ViewMatrix.Transpose();
CBConstants->ViewProjMat = m_ViewProjMatrix.Transpose();
CBConstants->CoTanHalfFov = m_LodScale * m_CoTanHalfFov;
CBConstants->FrustumCulling = m_FrustumCulling;
CBConstants->ElapsedTime = m_ElapsedTime;
CBConstants->Animate = m_Animate;
我们使用 ExtractViewFrustumPlanesFromMatrix()
函数从视图-投影矩阵计算视锥平面。请注意,平面未归一化,我们需要将其归一化才能用于球体剔除。最终的平面属性也写入常量缓冲区。
ViewFrustum Frustum;
ExtractViewFrustumPlanesFromMatrix(m_ViewProjMatrix, Frustum, false);
for (uint i = 0; i < _countof(CBConstants->Frustum); ++i)
{
Plane3D plane = Frustum.GetPlane(ViewFrustum::PLANE_IDX(i));
float invlen = 1.0f / length(plane.Normal);
plane.Normal *= invlen;
plane.Distance *= invlen;
CBConstants->Frustum[i] = plane;
}
现在我们终于准备好启动放大着色器了,这与分派计算组非常相似。
着色器每个组运行 32 个线程,所以我们需要分派 m_DrawTaskCount / ASGroupSize
个组。
(在此示例中,任务计数始终是 32 的倍数)
DrawMeshAttribs drawAttrs{m_DrawTaskCount / ASGroupSize, DRAW_FLAG_VERIFY_ALL};
m_pImmediateContext->DrawMesh(drawAttrs);
就这样!完整的示例源代码可在 GitHub 上找到。
致谢
感谢 Andrey Zhirnov 在 Diligent Engine 中实现网格着色器并为此文章的初始版本做出贡献。
参考文献
历史
- 2020 年 9 月 23 日 - 初始版本