GPU Driven Occlusion Culling(Hiz)

最近集成遮挡剔除算法,最终从各种算法预研收敛到HIZ算法(此算法也可快速将视锥剔除集成进来做到剔除算法全部采用GPU驱动为主,可参照Vulkan_基于GPU的视锥体剔除和LOD),因此本部分我们来着重看一下HIZ算法。首先我们来看一下遮挡剔除的算法有哪些:

一、遮挡剔除算法

遮挡剔除的几种主要算法包括:

  • 遮挡查询算法
  • 软件遮挡剔除算法
  • 覆盖缓冲区算法
  • 使用几何着色器进行 GPU 驱动的遮挡剔除算法
  • 使用计算和间接绘制进行 GPU 驱动的遮挡剔除算法

1.1 遮挡查询算法

这是一种最低效且常用的方法,你可以使用 D3D11_QUERY_OCCLUSION 或 GL_ARB_occlusion_query 在特殊渲染模式下渲染场景,在预处理前或者帧结束后使用它将返回调用像素着色器的像素数量或者直接返回是否可见(当然也可以结合场景管理粗略的优化)。可参照Vulkan_基于查询池的遮挡剔除

此算法优点:

  • GPU上快速执行
  • 遮挡精确
  • 可以节省 CPU 和 GPU 时间,因为可以跳过 CPU 端的绘图调用
  • 大部分API直接支持

此算法缺点:

  • 需要额外的绘图调用(查询池的使用)
  • 需要将遮挡物与被遮挡物分开并精确构建场景
  • 高延迟的回读(多 GPU 设置的情况下效率更低)

1.2 软遮挡剔除算法

该技术使用软件光栅化将遮挡物渲染到缩小的深度缓冲区并针对它测试遮挡物,所有操作都在 CPU 上完成,可参照Your Official Source for Developing on Intel® Hardware and Software算法详述。

此算法优点:

  • 遮挡测试结果立即可用,帧延迟为零
  • 它以 CPU 时间为代价节省了 GPU 时间(看你的性能瓶颈在哪,有些情况算是优点吧)
  • 可多线程扩展加速

此算法缺点:

  • CPU 上的软件光栅化速度很慢
  • 不适合动态场景
  • 对处理器要求高

1.3 覆盖缓冲区算法

没什么好说的,就是跟软遮挡剔除差不多。唯一的区别是它不是光栅化遮挡物,而是从 GPU 读取深度缓冲区,缩小并重新投影它并针对它测试被遮挡物。

GPU 回读具有很高的帧延迟,因此为了使其工作,必须使用先前的帧矩阵对深度缓冲区值进行反向投影,并使用当前的帧矩阵将其投影回来,此步骤称为重投影,详细可参照Temporal Anti-Aliasing(时域抗锯齿TAA)

此方法主要需要注意的是,在相机快速移动和各种孔的情况下,重投影通常会留下很大的间隙问题

此算法优点:

  • 整体遮挡,效率高
  • 适合户外大场景
  • 继承了软遮挡剔除的优点

此算法缺点:

  • 重投影会有时间间隙和孔洞的缺陷
  • 快速的相机移动会留下很大的间隙,不会遮挡任何东西
  • 深度缓冲区回读具有与遮挡查询相同的延迟
  • 继承了软遮挡剔除的缺点

1.4 使用几何着色器进行 GPU 驱动的遮挡剔除算法

可以说是第一个完全由 GPU 驱动的遮挡剔除方法,由Daniel Rákos开创。主要思想是将被遮挡物渲染为点(将边界框数据作为属性)并且经历以下步骤:

  • 顶点着色器检查边界框和视锥体
  • 如果可见,则将顶点发送到几何着色器
  • 几何着色器采用和软遮挡剔除类似的方式针对缩小的深度缓冲区测试框,如果测试通过,则执行几何着色器
  • 流输出获取处理后的数据

此算法优点:

  • CPU无压力
  • 不需要任何复杂的场景管理
  • 零帧延迟
  • 可以处理大量对象
  • 适用于动态场景

此算法缺点:

  • 需要一些额外的绘图调用
  • 需要预测缓冲区大小以避免额外的内存消耗
  • 仍需使用深度缓冲区

1.5 使用计算着色器和间接绘制进行 GPU 驱动的遮挡剔除算法

现阶段比较成熟的遮挡剔除算法,一般都会采用此方法,具体步骤如下:

  • 使用简单的像素着色器将遮挡器渲染到深度缓冲区;
  • 生成Hierarchical-Z mipmap;
  • 为所有对象调用计算着色器执行剔除;
  • 间接绘制可见物体;

此算法优点:

  • 无 CPU 消耗
  • 软遮挡剔除或软覆盖缓冲区的所有优点
  • 实现起来很简单
  • 剔除精确

此算法缺点:

  • 图形API版本限制
  • 间接渲染通常比普通渲染慢
  • 要求所有内容都通过实例化渲染并高效批处理,否则效率不高

二、Hiz遮挡剔除

Hi-Z Culling Hiz的全称是Hierarchical-Z map based occlusion culling。本算法主要分为四步:

  1. 遮挡缓冲区生成
  2. Hierarchical-Z mipmap生成
  3. 计算着色器判断可见性
  4. 绘制可见实体

2.1 遮挡缓冲区生成

本部分主要是将所有主要遮挡物的深度渲染到遮挡缓冲区。在这种情况下,“遮挡缓冲区”是具有 DepthStencil 视图的纹理。根据您的渲染需求或遮挡精度要求,此缓冲区可以是全分辨率,也可以更低的分辨率。例如,低分辨率缓冲区会更容易遮挡小道具。此处只需要将深度写入遮挡缓冲区,可以只使用一个简单的顶点着色器,片元着色器可以不设置,这样可以使写入缓冲区的加速。

如果你想使用上一帧的深度缓冲区也可以,但最好别采用全分辨率,会因为相机移动导致剔除不正确,采用低分辨率粗略剔除可以改善此部分。

2.2 Hierarchical-Z mipmap生成

本部分主要创建遮挡缓冲区的 Hierarchical-Z mipmap,使用 max 运算符生成每个 mip 级别。根据您渲染到遮挡缓冲区中的内容,这个 Hi-z mipmap也可以用于其他环境,例如加速体积雾计算、屏幕空间反射等。我可以使用计算着色器进行下采样。。

在您的下采样着色器中,在每个步骤中,您可以使用 4 次纹理读取来读取深度以计算最大值,也可以使用一次读取返回所有 4 个深度的 Gather 操作。

void downscale(uint3 threadID : SV_DispatchThreadID)
{
   if (all(threadID.xy < RTSize.xy))
   {
   	  //获取周围像素深度几何
      float4 depths = inputRT.Gather(samplerPoint, (threadID.xy + 0.5)/ RTSize.xy);
 
      //查找当前像素点最大深度值
      outputRT[threadID.xy] = max(max(depths.x, depths.y), max(depths.z, depths.w));
    }
}

这种方法的一个缺点是,由于 Gather 不支持 mip 级别选择,您必须为每个 mip 级别创建不同的着色器/渲染目标视图,并在下采样期间连续绑定它们。或者,您可以使用带有 SampleLevel 的 4 个纹理读取来选择要从中读取的 mip。

生成的mipmap如下所示(示意用,具体需要根据你的分辨率生成对应多层级的mipmap):
在这里插入图片描述

2.3 计算着色器判断可见性

接下来,我们将实例的数据打包到结构化缓冲区中:世界空间下的包围盒信息。你可以把视锥体剔除和遮挡剔除都继承到GPU上,此时需要将视锥体六个面传到计算着色判断包围盒是否在视锥体内即可。

遮挡剔除是使用计算着色器实现,具体算法如下:

Texture2D inputRT : register(t0);
RWTexture2D outputRT : register(u0);
StructuredBuffer instanceDataIn : register(t1);
AppendStructuredBuffer instanceDataOut : register(u0);
RWBuffer instanceCounts : register(u1);
SamplerState samplerPoint : register(s0);
 
void occlusion(uint3 threadID : SV_DispatchThreadID)
{
    //不能大于实例个数,否则间接绘制的buffer数据会出现异常
    //因为计算着色器分配的线程数量可能与实例个数不同
    if (threadID.x < NoofInstances)
    {
        float3 bboxMin = instanceDataIn[threadID.x].bboxMin.xyz;
        float3 bboxMax = instanceDataIn[threadID.x].bboxMax.xyz;
        float3 boxSize = bboxMax - bboxMin;
 
        float3 boxCorners[] = { bboxMin.xyz,
                                bboxMin.xyz + float3(boxSize.x,0,0),
                                bboxMin.xyz + float3(0, boxSize.y,0),
                                bboxMin.xyz + float3(0, 0, boxSize.z),
                                bboxMin.xyz + float3(boxSize.xy,0),
                                bboxMin.xyz + float3(0, boxSize.yz),
                                bboxMin.xyz + float3(boxSize.x, 0, boxSize.z),
                                bboxMin.xyz + boxSize.xyz
                             };
        float minZ = 1;
        float2 minXY = 1;
        float2 maxXY = 0;
 
        [unroll]
        for (int i = 0; i < 8; i++)
        {
            //dx NDC空间获取最小深度值
            float4 clipPos = mul(float4(boxCorners[i], 1), ViewProjection);
 
            clipPos.z = max(clipPos.z, 0);
 
            clipPos.xyz = clipPos.xyz / clipPos.w;
 
            clipPos.xy = clamp(clipPos.xy, -1, 1);
            clipPos.xy = clipPos.xy * float2(0.5, -0.5) + float2(0.5, 0.5);
 
            minXY = min(clipPos.xy, minXY);
            maxXY = max(clipPos.xy, maxXY);
 
            minZ = saturate(min(minZ, clipPos.z));
        }
 
        float4 boxUVs = float4(minXY, maxXY);
 
        // 计算采样的hiz mip层级
        int2 size = (maxXY - minXY) * RTSize.xy;
        float mip = ceil(log2(max(size.x, size.y)));
 
        mip = clamp(mip, 0, MaxMipLevel);
 
        // 较低(细粒度)级别的层级  
        float  level_lower = max(mip - 1, 0);
        float2 scale = exp2(-level_lower);
        float2 a = floor(boxUVs.xy*scale);
        float2 b = ceil(boxUVs.zw*scale);
        float2 dims = b - a;
 
        // 如果我们只在两个维度上间距<= 2 texel,则使用较低的层次  
        if (dims.x <= 2 && dims.y <= 2)
            mip = level_lower;
 
        //获取hiz上的深度值
        float4 depth = { inputRT.SampleLevel(samplerPoint, boxUVs.xy, mip),
                         inputRT.SampleLevel(samplerPoint, boxUVs.zy, mip),
                         inputRT.SampleLevel(samplerPoint, boxUVs.xw, mip),
                         inputRT.SampleLevel(samplerPoint, boxUVs.zw, mip)
                        };
 
        //最大深度
        float maxDepth = max(max(max(depth.x, depth.y), depth.z), depth.w);
 
 		//如果被测物体最小深度大于遮挡物最大深度 则不可见
        if (ActivateCulling == 0 || minZ <= maxDepth)
        {         
        	InterlockedAdd(instanceCounts[ instanceDataIn[threadID.x].instanceCountOffset ], 1);
        }
    }
}

hiz mipmap剔除具体原理见Practical, Dynamic Visibility for Games

2.4 绘制可见实体

由于遮挡将发生在 GPU 上,并且 GPU 消耗的结果也无需 CPU 干预,因此我们需要使用

void DrawIndexedInstancedIndirect(
  [in] ID3D11Buffer *pBufferForArgs,
  [in] UINT         AlignedByteOffsetForArgs
);

这在功能上与 DrawIndexedInstanced 相同,主要区别在于它通过 ID3D11Buffer 接收参数,此pBufferForArgs也就是我们在计算着色器中写入的buffer,AlignedByteOffsetForArgs是pBufferForArgs中的偏移值,这个值可以获取对应的命令偏移位。

重点来了:间接绘制到底如何使用,集成算法过程中一直在纠结这个问题,下边来具体说一下:

我们首先看一下

void DrawIndexedInstanced(
  [in] UINT IndexCountPerInstance,
  [in] UINT InstanceCount,
  [in] UINT StartIndexLocation,
  [in] INT  BaseVertexLocation,
  [in] UINT StartInstanceLocation
);

可以看到正常想绘制实例的话,必须有以上五个参数,但是DrawIndexedInstancedIndirect没有,那么GPU如何知道该绘制多少个实例,每个实例用多少了所以和偏移值呢?

这就是用到pBufferForArgs的地方了,可以简单理解:pBufferForArgs其实就是由{IndexCountPerInstance, InstanceCount, StartIndexLocation, BaseVertexLocation,StartInstanceLocation}构成的数组,可以在CPU端开辟空间(开辟时候需要标识为D3D11_RESOURCE_MISC_DRAWINDIRECT_ARGS),将必要的数据填入:单实例的索引数量、偏移等数据,之后在GPU将实例个数参数传递进去(InterlockedAdd()的个数),最后在调用时候将后一个AlignedByteOffsetForArgs设置成对应的{IndexCountPerInstance, InstanceCount, StartIndexLocation, BaseVertexLocation,StartInstanceLocation}在数组中的位置即可。
这便是DrawIndexedInstancedIndirect的用法。其实很简单,刚开始的时候有点蒙圈,后续了解了便很清晰明了。

2.5 算法拓展

上述算法其实是最简单的一种,在集成过程中发现,如果场景中实例化数量很多的话,此算法将极大提升帧率,但如果实例化数量很少的话,其实深度缓冲区获取和计算着色器的计算就很耗费时间的,会得不偿失。

本算法还有极大的提升空间,可以使用各种方式来优化,实现一次性对所有的所有实例执行遮挡,以减少绘制调用的数量并更好地利用计算单元。具体可以参照EXPERIMENTS IN GPU-BASED OCCLUSION CULLING

由于是在工作环境集成的算法就不放截图了,核心代码都在上边了大家可以自行实现即可。说白了,其实就是间接绘制API如何调用的事。

  • 3
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值