
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.h -
Engine/Source/Runtime/Core/Public/Microsoft/MicrosoftPlatformMath.h
Шейдер генерации HZB
Полный код шейдера из Unreal Engine располагается в файле: EngineShadersPrivateHZB.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.020 |
|
RTX 4070 Ti |
0.011 |
0.013 |
Это простой тест, проведённый на Full HD. Абсолютные значения совсем маленькие, хотя даже тут видна разница. Я уверен, что при генерации HZB из текстур с разрешением 2K и выше результаты будут ещё более показательными и интересными.
Автор: ArtemVetik
