全部资源
  • 全部资源
  • - 交通工具
  • - 动物昆虫
  • - 场景模型
  • - 建筑模型
  • - 植物模型
  • - 武器模型
  • - 独家资源库
  • - 科幻模型
  • - 角色动画库
  • - 角色模型
  • 知识干货

巧用定制渲染架构,在Unity中实现大规模程序化几何体的实时GPU渲染

本文探讨了一种专为高效渲染程序化生成几何体而设计的定制渲染架构。我们的重点在于设置计算着色器并通过将其合并为更少的材质通道来优化批次计数,旨在最大限度地减少整个系统的内存分配。我们将略过间接渲染和GPU实例化的基础知识,因为已有大量资源对此类主题进行了详尽阐述。

巧用定制渲染架构,在Unity中实现大规模程序化几何体的实时GPU渲染次世代模型库

巧用定制渲染架构,在Unity中实现大规模程序化几何体的实时GPU渲染次世代模型库

巧用定制渲染架构,在Unity中实现大规模程序化几何体的实时GPU渲染次世代模型库

假设条件:我们正在构建一个系统,旨在以最少的数据量处理固定数量的变换,同时具备处理不同类型游戏元素(称为“预置体”)的灵活性。目标是以尽可能高效的方式渲染所有内容,其中大部分过程(如剔除、LOD选择和数据压缩)由GPU处理。

原型

将原型视作游戏设计中类似预置体的概念。它是一个包含所有LOD(细节层次)级别的包,每个级别都带有各自的子网格和材质。这一额外的抽象层赋予我们更多控制权,使我们能够在传统Unity渲染方式下难以实现或过于耗时的情况下应用一些巧妙手法。

定制示例:

  • 自定义阴影材质:通过在植被中排除alpha剪裁,或者使用单一材质绘制所有网格阴影,我们可以显著提升批次处理效率。
  • 跨LOD阈值渲染阴影:我们可利用原型的最后一个LOD级别网格为所有LOD距离渲染阴影。这种方法降低了顶点计数,进一步增强了批次处理效果。

 

首次实现尝试

受到诸如《GPU-驱动渲染引擎》等文章的启发,我的初期工作重点放在减少批次计数与优化材质通道上,本质上是力求以一个大型命令缓冲区渲染整个场景。

该架构运作如下:

  1. 缓冲区分配:我们需要知道每种原型的数量并为其分配特定缓冲区。我们将所需的所有原型组合在一起,分配一个大的变换和可见性缓冲区。
  2. 可见性标记与压缩:基于某些外部类,我们标记特定原型是否可见,然后进行前缀和+压缩操作,以了解需要绘制每种类型的原型数量。
  3. 数据打包:接着将这些数据打包进批次缓冲区,再转入命令缓冲区。

尽管这种方法相当标准且已有大量优秀论文讨论,但当我们要动态更改原型类型时,问题便显现出来。我的主要应用场景是将其与正在开发的动态地形实例化器配合使用。虽然可以预测变换数量,但具体原型类型数量未知,且每次更新实例化器时可能变化。

在首次尝试中,我为所有可能出现的情况分配了缓冲区。例如,如果要在1000个位置随机生成10种树木,就需要为实际上可能出现的10000棵树分配数据,并对它们全部执行前缀和/压缩。随着原型数量增加,这种方法的扩展性极差。

解决方案发现!

该算法的一大亮点在于其高效性,尤其是保持内存分配恒定,无论涉及多少原型。下面逐步解析这一过程:

巧用定制渲染架构,在Unity中实现大规模程序化几何体的实时GPU渲染次世代模型库

  1. 原型选取与剔除:选取原型索引并设置变换缓冲区。该缓冲区与变换缓冲区一对一映射,并在其中存储选中的原型索引。为了优化内存存储,采用以下方式打包选中原型缓冲区:16位选中原型索引,14位LOD值,2位可见性(GBuffer, Shadow)。16位最大值为65,536,作为批次组中最大原型类型数量。14位LOD值和2位可见性将在后续步骤中使用。
  2. 可见性传递:基于所选LOD,从“GPU_PROTOTYPE_LOD”缓冲区读取当前LOD级别需要渲染的网格。
  3. 为原型选取LOD网格:注意,“instancePickBatchBuffer”的大小取决于原型LOD级别中子网格的最大数量。在我的实现中,假定在GBuffer渲染中最多可存储4个子网格,在阴影通道中同样最多存储4个(两者可能不同)。
  4. 排序:在最后一步之后,我们的“instancePickBatchBuffer”的顺序非常混乱,我们仍然不知道每种类型的网格需要渲染多少个网格。因此,我们进行排序。
  5. 批次计数与命令缓冲区准备:遍历所有批次,计算其大小并将数据传入命令缓冲区。

我们在上面调度着色器并搜索批处理开始 – 结束。下面是执行此操作的示例代码:

[numthreads(GPUI_THREADS,1,1)]
void MarkLaneStart (uint3 id : SV_DispatchThreadID)
{
if(id.x >= maxInstances) return;

uint refIndex = batchIndexesRef[id.x];
uint prevRefIndex = batchIndexesRef[id.x-1];

uint pickBatch = batchIndexes[refIndex];
if(pickBatch >= 0xFFFF) return; // Invalid

if((pickBatch != batchIndexes[prevRefIndex] || id.x ==0))
{
batches[pickBatch].start = id.x;
batchesVisibility[pickBatch] = 1;
}
}
[numthreads(GPUI_THREADS,1,1)]
void MarkLaneEnd (uint3 id : SV_DispatchThreadID)
{
if(id.x >= maxInstances) return;

uint refIndex = batchIndexesRef[id.x];
uint nextRefIndex = batchIndexesRef[id.x+1];

uint pickBatch = batchIndexes[refIndex];
if(pickBatch >= 0xFFFF) return; // Invalid

if((pickBatch != batchIndexes[nextRefIndex] || id.x+1 >= maxInstances ))
{
batches[pickBatch].end = id.x;
}
}

注:这两个内核可以轻松合并为一个,但我将其保留原样是为了便于阅读。无论如何,这样做不会造成任何性能损失,因为99.9%的瓶颈在于排序阶段。

 

GPU排序

当前系统的一大瓶颈是排序。已有大量优秀的论文阐述了这一过程及其优化方法。我仍在测试新的选项,但可能最大的改变是转向DX12,利用WaveIntrinsics和其他内存访问技巧来提升性能。我发现了一个实现了最佳排序算法之一的仓库,并对其性能进行了测试:ShaderOneSweep

目前我的实现基于BitonicSort:GPUMergeSortForUnity

变换存储

如前所述,内存带宽是最大的瓶颈之一。我首先尝试优化矩阵变换,采用与Unity BRG中相同的float4x3代替float4x4。

另一项优化是不再存储WorldToObject矩阵,而是在着色器中计算。WorldToObject矩阵主要用于光照计算,因此对于某些渲染管线可以省略。

得益于Jason Booth提出的优化方案,我成功将位置、旋转和缩放打包到float3和uint3中。位置存储在float3中,缩放和旋转存储在uint3中。

这带来了显著的效果。例如,若用float4x4存储1,000,000个变换需要约64MB,而采用最终的打包方法仅需约24MB。

 

其他内存优化

除了缩放和旋转外,还对诸如变换索引选择和LOD交叉淡入值等其他参数进行了打包,进一步提升了内存使用效率。最终的缓冲区及其打包说明如下:

巧用定制渲染架构,在Unity中实现大规模程序化几何体的实时GPU渲染次世代模型库

绘制方法

如果你使用Unity,请记住使用“Graphics.RenderMeshIndirect()”而非“Graphics.DrawMeshInstancedIndirect()”。前者是最新版本且在HDRP等环境中支持运动向量等功能。你可以在Unity论坛轻松找到更多关于它们的详细信息。

 

我们能否做得更好?

  • 合并材质并使用纹理数组:这是下一步我计划优化的方向。思路是将材质参数合并到缓冲区中,并将它们的纹理存储在纹理数组中,绘制对象时只需选择数组中的相应页。问题在于,在Unity中以一种“干净”的方式实现这一想法颇具挑战性,因为据我所知,Unity并未提供直接控制纹理加载/卸载的API。

标签

评论