层次深度缓冲区


简短评论


分层深度缓冲区是用作深度查询的加速结构的多级深度缓冲区(Z缓冲区)。与纹理Mip链一样,每个级别的大小通常是将全分辨率缓冲区的大小除以2的结果。在本文中,我将讨论从全分辨率缓冲区生成分层深度缓冲区的两种方法。

首先,我将向您展示如何为深度缓冲区生成完整的Mip链,即使深度缓冲区的大小不等于2的幂,该深度链也可以在纹理坐标空间(或NDC)中保持深度查询的准确性。 (在Internet上,我遇到了不能保证这种准确性的代码示例,这会使在高Mip级别执行精确查询变得复杂。)

然后,对于仅需要一个级别的下采样的情况,我将演示如何通过一次调用计算着色器来生成此级别,该计算着色器使用工作组共享内存中的原子操作。在我的应用程序中,仅需要1/16 x 1/16的分辨率(mip级别4),使用计算着色器的方法比对mip链进行几遍下采样的常规方法快2-3倍

介绍


层次深度(也称为Hi-Z)是3D图形中经常使用的一种技术。它是用来加速隐形几何形状(遮挡剔除)(中的微调CPU,如以及GPU),计算在屏幕空间反射体积雾等等。

此外,Hi-Z GPU 通常被实现为栅格化管线的一部分。如果碎片图块被先前渲染的基元完全覆盖,则可以通过芯片上缓存中的快速Hi-Z搜索操作完全跳过碎片图块。

Hi-Z的基本思想是通过读取较低分辨率的缓冲区来加速深度查询操作。这比从缓冲区读取完整分辨率深度要快,原因有两个:

  1. 较低分辨率缓冲器的一个像素(或仅几个像素)可以用作高分辨率缓冲器的多个像素的近似值。
  2. 较低分辨率的缓冲区可能足够小并被缓存,从而极大地加快了搜索操作的执行速度(尤其是使用随机访问时)。

下采样的Hi-Z缓冲区级别的内容取决于如何使用它们(深度缓冲区是否将被“反转”,应使用哪种类型的请求)。通常,Hi-Z缓冲区级别的纹理像素会存储minmax在先前级别对应于它的所有纹理像素。有时和的值会min同时存储max。简单平均值(通常在常规纹理的Mip级别中使用)很少使用,因为它们对于此类查询很少有用。

Hi-Z缓冲区通常几乎在出口处立即被请求,以避免在全分辨率缓冲区中进行进一步的处理和更精确的搜索操作。例如,如果我们存储值max对于同相的深度缓冲区(深度值越大,对象越远),我们可以快速准确地确定屏幕空间中的特定位置是否被深度缓冲区覆盖(如果其坐标Z>是存储在某些位置的值(最大值) Hi-Z缓冲区的较高级别(即较低的分辨率))。

请注意,我使用了“完全”一词:如果坐标Z <=接收值(最大值),则不知道其缓冲区是否重叠。在某些应用中,在不确定的情况下,可能有必要在缓冲区中搜索完整的分辨率深度。在其他情况下,则不需要这样做(例如,如果只考虑了不必要的计算,而没有正确的渲染)。

我的应用程序:在计算着色器中渲染粒子


在我的PARTICULATE VR应用程序的引擎中的计算着色器 实现粒子渲染时,我面临使用Hi-Z的需求。由于此渲染技术不使用具有固定功能的栅格化,因此需要对每个具有一个像素大小的粒子使用自己的深度检查。而且由于粒子没有以任何方式分类,因此深度缓冲区的访问(在最坏的情况下)几乎是随机的。 全屏随机访问纹理上的搜索操作是降低性能的方法。为了减少负载,我首先在分辨率降低后的深度缓冲区中搜索深度,分辨率为原始分辨率的1/16 x 1/16。该缓冲区包含深度值。

min,它允许绝大多数可见粒子的计算渲染着色器跳过完整分辨率的深度测试。 (如果粒子深度是<较低分辨率缓冲区中存储的最小深度,则我们知道它是绝对可见的。如果它是> = min,则需要检查完整分辨率的深度缓冲区。)

因此,在一般情况下,对可见粒子进行了深度测试成为低成本经营。 (对于与几何图形重叠的粒子,它更昂贵,但是它适合我们,因为它不会导致渲染成本,因此粒子仍然需要很少的计算。)

由于首先在较低分辨率深度的缓冲区中执行搜索(如上所述),这一事实,粒子渲染时间最多减少35%与仅在全分辨率缓冲区中执行搜索的情况相比。因此,对于我的应用,Hi-Z非常有益。

现在,我们将介绍两种用于生成分层深度缓冲区的技术。

技术1:生成完整的Mip链


在许多Hi-Z应用中,需要创建完整的深度缓冲区Mip链。例如,当使用Hi-Z执行遮挡剔除时,将边界体积投影到屏幕空间中,并且使用投影的大小来选择适当的Mip级别(以便在每个重叠测试中包含固定数量的纹理像素)。

从全分辨率深度缓冲区生成Mip链通常是一项简单的任务-对于级别N的每个纹理像素,我们获取max(或min,或同时获取)先前生成的级别N-1中的相应4个纹理像素。我们执行顺序遍历(每次将大小减小一半),直到获得最后一个1x1的mip级别。

但是,在深度缓冲区的大小不等于2的幂的情况下,一切都变得更加复杂。由于用于深度缓冲区的Hi-Z通常是根据标准屏幕分辨率(很少是2的幂)构建的,因此我们需要找到解决此问题的可靠解决方案。

首先,让我们决定什么将意味着每个纹理像素Mip级别深度缓冲区的值。在本文中,我们将假定Mip链存储value min。深度搜索操作应使用最近邻居的过滤,因为值的插值min对我们来说无用的,并且会损害所创建深度的Mip链的层次性质。

那么,我们获得的mip级别N的单个纹理像素的值到底意味着什么?这应该是最小值(min全屏深度缓冲区的所有纹理像素的)在(规范化)纹理坐标空间中占据相同的空间。

换句话说,如果纹理的单独坐标(在区间[0,1]2)(通过过滤最接近的邻居)映射到全分辨率缓冲区的单个纹理像素,然后应将此全分辨率的纹理像素视为min在每个后续更高的Mip级别上为纹理像素计算的值的候选对象,并映射相同的纹理坐标。

如果保证了这种对应关系,那么我们将确保在高mip级别的搜索操作将永远不会在与全分辨率缓冲区(级别0)相对应的相同纹理坐标中返回depth值> texel值。在单独的N的情况下,此保证会在其以下的所有级别(<N)保持不变。

对于偶数维(在全分辨率缓冲区为2的幂的情况下),每个级别的偶数维直到最末尾(维数等于1)都将很容易实现。在一维情况下,对于具有索引的纹理元素i 在N级时,我们需要在N-1级时使用带有索引的纹理像素 22i+1并找到他们的意思minDN[i]=min(DN1[2i],DN1[2i+1])我们可以直接以“ 2比1”的比率比较纹理像素(因此也可以比较纹理坐标的大小),因为每个级别的大小恰好是前一个的两倍。


均匀级别大小的示例:此级别上的6个纹理像素在更高级别上减少到3个。三个高级别像素的每一个的纹理坐标大小都精确地叠加在每两个低级别像素上。(点是纹理像素的中心,正方形是在过滤最近的邻居时纹理坐标的尺寸。)

在奇数级大小的情况下(并且非分辨率为2的幂的全分辨率缓冲区将至少具有一个奇数大小的级)。越来越难。对于奇数大小的N-1级dimN1 下一级(N)的大小将相等 dimN=dimN12,即 dimN12

这意味着现在我们没有清晰的N-1级纹理像素到N级纹理像素的2对1映射。现在,N级每个纹理像素的纹理坐标大小叠加在N-1级3个纹理像素的大小上


奇数级别大小的示例:该级别的7个纹理像素在下一级别减少为3个纹理像素。三个高级纹理像素的纹理坐标的尺寸从较低的水平叠加在三个纹理像素的尺寸上。

因此DN[i]=min(DN1[2i],DN1[2i+1],DN1[2i+2])这意味着N-1级别的一个纹理元素有时会影响N级别的2个纹理元素的min计算。这对于维持上述比较是必要的。 为了简单起见,以上描述仅在一个维度上给出。在二维中,如果N-1级的两个维度都相等,则N-1级的2x2像素区域映射到N级的一个像素;如果其中一个维是奇数,则N-1级的2x3或3x2区域映射到一个。如果两个维度均为奇数,则还应考虑“角度”纹理像素,即,将N-1级别的3x3区域与N级别的一个像素进行比较。



代码示例


下面显示的G​​LSL着色器代码实现了我们描述的算法。必须从级别1(级别0是完整分辨率级别)开始,为随后的每个mip执行该操作。

uniform sampler2D u_depthBuffer;
uniform int u_previousLevel;
uniform ivec2 u_previousLevelDimensions;

void main() {
	ivec2 thisLevelTexelCoord = ivec2(gl_FragCoord);
	ivec2 previousLevelBaseTexelCoord = 2 * thisLevelTexelCoord;

	vec4 depthTexelValues;
	depthTexelValues.x = texelFetch(u_depthBuffer,
                                    previousLevelBaseTexelCoord,
                                    u_previousLevel).r;
	depthTexelValues.y = texelFetch(u_depthBuffer,
                                    previousLevelBaseTexelCoord + ivec2(1, 0),
                                    u_previousLevel).r;
	depthTexelValues.z = texelFetch(u_depthBuffer,
                                    previousLevelBaseTexelCoord + ivec2(1, 1),
                                    u_previousLevel).r;
	depthTexelValues.w = texelFetch(u_depthBuffer,
                                    previousLevelBaseTexelCoord + ivec2(0, 1),
                                    u_previousLevel).r;

	float minDepth = min(min(depthTexelValues.x, depthTexelValues.y),
                         min(depthTexelValues.z, depthTexelValues.w));

    // Incorporate additional texels if the previous level's width or height (or both) 
    // are odd. 
	bool shouldIncludeExtraColumnFromPreviousLevel = ((u_previousLevelDimensions.x & 1) != 0);
	bool shouldIncludeExtraRowFromPreviousLevel = ((u_previousLevelDimensions.y & 1) != 0);
	if (shouldIncludeExtraColumnFromPreviousLevel) {
		vec2 extraColumnTexelValues;
		extraColumnTexelValues.x = texelFetch(u_depthBuffer,
                                              previousLevelBaseTexelCoord + ivec2(2, 0),
                                              u_previousLevel).r;
		extraColumnTexelValues.y = texelFetch(u_depthBuffer,
                                              previousLevelBaseTexelCoord + ivec2(2, 1),
                                              u_previousLevel).r;

		// In the case where the width and height are both odd, need to include the 
        // 'corner' value as well. 
		if (shouldIncludeExtraRowFromPreviousLevel) {
			float cornerTexelValue = texelFetch(u_depthBuffer,
                                                previousLevelBaseTexelCoord + ivec2(2, 2),
                                                u_previousLevel).r;
			minDepth = min(minDepth, cornerTexelValue);
		}
		minDepth = min(minDepth, min(extraColumnTexelValues.x, extraColumnTexelValues.y));
	}
	if (shouldIncludeExtraRowFromPreviousLevel) {
		vec2 extraRowTexelValues;
		extraRowTexelValues.x = texelFetch(u_depthBuffer,
                                           previousLevelBaseTexelCoord + ivec2(0, 2),
                                           u_previousLevel).r;
		extraRowTexelValues.y = texelFetch(u_depthBuffer,
                                           previousLevelBaseTexelCoord + ivec2(1, 2),
                                           u_previousLevel).r;
		minDepth = min(minDepth, min(extraRowTexelValues.x, extraRowTexelValues.y));
	}

	gl_FragDepth = minDepth;
}

这段代码有缺陷


首先,如果全分辨率深度缓冲区的一个维度大于另一维度的两倍,则调用索引texelFetch可能会超出u_depthBuffer。 (在这种情况下,较小的尺寸先于1变为1。)我想使用此示例texelFetch(使用整数坐标),以便使所发生的事情尽可能清晰,并且不会亲自遇到如此特别的宽/高深度缓冲区。如果遇到此类问题,可以限制(clamp)传输的texelFetch坐标,也可以使用texture纹理规范化坐标(在采样器中,在边缘上设置一个限制)。在计算时,minmax应该始终多次考虑一个纹理像素是否存在边界情况。

其次,尽管texelFetch可以用一个替换前四个调用textureGather,但这使事情变得复杂(因为textureGather无法显示mip级别)。另外,使用时我没有注意到速度增加textureGather

性能


我使用上面的片段着色器为VR引擎中的两个深度缓冲区(每只眼睛一个)生成了两个完整的Mip链。在测试中,每只眼睛的分辨率为1648x1776,这导致创建了10个额外的降低的mip级别(这意味着10次通过)。在NVIDIA GTX 980上花费了0.25毫秒,在AMD R9 290上花费了0.30毫秒,从而为双眼生成了一条完整的链。



Mip- 4, 5 6, , . ( , , , .) Mip- 4 — , (103x111) 2.

mip-


上述算法的任务是维持纹理坐标空间(或NDC)中深度查询的准确性。为了完整性(并且因为我在下面的2中的技术中拒绝了此保证),我想演示一下我遇到的另一种方法(例如,在本文中)。

请注意,与前一个方法一样,此替代方法也设计用于大小不为2的幂的全分辨率缓冲区(但是,当然,它们的大小等于2的幂)。

在这种替代方法中,当对具有奇数宽度(或高度)的级别进行下采样而不是为每个输出纹理像素添加上一(较低)级别的附加列(或行)纹理像素时,我们仅对具有最大索引(“极端”纹理像素”的输出纹理像素执行此操作) )上面显示的片段着色器中唯一发生变化的是设置值shouldIncludeExtraColumnFromPreviousLevelshouldIncludeExtraRowFromPreviousLevel

// If the previous level's width is odd and this is the highest-indexed "edge" texel for 
// this level, incorporate the rightmost edge texels from the previous level. The same goes 
// for the height. 
bool shouldIncludeExtraColumnFromPreviousLevel =
    (previousMipLevelBaseTexelCoords.x == u_previousLevelDimensions.x - 3);
bool shouldIncludeExtraRowFromPreviousLevel =
    (previousMipLevelBaseTexelCoords.y == u_previousLevelDimensions.y - 3);

因此,具有最高索引的极端纹素变得非常“厚”,因为每个除以2个奇数维会导致这样一个事实,即它们占据了归一化纹理坐标空间的较大比例的间隔。

这种方法的缺点是执行高Mip级别的深度查询变得更加困难。我们不仅需要使用归一化的纹理坐标,还需要确定与这些坐标相对应的全分辨率纹理像素,然后将该纹理像素的坐标转换为相应的Mip级别的坐标,并执行其请求。

下面的代码段是从NDC空间迁移的[1,1]2到mip级别的texel坐标higherMipLevel

vec2 windowCoords = (0.5 * ndc.xy + vec2(0.5)) * textureSize(u_depthBuffer, 0);
// Account for texel centers being halfway between integers. 
ivec2 texelCoords = ivec2(round(windowCoords.xy - vec2(0.5)));
ivec2 higherMipLevelTexelCoords =
    min(texelCoords / (1 << higherMipLevel),
        textureSize(u_depthBuffer, higherMipLevel).xy - ivec2(1));

技术2:使用计算着色器生成单个Hi-Z水平


生成完整的Mip链非常快,但是让我有些烦恼的是,我的应用程序会生成所有这些级别,并且仅使用其中一个级别(级别4)。除了消除这种效率低下的问题,我还想看看如果仅使用一个计算着色器调用来生成所需的级别,那么可以加速多少工作。(值得注意的是,当使用带有多遍片段着色器的解决方案时,我的应用程序可以在第4级停止,因此在本节的最后,我将其用作比较运行时的基础。)

在大多数Hi-Z应用中,仅需要一个深度级别,因此我发现这种情况很常见。我为自己的特定要求编写了计算着色器(生成级别4,其分辨率为原始级别的1/16 x 1/16)。类似的代码可用于生成不同的级别。

计算着色器非常适合此任务,因为它可以使用共享的工作组内存在线程之间交换数据。每个工作组负责一个输出纹理像素(通过对缓冲区的下采样减少),并且工作组的线程共享计算min全分辨率相应纹理像素的工作,并通过共享内存共享结果。

我尝试了基于计算着色器的两个主要解决方案。首先,每个线程都atomicMin需要一个共享内存变量。

请注意,由于程序员无法(没有特定制造商的硬件扩展)对非整数值执行原子操作(并且我的深度存储为float),因此这里需要一些技巧。因为当它们的位作为无符号整数值进行处理非负浮动的IEEE 754标准保留它们的顺序点值,我们可以使用floatBitsToUint使(使用重新解释铸造)的深度值floatuint,然后调用atomicMin(以然后执行uintBitsToFloat对成品最小值uint) 。

最明显的解决方案atomicMin是创建16x16线程组,其中每个线程接收一个texel,然后atomicMin使用共享内存中的值执行它。我比较了使用较小流块(8x8、4x8、4x4、2x4、2x2)的这种方法,其中每个流接收一个texel区域并计算其自身的局部最小值,然后调用atomicMin

所有这些经过测试的解决方案中最快的atomicMinNVIDIA和AMD都提出了具有4x4流模块的解决方案(其中每个流本身都接收4x4 texel区域)。我不太明白为什么这个选择最快,但是也许反映了原子操作竞争和独立流计算之间的折衷。还值得注意的是,4x4工作组的大小每个扭曲/波动仅使用16个线程(并且也可以使用32或64个线程),这很有趣。下面的示例实现了这种方法。

作为使用的替代方法,atomicMin我尝试使用在此NVIDIA演示文稿中引用的技术执行并行还原(基本思想是使用与工作组中的线程数相同大小的共享内存阵列,以及用于依次联合计算每个线程低点,直到获得整个工作组的最终最小值。) 我使用与解决方案c相同的所有工作组大小尝试了此解决方案即使采用了NVIDIA演示文稿中描述的所有优化措施,并行还原解决方案(在两个GPU上都具有10个GPU的情况下)仍比我提出的解决方案稍慢另外,带有代码的解决方案要简单得多。log2(n)min

atomicMinatomicMinatomicMin

代码示例


使用此方法,最简单的方法是不尝试在减少缓冲区和全分辨率纹理像素之间的纹理坐标的标准化空间中保持对应关系。您可以简单地执行从全分辨率的texel坐标到降低分辨率的texel坐标的转换:

ivec2 reducedResTexelCoords = texelCoords / ivec2(downscalingFactor);

在我的情况下(生成等价于4级的等价点)downscalingFactor是16。

如上所述,此GLSL计算着色器实现了具有atomicMin4x4工作组大小的解决方案,其中每个线程从全分辨率缓冲区接收4x4 texel区域。所得到的减小的深度深度缓冲区min为全分辨率缓冲区大小的1/16 x 1/16(如果全分辨率大小不能被16整除,则四舍五入)。

uniform sampler2D u_inputDepthBuffer;
uniform restrict writeonly image2DArray u_outputDownsampledMinDepthBufferImage;
// The dimension in normalized texture coordinate space of a single texel in 
// u_inputDepthBuffer. 
uniform vec2 u_texelDimensions;

// Resulting image is 1/16th x 1/16th resolution, but we fetch 4x4 texels per thread, hence 
// the divisions by 4 here. 
layout(local_size_x = 16/4, local_size_y = 16/4, local_size_z = 1) in;

// This is stored as uint because atomicMin only works on integer types. Luckily 
// (non-negative) floats maintain their order when their bits are interpreted as uint (using 
// floatBitsToUint). 
shared uint s_workgroupMinDepthEncodedAsUint;

void main() {
	if (gl_LocalInvocationIndex == 0) {
        // Initialize to 1.0 (max depth) before performing atomicMin's. 
		s_workgroupMinDepthEncodedAsUint = floatBitsToUint(1.0);
	}

	memoryBarrierShared();
	barrier();

	// Fetch a 4x4 texel region per thread with 4 calls to textureGather. 'gatherCoords' 
    // are set up to be equidistant from the centers of the 4 texels being gathered (which 
    // puts them on integer values). In my tests textureGather was not faster than 
    // individually fetching each texel - I use it here only for conciseness. 
    // 
    // Note that in the case of the full-res depth buffer's dimensions not being evenly 
    // divisible by the downscaling factor (16), these textureGather's may try to fetch 
    // out-of-bounds coordinates - that's fine as long as the texture sampler is set to 
    // clamp-to-edge, as redundant values don't affect the resulting min. 

	uvec2 baseTexelCoords = 4 * gl_GlobalInvocationID.xy;
	vec2 gatherCoords1 = (baseTexelCoords + uvec2(1, 1)) * u_texelDimensions;
	vec2 gatherCoords2 = (baseTexelCoords + uvec2(3, 1)) * u_texelDimensions;
	vec2 gatherCoords3 = (baseTexelCoords + uvec2(1, 3)) * u_texelDimensions;
	vec2 gatherCoords4 = (baseTexelCoords + uvec2(3, 3)) * u_texelDimensions;

	vec4 gatheredTexelValues1 = textureGather(u_inputDepthBuffer, gatherCoords1);
	vec4 gatheredTexelValues2 = textureGather(u_inputDepthBuffer, gatherCoords2);
	vec4 gatheredTexelValues3 = textureGather(u_inputDepthBuffer, gatherCoords3);
	vec4 gatheredTexelValues4 = textureGather(u_inputDepthBuffer, gatherCoords4);

	// Now find the min across the 4x4 region fetched, and apply that to the workgroup min 
    // using atomicMin. 
	vec4 gatheredTexelMins = min(min(gatheredTexelValues1, gatheredTexelValues2),
                                 min(gatheredTexelValues3, gatheredTexelValues4));
	float finalMin = min(min(gatheredTexelMins.x, gatheredTexelMins.y),
                         min(gatheredTexelMins.z, gatheredTexelMins.w));
	atomicMin(s_workgroupMinDepthEncodedAsUint, floatBitsToUint(finalMin));

	memoryBarrierShared();
	barrier();

    // Thread 0 writes workgroup-wide min to image. 
	if (gl_LocalInvocationIndex == 0) {
		float workgroupMinDepth = uintBitsToFloat(s_workgroupMinDepthEncodedAsUint);

		imageStore(u_outputDownsampledMinDepthBufferImage,
		           ivec2(gl_WorkGroupID.xy),
                   // imageStore can only be passed vec4, but only a float is stored. 
				   vec4(workgroupMinDepth));
	}
}

性能


我使用了上面的计算着色器来处理具有与生成完整mip链相同的尺寸的全分辨率深度缓冲区(每只眼睛1648x1776缓冲区)。它在NVIDIA GTX 980上的运行时间为0.12毫秒,在AMD R9 290上的运行时间为0.08毫秒。如果我们将生成时间仅为1-4 mip(在NVIDIA上为0.22 ms,在AMD上为0.25 ms),那么用计算着色器中的溶液被证明是与NVIDIA GPU的快87%197%,比AMD的GPU更快

绝对而言,加速度不是很大,但是每0.1 ms很重要,尤其是在VR中:)

All Articles