Loading

Bloom with Hardware Mipmap Generation

Technical
–

This post covers the bloom system built for a custom DirectX 12 deferred rendering engine. The engine runs an Iris-compatible shader pipeline, and the bloom implementation is part of the EnigmaDefault ShaderBundle modeled after the Complementary Reimagined visual style.

The system builds a 7-level tile atlas by sampling hardware mipmaps of the HDR scene texture with a 7x7 Gaussian kernel, then composites the result back onto the scene before tonemapping. A compute shader generates the mipmap chain one level per dispatch, using RWTexture2D UAV access to avoid SRV/UAV state conflicts. D12Texture stores per-mip UAV descriptors as persistent members, avoiding descriptor reallocations on every frame.

Rendering pipeline overview

The bloom system spans two composite passes and a compute mipmap generation step run between them. Mipmap generation runs automatically after each composite sub-pass for any render target with colortexNMipmapEnabled = true declared in the ShaderBundle.

flowchart TD
    subgraph Deferred["Deferred Pass"]
        DL["Deferred Lighting, colortex0 = HDR Scene"]
    end

    subgraph Composite["Composite Passes"]
        C1["Composite 1, Volumetric Light + Underwater"]
        MIP["Compute: Mipmap Generation, 10 Dispatch per mip chain"]
        C4["Composite 4, Bloom Tile Atlas Generation, 7x7 Gaussian x 7 LODs"]
        C5["Composite 5, Bloom Application + Tonemapping"]
    end

    DL --> C1
    C1 -->|"colortex0 written"| MIP
    MIP -->|"colortex0 mip 0-10 ready"| C4
    C4 -->|"colortex3 = tile atlas"| C5
    C5 -->|"colortex0 = LDR output"| Final["Final Pass"]

Mipmap generation system

The bloom algorithm requires downsampled versions of the HDR scene texture (colortex0) across multiple resolutions. Rather than downsampling manually inside the bloom shader, the engine computes a full mipmap chain using a compute shader. This supplies pre-filtered data at each resolution level and keeps downsampling cost off the rasterizer.

ShaderBundle configuration

Mipmap generation is opt-in per render target. The ShaderBundle marks textures that need mipmaps with a directive in the shader source, following the Iris const bool convention:

// rt_formats.hlsl
const bool colortex0MipmapEnabled = true;

The engine’s PackRenderTargetDirectives parser scans shader files during bundle load and applies the flag to RenderTargetConfig. When enabled, the render target is allocated with a full mip chain (MipLevels = floor(log2(max(width, height))) + 1). A 1920x1080 texture gets 11 mip levels.

Compute shader

Each mip level is written by a single compute dispatch using an 8x8 thread group. The shader reads the source mip through an RWTexture2D load and writes to the destination mip through an RWTexture2D store, applying a 2x2 box filter:

[numthreads(8, 8, 1)]
void main(uint3 DTid : SV_DispatchThreadID)
{
    if (DTid.x >= g_dstWidth || DTid.y >= g_dstHeight)
        return;

    RWTexture2D<float4> srcMip = ResourceDescriptorHeap[g_srcTextureIndex];
    RWTexture2D<float4> dstMip = ResourceDescriptorHeap[g_dstMipUavIndex];

    uint2 srcCoord = DTid.xy * 2;
    uint srcWidth  = g_dstWidth * 2;
    uint srcHeight = g_dstHeight * 2;

    float4 s00 = srcMip[min(srcCoord + uint2(0, 0), uint2(srcWidth - 1, srcHeight - 1))];
    float4 s10 = srcMip[min(srcCoord + uint2(1, 0), uint2(srcWidth - 1, srcHeight - 1))];
    float4 s01 = srcMip[min(srcCoord + uint2(0, 1), uint2(srcWidth - 1, srcHeight - 1))];
    float4 s11 = srcMip[min(srcCoord + uint2(1, 1), uint2(srcWidth - 1, srcHeight - 1))];

    dstMip[DTid.xy] = (s00 + s10 + s01 + s11) * 0.25;
}

Both source and destination are accessed through UAVs (RWTexture2D) instead of mixing SRV reads with UAV writes. In DX12, transitioning an entire resource into UNORDERED_ACCESS state means SRV reads (Texture2D.SampleLevel) fail because the texture is no longer in PIXEL_SHADER_RESOURCE state. Subresource barriers do not resolve this because the SRV descriptor covers all mip levels; keeping even one mip in UAV state invalidates full-resource SRV reads. Using RWTexture2D for both inputs and outputs sidesteps the issue by keeping the resource in UAV state throughout downsampling.

RenderDoc Event Browser showing 4 compute passes with 10 dispatches each for mipmap generation between composite sub-passes
RenderDoc Event Browser showing 4 compute passes (one after each composite sub-pass), each with 10 dispatches generating mip levels 1 through 10
RenderDoc stepping through the 10 compute dispatches of a single mipmap generation pass, showing progressive downsampling from 960x540 (mip 1) down to 1x1 (mip 10)

D3D12RenderSystem::CreateUAV

The engine provides a static factory for UAV creation that handles bindless index allocation and descriptor heap writes in one call:

static uint32_t CreateUAV(
    ID3D12Resource* resource,
    const D3D12_UNORDERED_ACCESS_VIEW_DESC& desc
);

The caller constructs the D3D12_UNORDERED_ACCESS_VIEW_DESC (specifying format, dimension, and mip slice) and receives a bindless index. This keeps D3D12RenderSystem general while allowing individual resource classes to configure their own descriptors, mirroring the existing TransitionResource and UAVBarrier helpers.

Persistent UAV indices on D12Texture

Per-mip UAV descriptors are stored directly on D12Texture rather than allocated per frame in MipmapGenerator.

The original implementation allocated temporary UAV indices for each dispatch and freed them after the loop. That broke because CreateUnorderedAccessView writes immediately to the GPU descriptor heap on the CPU timeline, while the GPU reads descriptors later during command list execution. Freeing and reusing an index within the same frame overwrote the descriptor before the GPU could read it.

To fix this, D12Texture creates per-mip UAV descriptors lazily on the first GenerateMips() call and keeps them until destruction. MipmapGenerator queries texture->GetMipUavIndex(mip) directly without allocating memory or descriptor slots per frame.

flowchart LR
    subgraph D12Texture["D12Texture"]
        MUI["m_mipUavIndices: [0] [1] [2] ... [10]"]
        GM["GenerateMips()"]
        CMUD["createMipUavDescriptors(), Lazy init, called once"]
        FREE["~D12Texture(), freeMipUavDescriptors()"]
    end

    subgraph MipmapGenerator["MipmapGenerator (stateless)"]
        LOOP["Per-mip dispatch loop"]
        GET["texture->GetMipUavIndex(mip)"]
    end

    GM -->|"first call"| CMUD
    CMUD -->|"D3D12RenderSystem::CreateUAV()"| MUI
    GM --> LOOP
    LOOP --> GET
    GET --> MUI

Bloom algorithm

The bloom pass builds a 7-level tile atlas based on Complementary Reimagined. Instead of using a ping-pong downsampling and upsampling chain, all 7 LOD levels are written into a single atlas in colortex3 (RGBA8).

Tile atlas layout

Each LOD level occupies a dedicated region of the atlas in normalized UV space. The layout targets 1920x1080 and rescales for other resolutions through GetBloomRescale():

+---------------------------------------+
| LOD 2 (1/4 res)                       |
| offset: (0.0, 0.0)                    |
+-------------------+-------------------+
| LOD 3 (1/8)       | LOD 4 (1/16)      |
| (0.0, 0.26)       | (0.135, 0.26)     |
+----------+--------+--------+----------+
| LOD 5    | LOD 6  | LOD 7  | LOD 8    |
| (1/32)   | (1/64) | (1/128)| (1/256)  |
+----------+--------+--------+----------+

Gaussian blur with hardware mipmaps

The BloomTile() function samples colortex0 at the corresponding hardware mip level using a 7x7 Gaussian kernel (Pascal’s triangle row 6, weights: 1, 6, 15, 20, 15, 6, 1). The hardware mipmap provides downsampling, and the Gaussian kernel applies spatial blur on top:

float3 BloomTile(float lod, float2 offset, float2 scaledCoord)
{
    float scale = exp2(lod);
    // ... tile boundary check ...

    for (int i = -3; i <= 3; i++)
        for (int j = -3; j <= 3; j++)
        {
            float  wg         = bloomWeight[i + 3] * bloomWeight[j + 3];
            float2 bloomCoord = (scaledCoord - offset + float2(i,j) * pixelSize) * scale;
            bloom += colortex0.SampleLevel(sampler0, bloomCoord, lod).rgb * wg;
        }
    bloom /= 4096.0; // 64 * 64

    return pow(max(bloom / 128.0, 0.0), 0.25); // Gamma encode
}

At mip level lod, each texel spans exp2(lod) original pixels. Kernel offsets of one screen pixel scaled by scale equal exactly one mip texel, producing a 7-texel blur at each resolution.

Gamma encoding for HDR in RGBA8

The bloom atlas (colortex3) uses an RGBA8 format to reduce memory bandwidth. To store HDR scene data in 8-bit channels, a simple gamma curve compresses and restores the dynamic range:

StageFormulaPurpose
Encode (composite4)pow(x / 128.0, 0.25)Compress HDR to [0,1] for RGBA8
Decode (composite5)x^4 * 128.0Restore original HDR range

The 4th-root curve distributes more precision to darker values where quantization is visible, and dividing by 128 normalizes typical HDR highlights.

Bloom pass pipeline

Composite 4: Bloom generation

This pass samples the HDR scene from colortex0 with hardware mipmaps and packs the 7-LOD tile atlas into colortex3. Higher LOD levels use lower blend weights to prevent excessive blurring at large radii:

blur += BloomTile(2.0, float2(0.0, 0.0),       scaledCoord);        // 1.0x
blur += BloomTile(3.0, float2(0.0, 0.26),       scaledCoord);        // 1.0x
blur += BloomTile(4.0, float2(0.135, 0.26),     scaledCoord);        // 1.0x
blur += BloomTile(5.0, float2(0.2075, 0.26),    scaledCoord) * 0.8;  // reduced
blur += BloomTile(6.0, float2(0.135, 0.3325),   scaledCoord) * 0.8;
blur += BloomTile(7.0, float2(0.160625, 0.3325), scaledCoord) * 0.6;
blur += BloomTile(8.0, float2(0.1784375,0.3325), scaledCoord) * 0.4;

Composite 5: Bloom application and tonemapping

This pass reads both the HDR scene (colortex0) and the bloom atlas (colortex3), combines them before tonemapping, and outputs the final LDR result:

  1. Read the HDR scene color
  2. Average all 7 LOD tiles (* 0.14) in DoBloom() and blend with lerp(color, blur, bloomStrength)
  3. Apply Lottes 2016 tonemapping from HDR to LDR
  4. Adjust color saturation and vibrance
  5. Output LDR color to colortex0

Bloom strength includes a darkness adjustment (+ 0.2 * darknessFactor) that increases glow intensity in dark scenes, matching human contrast sensitivity in low light.

Bloom configuration

Bloom settings are declared in settings.hlsl as compile-time defines with slider ranges matching the Iris/OptiFine format:

ParameterDefaultRangePurpose
BLOOM_ENABLED1-1, 1Enable or disable bloom entirely
BLOOM_STRENGTH0.520.027 to 10.00Bloom intensity (lerp factor)

The colortex0MipmapEnabled directive in rt_formats.hlsl controls whether the engine allocates hardware mipmaps for the scene texture. If disabled, sampling falls back to mip 0.

Results

Scene with bloom disabled, showing the raw HDR tonemapped output without glow
Bloom at 32% strength, showing moderate glow on water reflections and the sky
Bloom at default 52% strength

Design decisions

Texture resource ownership

Per-mip UAV descriptors live directly on D12Texture as persistent members, created lazily on first use and freed in the destructor. MipmapGenerator remains a stateless utility with no per-frame allocation. This matches the ownership model used for SRV bindless indices on D12Resource.

Generic engine interfaces

D3D12RenderSystem::CreateUAV accepts a raw D3D12_UNORDERED_ACCESS_VIEW_DESC without hardcoding texture dimensions or mip slices, and D3D12RenderSystem::UAVBarrier exposes the underlying synchronization primitive. Each resource type constructs its own descriptor and passes it to the common API, keeping texture-specific details out of the core render system.

ShaderBundle-driven configuration

Mipmap generation is enabled per render target through the const bool colortexNMipmapEnabled directive in the shader source. The engine parses this during bundle loading and configures the target accordingly, allowing shader packs to request mipmaps without C++ changes.

Compute shader downsampling

The engine uses a dedicated compute shader rather than GenerateMips or CPU-side filtering. Using SM6.6 bindless RWTexture2D access avoids the resource state conflicts that occur when mixing SRV reads and UAV writes in DX12, while a 2x2 box filter produces identical results to hardware bilinear downsampling for power-of-two textures.