Hierarchical Z-Buffer (HZB) — это иерархическая структура глубины, представляющая сцену в виде набора mip-уровней, где каждый следующий уровень хранит обобщённое значение глубины из более крупного блока пикселей.

Когда я реализовывал HZB для собственного графического движка и изучал различные статьи и реализации, оказалось, что почти везде описывается примерно один и тот же подход. Обычно это прямолинейная генерация, где один вызов compute шейдера или пиксельного шейдера генерирует один mip-уровень, учитывая четность размеров текстуры.

Однако затем я решил заглянуть внутрь Unreal Engine и посмотреть, как HZB реализован там. Оказалось, что реализация содержит несколько интересных решений, которые редко упоминаются в популярных статьях или примерах. В этой статье я разберу эти особенности и покажу, как именно устроена генерация HZB в Unreal Engine.

Мы рассмотрим следующие аспекты реализации:

  • как выбираются размеры HZB и почему они округляются до степени двойки;

  • зачем в шейдере выполняется специальное округление значений float;

  • как используется функция Gather и почему она может быть эффективнее нескольких отдельных выборок;

  • как Unreal генерирует несколько mip-уровней за один dispatch (батчинг мипов);

  • каким образом применяется эффективная работа с groupshared памятью;

  • где используются wave-операции;

  • и как Morton Z Curve помогает улучшить доступ к памяти и снизить конфликты банков.

В итоге мы детально разберём одну из самых оптимизированных реализаций генерации HZB, используемую в Unreal Engine.

Размеры HZB

Hierarchical Z Buffer представляет собой mip-цепочку, где размер нулевого mip обычно вычисляется как floor(size / 2.0) — то есть деление на два с округлением вниз до ближайшего целого. Размер каждого следующего mip-уровня вычисляется точно также.

Например, если исходный буфер глубины имеет размер 850x850, то цепочка HZB будет выглядеть следующим образом:

mip 0

mip 1

mip 2

mip 3

mip 4

mip 5

mip 6

mip 7

mip 8

425x425

212x212

106x106

53x53

26x26

13x13

6x6

3x3

1x1

Как видно, многие размеры оказываются нечётными. Если при генерации каждого mip-уровня всегда брать только квад 2×2 из предыдущего уровня, часть данных будет потеряна — крайние тексели просто не попадут в редукцию.

Поэтому в таких случаях необходимо проверять размеры текстуры и выполнять дополнительные выборки, чтобы корректно обработать границы. Подобный подход часто описывается в статьях по HZB, например в статье Mike Turitzin “Hierarchical Depth Buffers”.

Однако в Unreal Engine используется другой подход.

Нулевой mip-уровень HZB выбирается как ближайшая степень двойки, не превышающая размер исходного depth-буфера. Если снова взять текстуру размером 850×850, то цепочка будет выглядеть так:

mip 0

mip 1

mip 2

mip 3

mip 4

mip 5

mip 6

mip 7

mip 8

mip 9

512x512

256x256

128x128

64x64

32x32

16x16

8x8

4x4

2x2

1x1

В этом случае получается на один mip-уровень больше, однако размеры всех уровней становятся степенями двойки. С такими размерами значительно проще работать, и, как мы увидим далее, это позволяет реализовать более эффективную генерацию HZB, включая батчинг нескольких mip-уровней за один dispatch.

Упрощённый код вычисления размера нулевого mip-уровня выглядит следующим образом:

uint32 RoundUpToPowerOfTwo(uint32 Arg)
{
  Arg = Arg ? Arg : 1;
  
  unsigned long BitIndex;
  _BitScanReverse(&BitIndex, Arg - 1);
  
  return 1u << BitIndex;
}

Полную реализацию этой функции можно найти в исходниках Unreal Engine:

Шейдер генерации HZB

Полный код шейдера из Unreal Engine располагается в файле: Engine\Shaders\Private\HZB.usf

В нём присутствует дополнительная логика, связанная с Visibility Buffer, а также с Froxels — структурой, используемой для кластеризации сцены по глубине и объёму (например, в алгоритмах кластерного освещения).

Кроме того, в файле есть альтернативная и простая реализация генерации HZB с использованием pixel shader.

В рамках этой статьи мы будем рассматривать только реализацию на compute-шейдере. Код, связанный с Visibility Buffer и Froxels, не влияет на основной алгоритм построения HZB, поэтому для простоты мы опустим эти части и сосредоточимся на ключевых элементах шейдера.

Шейдер генерации HZB из Unreal Engine (сокращенный вариант)
// Copyright Epic Games, Inc. All Rights Reserved.

#include "Common.ush"
#include "SceneTextureParameters.ush"
#include "ReductionCommon.ush"
#include "/Engine/Public/WaveBroadcastIntrinsics.ush"


#define MAX_MIP_BATCH_SIZE 4
#define GROUP_TILE_SIZE 8


float4 DispatchThreadIdToBufferUV;
float2 InvSize;
float2 InputViewportMaxBound;

Texture2D ParentTextureMip;
SamplerState ParentTextureMipSampler;

RWTexture2D<float> FurthestHZBOutput_0;
RWTexture2D<float> FurthestHZBOutput_1;
RWTexture2D<float> FurthestHZBOutput_2;
RWTexture2D<float> FurthestHZBOutput_3;

RWTexture2D<float> ClosestHZBOutput_0;
RWTexture2D<float> ClosestHZBOutput_1;
RWTexture2D<float> ClosestHZBOutput_2;
RWTexture2D<float> ClosestHZBOutput_3;

groupshared float SharedMinDeviceZ[GROUP_TILE_SIZE * GROUP_TILE_SIZE];
groupshared float SharedMaxDeviceZ[GROUP_TILE_SIZE * GROUP_TILE_SIZE];

float RoundUpF16(float DeviceZ)
{
	// ClosestDeviceZ needs to be rounded up to nearest fp16 to be conservative
	return f16tof32(f32tof16(DeviceZ) + 1);
}

void OutputMipLevel(uint MipLevel, uint2 OutputPixelPos, float FurthestDeviceZ, float ClosestDeviceZ)
{
#if DIM_MIP_LEVEL_COUNT >= 2
	if (MipLevel == 1)
	{
#if DIM_FURTHEST
			FurthestHZBOutput_1[OutputPixelPos] = FurthestDeviceZ;
#endif
#if DIM_CLOSEST
			ClosestHZBOutput_1[OutputPixelPos] = RoundUpF16(ClosestDeviceZ);
#endif
	}
#endif
#if DIM_MIP_LEVEL_COUNT >= 3
	else if (MipLevel == 2)
	{
#if DIM_FURTHEST
			FurthestHZBOutput_2[OutputPixelPos] = FurthestDeviceZ;
#endif
#if DIM_CLOSEST
			ClosestHZBOutput_2[OutputPixelPos] = RoundUpF16(ClosestDeviceZ);
#endif
	}
#endif
#if DIM_MIP_LEVEL_COUNT >= 4
	else if (MipLevel == 3)
	{
#if DIM_FURTHEST
			FurthestHZBOutput_3[OutputPixelPos] = FurthestDeviceZ;
#endif
#if DIM_CLOSEST
			ClosestHZBOutput_3[OutputPixelPos] = RoundUpF16(ClosestDeviceZ);
#endif
	}		
#endif
}


[numthreads(GROUP_TILE_SIZE, GROUP_TILE_SIZE, 1)]
void HZBBuildCS(
	uint2 GroupId : SV_GroupID,
	uint GroupThreadIndex : SV_GroupIndex)
{
#if DIM_MIP_LEVEL_COUNT == 1
		uint2 GroupThreadId = uint2(GroupThreadIndex % GROUP_TILE_SIZE, GroupThreadIndex / GROUP_TILE_SIZE);
#else
		uint2 GroupThreadId = InitialTilePixelPositionForReduction2x2(MAX_MIP_BATCH_SIZE - 1, GroupThreadIndex);
#endif

	uint2 GroupOffset = GROUP_TILE_SIZE * GroupId;
	uint2 DispatchThreadId = GroupOffset + GroupThreadId;

	float2 BufferUV = (DispatchThreadId + 0.5) * DispatchThreadIdToBufferUV.xy + DispatchThreadIdToBufferUV.zw;
	float2 UV = min(BufferUV + float2(-0.25f, -0.25f) * InvSize, InputViewportMaxBound - InvSize);
    float4 DeviceZ = ParentTextureMip.GatherRed(ParentTextureMipSampler, UV);

	float MinDeviceZ = min(min3(DeviceZ.x, DeviceZ.y, DeviceZ.z), DeviceZ.w);
	float MaxDeviceZ = max(max3(DeviceZ.x, DeviceZ.y, DeviceZ.z), DeviceZ.w);
	
	uint2 OutputPixelPos = DispatchThreadId;
	
#if DIM_FURTHEST
		FurthestHZBOutput_0[OutputPixelPos] = MinDeviceZ;
#endif
	
#if DIM_CLOSEST
		ClosestHZBOutput_0[OutputPixelPos] = RoundUpF16(MaxDeviceZ);
#endif

#if DIM_MIP_LEVEL_COUNT == 1
	{
		// NOP
	}
#else
	{
		SharedMinDeviceZ[GroupThreadIndex] = MinDeviceZ;
		SharedMaxDeviceZ[GroupThreadIndex] = MaxDeviceZ;
#if FEATURE_LEVEL >= FEATURE_LEVEL_SM6 || PLATFORM_SUPPORTS_SM6_0_WAVE_OPERATIONS
		const uint LaneCount = WaveGetLaneCount();
#else
		// Actual wave size is unknown, assume the worst
		const uint LaneCount = 0u;
#endif
	
		UNROLL
		for (uint MipLevel = 1; MipLevel < DIM_MIP_LEVEL_COUNT; ++MipLevel)
		{
			const uint TileSize = uint(GROUP_TILE_SIZE) >> MipLevel;
			const uint ReduceBankSize = TileSize * TileSize;
			
			// More waves than one wrote to LDS, need to sync.
			if ((ReduceBankSize << 2u) > LaneCount)
			{
				GroupMemoryBarrierWithGroupSync();
			}

			BRANCH
			if (GroupThreadIndex < ReduceBankSize)
			{
				float4 ParentMinDeviceZ;
				float4 ParentMaxDeviceZ;
				ParentMinDeviceZ[0] = MinDeviceZ;
				ParentMaxDeviceZ[0] = MaxDeviceZ;

				UNROLL
				for (uint i = 1; i < 4; i++)
				{
					uint LDSIndex = GroupThreadIndex + i * ReduceBankSize;
					ParentMinDeviceZ[i] = SharedMinDeviceZ[LDSIndex];
					ParentMaxDeviceZ[i] = SharedMaxDeviceZ[LDSIndex];
				}
				
				MinDeviceZ = min(min3(ParentMinDeviceZ.x, ParentMinDeviceZ.y, ParentMinDeviceZ.z), ParentMinDeviceZ.w);
				MaxDeviceZ = max(max3(ParentMaxDeviceZ.x, ParentMaxDeviceZ.y, ParentMaxDeviceZ.z), ParentMaxDeviceZ.w);
	
				OutputPixelPos = OutputPixelPos >> 1;
				OutputMipLevel(MipLevel, OutputPixelPos, MinDeviceZ, MaxDeviceZ);
				
				SharedMinDeviceZ[GroupThreadIndex] = MinDeviceZ;
				SharedMaxDeviceZ[GroupThreadIndex] = MaxDeviceZ;
			}
		}
	}
#endif
}

Давайте разберём этот код по порядку.

Генерация нулевого мипа

Рассмотрим первую часть шейдера:

#define GROUP_TILE_SIZE 8
#define MAX_MIP_BATCH_SIZE 4

RWTexture2D<float> FurthestHZBOutput_0;
RWTexture2D<float> ClosestHZBOutput_0;

[numthreads(GROUP_TILE_SIZE, GROUP_TILE_SIZE, 1)]
void HZBBuildCS(
	uint2 GroupId : SV_GroupID,
	uint GroupThreadIndex : SV_GroupIndex)
{
#if DIM_MIP_LEVEL_COUNT == 1
		uint2 GroupThreadId = uint2(GroupThreadIndex % GROUP_TILE_SIZE, GroupThreadIndex / GROUP_TILE_SIZE);
#else
		uint2 GroupThreadId = InitialTilePixelPositionForReduction2x2(MAX_MIP_BATCH_SIZE - 1, GroupThreadIndex);
#endif

	uint2 GroupOffset = GROUP_TILE_SIZE * GroupId;
	uint2 DispatchThreadId = GroupOffset + GroupThreadId;

	float2 BufferUV = (DispatchThreadId + 0.5) * DispatchThreadIdToBufferUV.xy + DispatchThreadIdToBufferUV.zw;
	float2 UV = min(BufferUV + float2(-0.25f, -0.25f) * InvSize, InputViewportMaxBound - InvSize);
    float4 DeviceZ = ParentTextureMip.GatherRed(ParentTextureMipSampler, UV);

	float MinDeviceZ = min(min3(DeviceZ.x, DeviceZ.y, DeviceZ.z), DeviceZ.w);
	float MaxDeviceZ = max(max3(DeviceZ.x, DeviceZ.y, DeviceZ.z), DeviceZ.w);
	
	uint2 OutputPixelPos = DispatchThreadId;

#if DIM_FURTHEST
		FurthestHZBOutput_0[OutputPixelPos] = MinDeviceZ;
#endif

#if DIM_CLOSEST
		ClosestHZBOutput_0[OutputPixelPos] = RoundUpF16(MaxDeviceZ);
#endif

  // ... batching
}

Параметр DIM_MIP_LEVEL_COUNT определяет, сколько mip-уровней шейдер генерирует за один dispatch. Поскольку группа содержит 8×8 = 64 потоков, максимальное количество уровней, которое можно обработать за один проход — четыре:

mip

активных потоков

mip 0

8×8

mip 1

4×4

mip 2

2×2

mip 3

1×1

Работа шейдера начинается с вычисления DispatchThreadId.

Если DIM_MIP_LEVEL_COUNT == 1, координата потока внутри группы (GroupThreadId) вычисляется напрямую из SV_GroupIndex. В этом случае DispatchThreadId фактически совпадает с тем, что вернула бы семантика SV_DispatchThreadID.

Если же используется батчинг mip-уровней, координата потока вычисляется иначе — с помощью функции InitialTilePixelPositionForReduction2x2. Эта функция определяет порядок обхода пикселей внутри тайла и играет ключевую роль в эффективной реализации батчинга. Мы подробнее разберём её в конце.

В остальном эта часть шейдера довольно проста. Каждый поток семплирует квад 2×2 с помощью функции GatherRed, после чего вычисляет минимальное и максимальное значения глубины и записывает их в соответствующие текстуры.

Ниже пример того, как выглядит HZBFurthest mip0 (256×256), полученный из буфера глубины размером 512×512.

DepthBuffer 512×512 (D32S8_TYPELESS) слева и HZBFurthest 256×256 (R16_FLOAT) справа
DepthBuffer 512×512 (D32S8_TYPELESS) слева и HZBFurthest 256×256 (R16_FLOAT) справа

На изображении выше я немного схитрил и сразу взял буфер глубины с размером, кратным степени двойки. Что будет, если размер не кратен степени двойки — покажу чуть позже.

Примечание

Изображение экспортировано в PNG, поэтому оттенки могут немного отличаться от реальных значений глубины.

Округление float

При записи в ClosestHZBOutput Unreal использует специальную функцию:

float RoundUpF16(float DeviceZ)
{
	// ClosestDeviceZ needs to be rounded up to nearest fp16 to be conservative
    return f16tof32(f32tof16(DeviceZ) + 1);
}

HZB хранится в формате R16_FLOAT (fp16), тогда как исходный буфер глубины имеет формат D32S8_TYPELESS (fp32). Это означает, что при преобразовании float32 → float16 происходит квантование глубины. Если просто выполнить такое преобразование, значение может округлиться вниз.

Это может привести к тому, что в HZB будет записано значение глубины меньше реального. Поскольку HZB используется для проверок видимости объектов (например, при occlusion culling), такое занижение глубины может привести к ошибкам.

Чтобы этого избежать, Unreal:

  1. Конвертирует глубину в fp16 (получая её 16-битное представление),

  2. Увеличивает битовый паттерн на 1 (на 1 ULP),

  3. Конвертирует обратно в float32.

Поскольку для положительных чисел IEEE754 больший битовый паттерн соответствует большему значению, операция +1 даёт следующее representable значение fp16, то есть выполняет ceil в пространстве half-float.

Это гарантирует условие HZB_depth ≥ реальная глубина и тем самым сохраняет корректность HZB.

Функция GatherRed

Функция GatherRed семплирует квад 2×2 соседних текселей и возвращает значения в виде float4 — как раз то, что нужно для генерации HZB. Использование этой функции может быть эффективнее чем 4 ручных семпла, тем более по одному каналу.

GatherRed работает следующим образом: берет UV и смещает его в направлениях (-; +), (+; +), (+; -), (-; -), где каждое смещение соответствует половине текселя. Затем выполняется выборка текстуры по этим координатам, а полученные значения записываются в компоненты x, y, z, w результирующего float4.

Подробнее о функции можно прочитать в документации Microsoft: Gather4 (HLSL).

Ниже приведён пример того, какие тексели семплирует GatherRed для разных UV на текстуре размером 8×4:

Пример того, какие тексели семплирует GatherRed для разных UV.
Пример того, какие тексели семплирует GatherRed для разных UV.

Вычисление BufferUV и UV для GatherRed

Разберем почему BufferUV и UV вычисляются именно так:

float2 BufferUV = (DispatchThreadId + 0.5) * DispatchThreadIdToBufferUV.xy + DispatchThreadIdToBufferUV.zw;
float2 UV = min(BufferUV + float2(-0.25f, -0.25f) * InvSize, InputViewportMaxBound - InvSize);
float4 DeviceZ = ParentTextureMip.GatherRed(ParentTextureMipSampler, UV);

Рассмотрим текстуру 8x4, из которой мы хотим создать HZB mip0. Мы хотим вычислять BufferUV так, чтобы он находился в пересечении 4х текселей, как показано на рисунке ниже:

Для этого используется значение DispatchThreadIdToBufferUV, которое трансформирует индекс потока в нужный нам BufferUV:

FVector4f DispatchThreadIdToBufferUV;
DispatchThreadIdToBufferUV.X = 2.0f / float(SrcSize.X);
DispatchThreadIdToBufferUV.Y = 2.0f / float(SrcSize.Y);
DispatchThreadIdToBufferUV.Z = ViewRect.Min.X / float(SrcSize.X);
DispatchThreadIdToBufferUV.W = ViewRect.Min.Y / float(SrcSize.Y);

Здесь SrcSize — размер исходной текстуры. На картинке выше SrcSize = (8, 4).

  • Компоненты Z и W задают смещение и заполняются только для генерации нулевого mip; для остальных mip-уровней Z = W = 0.

  • Если бы мы просто умножали (DispatchThreadId + 0.5) на 1 / SrcSize, UV указывал бы в центр каждого отдельного текселя. Умножение на 2 смещает UV в центр квада 2×2, обеспечивая, что BufferUV всегда указывает точно в пересечение четырёх текселей.

Далее BufferUV клампится, чтобы не выходить за пределы вьюпорта. Значения InputViewportMaxBound и InvSize вычисляются так:

FVector2f InputViewportMaxBound = FVector2f(
  float(ViewRect.Max.X - 0.5f) / float(SrcSize.X),
  float(ViewRect.Max.Y - 0.5f) / float(SrcSize.Y)
);

FVector2f InvSize = FVector2f(1.0f / SrcSize.X, 1.0f / SrcSize.Y);

Добавление float2(-0.25f, -0.25f) * InvSize смещает UV немного левее и выше центра пересечения текселей, что не меняет возвращаемое значение GatherRed.

Если вьюпорт совпадает с размером текстуры, то дополнительно вычислять UV необязательно и можно использовать значение BufferUV напрямую для функции GatherRed.

Clamp нулевого mip

Из-за особенностей выбора размера нулевого mip (округление до степени двойки) может возникнуть ситуация, когда некоторые потоки будут семплировать UV за пределами исходной текстуры — справа или снизу.

Например, если исходный буфер глубины имеет размер 840×840, то нулевой mip в HZB будет размером 512×512. В этом mip-уровне участок 420×420 пикселей покрывает исходный буфер, а оставшиеся области выходят за его границы.

При семплировании такие UV просто клампятся, и выбираются крайние пиксели текстуры, поскольку используется семплер с настройкой Point ClampEdge.

Пример такой ситуации на изображениях ниже:

DepthBuffer (1264x808)
DepthBuffer (1264x808)
HZBFurthest mip 0 (1024x512)
HZBFurthest mip 0 (1024x512)

Батчинг (генерация mip1 - mip3)

Мы разобрались с генерацией нулевого mip-уровня. Теперь посмотрим, как за один вызов Dispatch Unreal создаёт сразу до четырёх mip-уровней максимально эффективно.

Позже мы подробнее разберём функцию InitialTilePixelPositionForReduction2x2, поскольку именно она играет ключевую роль в эффективности алгоритма. А пока посмотрим, как в целом работает механизм батчинга:

#define GROUP_TILE_SIZE 8

groupshared float SharedMinDeviceZ[GROUP_TILE_SIZE * GROUP_TILE_SIZE];
groupshared float SharedMaxDeviceZ[GROUP_TILE_SIZE * GROUP_TILE_SIZE];

[numthreads(GROUP_TILE_SIZE, GROUP_TILE_SIZE, 1)]
void HZBBuildCS(
	uint2 GroupId : SV_GroupID,
	uint GroupThreadIndex : SV_GroupIndex)
{
  // ... mip0 generation
  
#if DIM_MIP_LEVEL_COUNT == 1
	{
		// NOP
	}
#else
	{
		SharedMinDeviceZ[GroupThreadIndex] = MinDeviceZ;
		SharedMaxDeviceZ[GroupThreadIndex] = MaxDeviceZ;
#if FEATURE_LEVEL >= FEATURE_LEVEL_SM6 || PLATFORM_SUPPORTS_SM6_0_WAVE_OPERATIONS
		const uint LaneCount = WaveGetLaneCount();
#else
		// Actual wave size is unknown, assume the worst
		const uint LaneCount = 0u;
#endif
	
		UNROLL
		for (uint MipLevel = 1; MipLevel < DIM_MIP_LEVEL_COUNT; ++MipLevel)
		{
			const uint TileSize = uint(GROUP_TILE_SIZE) >> MipLevel;
			const uint ReduceBankSize = TileSize * TileSize;
			
			// More waves than one wrote to LDS, need to sync.
			if ((ReduceBankSize << 2u) > LaneCount)
			{
				GroupMemoryBarrierWithGroupSync();
			}

			BRANCH
			if (GroupThreadIndex < ReduceBankSize)
			{
				float4 ParentMinDeviceZ;
				float4 ParentMaxDeviceZ;
				ParentMinDeviceZ[0] = MinDeviceZ;
				ParentMaxDeviceZ[0] = MaxDeviceZ;

				UNROLL
				for (uint i = 1; i < 4; i++)
				{
					uint LDSIndex = GroupThreadIndex + i * ReduceBankSize;
					ParentMinDeviceZ[i] = SharedMinDeviceZ[LDSIndex];
					ParentMaxDeviceZ[i] = SharedMaxDeviceZ[LDSIndex];
				}
				
				MinDeviceZ = min(min3(ParentMinDeviceZ.x, ParentMinDeviceZ.y, ParentMinDeviceZ.z), ParentMinDeviceZ.w);
				MaxDeviceZ = max(max3(ParentMaxDeviceZ.x, ParentMaxDeviceZ.y, ParentMaxDeviceZ.z), ParentMaxDeviceZ.w);
	
				OutputPixelPos = OutputPixelPos >> 1;
				OutputMipLevel(MipLevel, OutputPixelPos, MinDeviceZ, MaxDeviceZ);
				
				SharedMinDeviceZ[GroupThreadIndex] = MinDeviceZ;
				SharedMaxDeviceZ[GroupThreadIndex] = MaxDeviceZ;
			}
		}
	}
#endif

Если DIM_MIP_LEVEL_COUNT == 1, дополнительные mip-уровни генерировать не требуется, и шейдер завершает выполнение. В противном случае будет выполняться цикл, создающий следующие уровни.

Перед началом цикла в groupshared память записываются вычисленные ранее значения MinDeviceZ и MaxDeviceZ. Напомню, что GroupThreadIndex находится в диапазоне 0–63, поскольку группа состоит из 8×8 потоков.

Далее, если платформа и шейдер поддерживают Wave-операции, определяется количество потоков внутри одной wave — LaneCount. У GPU NVIDIA такая группа потоков называется warp и обычно содержит 32 потока, а у AMD используется термин wavefront, который обычно включает 64 потока. Это значение далее используется для оптимизации синхронизации потоков.

Уменьшение числа потоков

Перейдём к циклу. Напомню, что максимальное значение DIM_MIP_LEVEL_COUNT равно 4.

Переменная ReduceBankSize определяет число потоков, участвующих в генерации текущего mip.

Изначально имеется 8×8 = 64 потока, которые формируют mip0. Для последующих уровней требуется:

  • mip1 → 4×4 = 16 потоков

  • mip2 → 2×2 = 4 потока

  • mip3 → 1×1 = 1 поток

Это соответствует редукции квада 2×2 на каждом уровне.

Синхронизация потоков

Далее при необходимости выполняется синхронизация:

if ((ReduceBankSize << 2u) > LaneCount)
{
    GroupMemoryBarrierWithGroupSync();
}

Выражение ReduceBankSize << 2 эквивалентно ReduceBankSize * 4 и соответствует количеству значений, которые должны быть прочитаны из groupshared памяти для построения текущего mip-уровня.

Например, если ReduceBankSize = 16, то каждый поток читает квад 2×2, поэтому требуется 16 × 4 = 64 значения.

  • Если это число меньше либо равно LaneCount, значит все потоки выполняются в рамках одного wave, и дополнительная синхронизация не требуется.

  • Если значение больше LaneCount, значит задействовано несколько wave, поэтому необходимо дождаться завершения записи данных всеми потоками.

Вычисление следующего mip-уровня

После этого только первые ReduceBankSize потоков участвуют в генерации следующего mip.

Каждый из этих потоков вместо повторного семплирования текстуры читает четыре значения из groupshared памяти, выбирает среди них min и max, после чего записывает результат.

Индексы соседних элементов вычисляются так:

uint LDSIndex = GroupThreadIndex + i * ReduceBankSize;

Почему используется именно такая схема адресации, мы разберём в следующем разделе. Пока можно представить процесс следующим образом: на каждом уровне количество активных потоков уменьшается в два раза по каждой оси, и каждый поток выполняет редукцию 2×2 значений из groupshared памяти.

Создание mip1
Создание mip1. Задействовано 16 потоков.
Создание mip2. Задействовано 4 потока.
Создание mip2. Задействовано 4 потока.
Создание mip3. Задействован 1 поток.
Создание mip3. Задействован 1 поток.

Функция OutputMipLevel просто записывает полученные значения Min и Max в соответствующий mip-уровень. Выражение OutputPixelPos = OutputPixelPos >> 1 делит координаты пополам, поскольку каждый следующий mip имеет вдвое меньший размер по сравнению с предыдущим.

Функция OutMipLevel()
RWTexture2D<float> FurthestHZBOutput_0;
RWTexture2D<float> FurthestHZBOutput_1;
RWTexture2D<float> FurthestHZBOutput_2;
RWTexture2D<float> FurthestHZBOutput_3;

RWTexture2D<float> ClosestHZBOutput_0;
RWTexture2D<float> ClosestHZBOutput_1;
RWTexture2D<float> ClosestHZBOutput_2;
RWTexture2D<float> ClosestHZBOutput_3;

void OutputMipLevel(uint MipLevel, uint2 OutputPixelPos, float FurthestDeviceZ, float ClosestDeviceZ)
{
#if DIM_MIP_LEVEL_COUNT >= 2
	if (MipLevel == 1)
	{
#if DIM_FURTHEST
			FurthestHZBOutput_1[OutputPixelPos] = FurthestDeviceZ;
#endif
#if DIM_CLOSEST
			ClosestHZBOutput_1[OutputPixelPos] = RoundUpF16(ClosestDeviceZ);
#endif
	}
#endif
#if DIM_MIP_LEVEL_COUNT >= 3
	else if (MipLevel == 2)
	{
#if DIM_FURTHEST
			FurthestHZBOutput_2[OutputPixelPos] = FurthestDeviceZ;
#endif
#if DIM_CLOSEST
			ClosestHZBOutput_2[OutputPixelPos] = RoundUpF16(ClosestDeviceZ);
#endif
	}
#endif
#if DIM_MIP_LEVEL_COUNT >= 4
	else if (MipLevel == 3)
	{
#if DIM_FURTHEST
			FurthestHZBOutput_3[OutputPixelPos] = FurthestDeviceZ;
#endif
#if DIM_CLOSEST
			ClosestHZBOutput_3[OutputPixelPos] = RoundUpF16(ClosestDeviceZ);
#endif
	}		
#endif
}

Избегание bank конфликтов или Morton Z Curve

Почти всё готово — осталось самое интересное. Чтобы собрать пазл и понять, как именно считаются GroupThreadId и LDSIndex (см. код выше), нужно разобрать функцию InitialTilePixelPositionForReduction2x2.

Я предполагаю что вы уже знакомы с понятием bank конфликт. Вот небольшие статьи для напоминания:

Я буду считать, что один банк памяти имеет размер 4 байта, а всего существует 32 банка.

Сначала посмотрим, что будет, если шейдер использует простой расчёт GroupThreadId:

uint2 GroupThreadId = uint2(GroupThreadIndex % GROUP_TILE_SIZE, GroupThreadIndex / GROUP_TILE_SIZE);

В таком случае потоки обрабатывают тексели последовательно друг за другом. Выглядит это так:

Ячейки - это тексели (min/max из исходной текстуры).

Числа в ячейках - номер потока, который обрабатывает этот тексель.

Каждый поток записывает по своему номеру значения в groupshared память:

SharedMinDeviceZ[GroupThreadIndex] = MinDeviceZ;
SharedMaxDeviceZ[GroupThreadIndex] = MaxDeviceZ;

Поскольку мы работаем с float (4 байта), можно считать, что GroupThreadIndex и есть номер банка. Если GroupThreadIndex больше 32, то номер банка вычисляется как GroupThreadIndex % 32.

Теперь посмотрим на батчинг. Ниже пример того, как потоки читают данные из groupshared памяти для создания mip1.

Первый поток читает индексы 0, 1, 8, 9 → банки 0, 1, 8, 9

Второй поток читает индексы 2, 3, 10, 11 → банки 2, 3, 10, 11 — конфликтов нет.

Но затем какой-то поток должен прочитать данные по индексам 32, 33, 40, 41 → банки 0, 1, 8, 9 (из-за %32) — возникает конфликт.

Если прокрутить код дальше и посмотреть что будет при создании mip2, то можно убедиться что там также возникнут конфликты банков.

Вышеописанный способ вычисления GroupThreadId работает только без батчинга. Если батчинг используется, GroupThreadId вычисляется так:

uint2 GroupThreadId = InitialTilePixelPositionForReduction2x2(MAX_MIP_BATCH_SIZE - 1, GroupThreadIndex);
// Returns the pixel pos [[0; N[[^2 in a two dimensional tile size of N=2^TileSizeLog2, to
// store at a given SharedArrayId in [[0; N^2[[, so that a following recursive 2x2 pixel
// block reduction stays entirely LDS memory banks coherent.
uint2 InitialTilePixelPositionForReduction2x2(const uint TileSizeLog2, uint SharedArrayId)
{
	uint x = 0;
	uint y = 0;
	
	UNROLL
	for (uint i = 0; i < TileSizeLog2; i++)
	{
		const uint DestBitId = TileSizeLog2 - 1 - i;
		const uint DestBitMask = 1u << DestBitId;
		x |= DestBitMask & SignedRightShift(SharedArrayId, int(DestBitId) - int(i * 2 + 0));
		y |= DestBitMask & SignedRightShift(SharedArrayId, int(DestBitId) - int(i * 2 + 1));
	}

	return uint2(x, y);
}

Эта функция реализует Morton Ordering. Вместо последовательного распределения потоков по сетке (в нашем случае 8×8), она меняет порядок потоков так, чтобы доступ к groupshared памяти оставался согласованным с банками и избегал конфликтов. Ниже картинка с обновленным распределением потоков.

Поток 0 читает индексы 0, 16, 32, 40 → банки 0, 2, 0, 2.

Поток 1 читает индексы 1, 17, 33, 39 → банки 1, 3, 1, 3.

Поток 2 читает индексы 2, 18, 34, 50 → банки 2, 4, 2, 4.

Можно заметить, что 16 потоков полностью прочитают сетку 8×8 без конфликтов банков.

Вот некоторые ссылки для более глубокого изучения алгоритмов Morton Ordering и редукции:

Теперь, если вернуться к вычислению LDSIndex для батчинга:

for (uint i = 1; i < 4; i++)
{
    uint LDSIndex = GroupThreadIndex + i * ReduceBankSize;
    ParentMinDeviceZ[i] = SharedMinDeviceZ[LDSIndex];
    ParentMaxDeviceZ[i] = SharedMaxDeviceZ[LDSIndex];
}

То можно понять что все 4 текселя в рамках одного квада отличаются друг от друга по индексу на ReduceBankSize.

На этом всё — мы разобрали шейдер и ключевые приёмы, с помощью которых Unreal Engine строит HZB.

Тестирование

В дополнение, я провёл небольшое тестирование производительности алгоритма HZB, рассмотренного в этой статье, и для сравнения использовал более простой вариант генерации HZB без батчинга.

Алгоритм HZB для сравнения
int2 ClampScreenCoord(int2 PixelCoord, int2 Dimension)
{
    return clamp(PixelCoord, int2(0, 0), Dimension - int2(1, 1));
}

float LoadDepth(Texture2D<float> inputTex, int2 Location, int3 Dimension)
{
    int2 Position = ClampScreenCoord(Location, Dimension.xy);
    return inputTex.Load(int3(Position, Dimension.z));
}

void UpdateClosestDepth(Texture2D<float> inputTex, int2 Location, int3 LastMipDimension, inout float MinDepth)
{
    float Depth = LoadDepth(inputTex, Location, LastMipDimension);
    MinDepth = min(MinDepth, Depth);
}

[numthreads(GROUP_TILE_SIZE, GROUP_TILE_SIZE, 1)]
void CS_2(uint3 dtid : SV_DispatchThreadID)
{
    Texture2D<float> inputTexture = ResourceDescriptorHeap[gInputTextureIdx];
    RWTexture2D<float> outputTexture0 = ResourceDescriptorHeap[gOutputTexture0Idx];

    int2 RemappedPosition = int2(2.0 * dtid.xy);
    int3 LastMipDimension = int3(gInputTextureSize.x, gInputTextureSize.y, 0);
    
    float MinDepth = 1;
    UpdateClosestDepth(inputTexture, RemappedPosition + int2(0, 0), LastMipDimension, MinDepth);
    UpdateClosestDepth(inputTexture, RemappedPosition + int2(0, 1), LastMipDimension, MinDepth);
    UpdateClosestDepth(inputTexture, RemappedPosition + int2(1, 0), LastMipDimension, MinDepth);
    UpdateClosestDepth(inputTexture, RemappedPosition + int2(1, 1), LastMipDimension, MinDepth);

    bool IsWidthOdd = (LastMipDimension.x & 1) != 0;
    bool IsHeightOdd = (LastMipDimension.y & 1) != 0;
    
    if (IsWidthOdd)
    {
        UpdateClosestDepth(inputTexture, RemappedPosition + int2(2, 0), LastMipDimension, MinDepth);
        UpdateClosestDepth(inputTexture, RemappedPosition + int2(2, 1), LastMipDimension, MinDepth);
    }
    
    if (IsHeightOdd)
    {
        UpdateClosestDepth(inputTexture, RemappedPosition + int2(0, 2), LastMipDimension, MinDepth);
        UpdateClosestDepth(inputTexture, RemappedPosition + int2(1, 2), LastMipDimension, MinDepth);
    }
    
    if (IsWidthOdd && IsHeightOdd)
    {
        UpdateClosestDepth(inputTexture, RemappedPosition + int2(2, 2), LastMipDimension, MinDepth);
    }

    outputTexture0[dtid.xy] = MinDepth;
}

HZB генерировался из буфера глубины с разрешением 1920×1080. Время замерялось с использованием GPU Timestamp счётчика.

Видеокарта

HZB Unreal Engine (мс)

Простой алгоритм HZB (мс)

RTX 2080 Ti

0.180

0.250

RTX 3080

0.015

0.200

RTX 4070 Ti

0.011

0.013

Это простой тест, проведённый на Full HD. Абсолютные значения совсем маленькие, хотя даже тут видна разница. Я уверен, что при генерации HZB из текстур с разрешением 2K и выше результаты будут ещё более показательными и интересными.