Loading

Volumetric Cloud in Deferred Rendering

Technical
–

This post covers the cloud rendering system built for a custom DirectX 12 deferred rendering engine. The engine implements an Iris-compatible shader pipeline (the modding framework behind Minecraft Java Edition shader packs), and the cloud system is part of the EnigmaDefault ShaderBundle modeled after the Complementary Reimagined aesthetic.

The engine supports two cloud rendering paths: a vanilla geometry cloud pipeline using CPU-side mesh generation based on Sodium’s design, and a volumetric cloud pipeline using screen-space ray marching in the deferred lighting pass. The EnigmaDefault ShaderBundle switches exclusively to the volumetric path, adding self-shadowing, height-gradient shading, and forward scattering.

Rendering pipeline architecture

The cloud system integrates into the engine’s multi-pass deferred rendering pipeline. Vanilla geometry clouds render during the GBuffer pass (gbuffers_clouds), while volumetric clouds are calculated in screen space during the first deferred lighting pass (deferred1). The volumetric pass writes cloud linear depth to modulate volumetric light shafts in subsequent composite passes.

flowchart TD
    subgraph GBuffer["GBuffer Pass"]
        GT["gbuffers_terrain"]
        GC["gbuffers_clouds\nVanilla geometry clouds"]
        GW["gbuffers_water"]
    end

    subgraph Deferred["Deferred Pass 1"]
        DL["Deferred Lighting"]
        AF["Atmospheric Fog"]
        VC["Volumetric Clouds\nRay March in Screen Space"]
        VCO["colortex5.a\nCloud Linear Depth"]
    end

    subgraph Composite["Composite Passes"]
        C1["Composite 1\nVolumetric Light + God Rays"]
        C5["Composite 5\nTonemapping + Color Grading"]
    end

    GBuffer --> Deferred
    DL --> AF --> VC --> VCO
    Deferred --> Composite
    VCO -.->|"vlFactor modulation"| C1
    C1 --> C5

Vanilla cloud rendering

The vanilla cloud pipeline ports the approach from Sodium’s CloudRenderer. Clouds are generated on the CPU as vertex geometry, uploaded to a vertex buffer, and rasterized through the standard GBuffer pass.

Geometry generation

The cloud texture (clouds.png) is a 256x256 bitmap where each pixel represents a 12x12x4 block cell. The CPU reads this texture at load time and builds geometry using a spiral traversal algorithm that expands outward from the player’s position, ensuring cells closest to the camera are built first.

The traversal works in three steps:

  1. Center cell at the player’s grid position
  2. Diamond expansion from layer 1 out to the configured radius
  3. Corner fill for layers beyond the base radius to complete square coverage

Each visible cell produces either a single horizontal quad (Fast mode) or a 12x12x4 box with up to six exterior faces and interior backfaces (Fancy mode). A face culling step removes faces that cannot be seen from the current camera orientation, keeping the vertex buffer small.

The coordinate system translates from Minecraft conventions (Y-up) to the engine’s Z-up system:

MinecraftEngineDescription
+X (East)+Y (Left)Horizontal axis
+Y (Up)+Z (Up)Vertical axis
+Z (South)+X (Forward)Depth axis

Each face receives a directional brightness multiplier to approximate ambient occlusion: top faces receive 1.0, bottom faces 0.7, and side faces 0.8 and 0.9.

Visual output

Vanilla style clouds during daytime, showing the CPU-generated geometry with directional face brightness
Vanilla geometry clouds during daytime, rendered in Fancy mode with per-face brightness
Vanilla style clouds at night with moonlight illumination
Vanilla clouds at night, showing the moonlit color tinting applied by the CPU
Vanilla style clouds at midnight with minimal ambient light
Vanilla clouds at midnight, demonstrating the darkened ambient with subtle blue tint

Volumetric cloud ray marching

The EnigmaDefault ShaderBundle replaces geometry clouds with screen-space ray marched volumetric clouds. The vanilla gbuffers_clouds pixel shader discards fragments, and the volumetric system runs in deferred1.ps.hlsl.

This implementation ports Complementary Reimagined’s cloud algorithm (reimaginedClouds.glsl) from GLSL to HLSL, adjusting for the engine’s coordinate conventions.

Ray marching algorithm

For each screen pixel, the shader casts a ray from the camera into the scene. If the ray intersects the cloud layer (a horizontal slab defined by CLOUD_ALT1 ± CLOUD_STRETCH), it marches along the ray inside that slab, sampling cloud density at each step.

flowchart LR
    A[Screen Pixel] --> B[Cast Ray from Camera]
    B --> C{Intersects Cloud Slab?}
    C -->|No| D[Return transparent]
    C -->|Yes| E[Compute near/far distances]
    E --> F[March: sample density at each step]
    F --> G{Cloud Hit?}
    G -->|No| F
    G -->|Yes| H[Compute Shading]
    H --> I[Self-Shadow Sampling]
    I --> J[Height Gradient + Scattering]
    J --> K[Output color + opacity]

The slab intersection determines entry and exit distances along the view ray:

float cloudUpper = float(cloudAltitude) + cloudStretch;
float cloudLower = float(cloudAltitude) - cloudStretch;

float distToUpper = (cloudUpper - camPos.z) / safeZ;
float distToLower = (cloudLower - camPos.z) / safeZ;

float nearDist = max(min(distToUpper, distToLower), 0.0);
float farDist  = min(max(distToUpper, distToLower), renderDistance);

Sample counts scale with quality settings: 16 samples on low, 32 on medium, and 48 on high. An Interleaved Gradient Noise dither staggers sample positions across adjacent pixels, which temporal anti-aliasing then resolves into a clean image.

Texture-driven cloud shape

Instead of generating cloud volumes from 3D Perlin or Worley noise, this system samples density from a 2D texture. The cloud-water.png texture (256x256) stores density data in its blue channel, lowering sampling overhead while maintaining stylized shapes.

The cloud-water.png texture used for cloud density sampling, blue channel contains the cloud shape data
The cloud-water.png texture, where the blue channel encodes cloud density patterns

The sampling pipeline proceeds in four stages:

  1. Wind offset: World position is shifted by a wind vector driven by worldTime, matching the in-game clock so cloud speed scales with time controls.
  2. Coordinate mapping: The animated position is scaled by CLOUD_NARROWNESS (0.07) and mapped into texture space with GetRoundedCloudCoord, which applies smoothstep rounding for soft edges.
  3. Height masking: Raw texture density is multiplied by a height falloff that peaks at cloud altitude and falls to zero at the slab edges.
  4. Thresholding: The masked density is raised to the 8th power (Pow2(Pow2(Pow2(noise)))) to produce defined cloud boundaries. Values below 0.001 are discarded.
bool GetCloudNoise(float3 tracePos, inout float3 tracePosM, int cloudAltitude)
{
    tracePosM = ModifyTracePos(tracePos);

    float2    coord         = GetRoundedCloudCoord(tracePosM.xy, CLOUD_ROUNDNESS_SAMPLE);
    Texture2D cloudWaterTex = customImage3;
    float     noise         = cloudWaterTex.Sample(sampler0, coord).b;

    float heightFactor = abs(tracePos.z - float(cloudAltitude));
    float heightMask   = saturate(1.0 - heightFactor * CLOUD_NARROWNESS);
    noise *= heightMask;

    // 8th-power threshold: x^8 via three nested squares
    float threshold = Pow2(Pow2(Pow2(noise)));
    return threshold > 0.001;
}
Close-up view showing the texture sampling points visible as noise patterns on the cloud surface
Close-up of volumetric clouds showing the texture-driven density sampling pattern

Self-shadowing

At quality level 2 and above, the shader computes self-shadowing by sampling density along the light direction from each cloud hit. Two additional texture samples are taken at increasing offsets along the light vector, attenuating illumination where light passes through cloud mass:

#if CLOUD_QUALITY >= 2
{
    float shadowStep = CLOUD_SHADOW_STEP; // 1.0 world units

    [unroll]
    for (int s = 1; s <= 2; s++)
    {
        float3 shadowWorldPos = tracePos + lightDir * (shadowStep * float(s));
        float3 shadowPosM     = ModifyTracePos(shadowWorldPos);
        float2 shadowCoord    = GetRoundedCloudCoord(shadowPosM.xy, CLOUD_ROUNDNESS_SHADOW);
        float  shadowNoise    = cloudWaterTex.Sample(sampler0, shadowCoord).b;
        light -= shadowNoise * cloudShadingM * CLOUD_SHADOW_STRENGTH;
    }
    light = max(light, CLOUD_SHADOW_MIN); // Prevent full black
}
#endif

Shadow sampling uses a broader roundness parameter (0.35 versus 0.125 for shape) to soften shadow boundaries. The weight cloudShadingM depends on height (1 - heightGrad^2), casting deeper shadows near the base of the clouds than at the top.

Color and lighting model

The cloud color model balances ambient and direct illumination using three components:

Height gradient shading maps the vertical position within the cloud slab from 0.0 at the bottom to 1.0 at the top, shaped by an exponent (CLOUD_SHADING_POWER = 2.5). This produces naturally darker cloud undersides.

Forward scattering applies a half-Lambert transform to the view-to-sun dot product (VdotS * 0.5 + 0.5), brightening cloud edges when looking toward the sun.

Time-of-day transitions blend warm daytime sunlight with cool blue moonlight at night, modulated by sunVisibility^2 for continuous changes across dusk and dawn.

The combined color evaluates as:

float3 cloudColor = cloudAmbient * 0.95 * (1.0 - 0.35 * cloudShading)
                  + cloudLight * (0.1 + cloudShading);

A distance fog term blends the final cloud color into the sky near the edge of the render distance to prevent abrupt clipping.

Cloud visuals across conditions

View from inside the volumetric cloud layer showing the density field from within
View from inside the cloud layer, showing the ray marched density field from within
Volumetric clouds seen from above, showing the cloud top illumination and height gradient
Volumetric clouds seen from above, with bright tops and self-shadowed undersides
Volumetric clouds at night seen from above, with moonlight illumination
Nighttime volumetric clouds from above, lit by moonlight with cool blue ambient tones

Configuration options

Cloud parameters are exposed in settings.hlsl as compile-time defines with slider ranges matching the Iris/OptiFine specification:

ParameterDefaultRangePurpose
CLOUD_QUALITY31, 2, 3Ray march sample count (16/32/48)
CLOUD_ALT1192-96 to 800Primary cloud layer altitude
CLOUD_STRETCH4.2fixedCloud slab vertical thickness
CLOUD_SPEED_MULT1000 to 900Wind animation speed multiplier
CLOUD_NARROWNESS0.07fixedHeight density falloff rate
CLOUD_SHADING_POWER2.51.0 to 3.5Height gradient curve exponent
CLOUD_SHADOW_STRENGTH0.350.1 to 0.7Self-shadow attenuation per sample
CLOUD_SHADOW_MIN0.30.1 to 0.5Minimum light (prevents full black)
CLOUD_R/G/B10025 to 300RGB color tint (percentage)
DOUBLE_REIM_CLOUDS00, 1Enable dual cloud layers

Night lighting uses independent ambient and moonlight settings:

#define CLOUD_NIGHT_AMBIENT float3(0.09, 0.12, 0.17)
#define CLOUD_NIGHT_AMBIENT_MULT 1.4
#define CLOUD_NIGHT_LIGHT float3(0.11, 0.14, 0.20)
#define CLOUD_NIGHT_LIGHT_MULT 0.9

Volumetric light integration

The volumetric cloud pass writes linear cloud depth to colortex5.a. The composite pass uses this depth value to modulate god rays: if a god ray sample lies behind a cloud, vlFactor attenuates its brightness, preventing rays from passing through cloud masses.

// In deferred1: output cloud depth for VL modulation
cloudLinearDepth = sqrt(lTracePos / renderDistance);

This couples the two atmospheric passes without requiring the volumetric light shader to re-evaluate cloud density.

Results

Volumetric clouds with Reimagined stylized rendering during daytime, showing self-shadowing and height gradient shading
Bird’s eye view of the volumetric cloud layer, demonstrating the texture-driven cloud shape and distance fog falloff

Design decisions

Dual-pipeline structure

The engine maintains vanilla geometry clouds and volumetric ray marched clouds as separate systems. The vanilla pipeline provides a lightweight fallback, while the EnigmaDefault ShaderBundle disables it via discard and runs the screen-space pass.

2D texture sampling over 3D noise

Cloud shapes use 2D texture lookups in cloud-water.png rather than 3D noise volumes. Paired with the 8th-power threshold for clean edges, this cuts sampling cost while height gradients and directional shadow steps add volumetric depth.

Compile-time parameter controls

Artistic parameters live in settings.hlsl as compile-time defines with slider ranges. Settings like altitude, thickness, speed, and shadow intensity can be adjusted without rewriting shader code, while unused quality branches are pruned during compilation.

Inter-pass depth coupling

Rather than isolating clouds as a self-contained effect, the pass outputs linear depth to modulate volumetric light shafts and respects terrain occlusion per sample. This handles occlusions between mountains, clouds, and god rays without duplicate density sampling.