
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:
Engine/Source/Runtime/Core/Public/GenericPlatform/GenericPlatformMath.hEngine/Source/Runtime/Core/Public/Microsoft/MicrosoftPlatformMath.h
Шейдер генерации 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.

На изображении выше я немного схитрил и сразу взял буфер глубины с размером, кратным степени двойки. Что будет, если размер не кратен степени двойки — покажу чуть позже.
Примечание
Изображение экспортировано в 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:
Конвертирует глубину в fp16 (получая её 16-битное представление),
Увеличивает битовый паттерн на 1 (на 1 ULP),
Конвертирует обратно в 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:

Вычисление 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.
Пример такой ситуации на изображениях ниже:


Батчинг (генерация 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 памяти.



Функция 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 и выше результаты будут ещё более показательными и интересными.
