Loading

Volumetric Light in Deferred Rendering

Technical
–

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

The system uses shadow map ray marching in the composite pass to produce screen-space light shafts. It integrates with the volumetric cloud system through cloud depth modulation, supports colored underwater light rays through dual shadow map testing, and includes a time-of-day color model with configurable parameters.

Rendering pipeline overview

Volumetric light is calculated in composite1.ps.hlsl after the deferred lighting pass resolves the lit scene and cloud depth. Linear cloud depth from deferred1 feeds into the light shaft shader as an attenuation factor, and the accumulated light is additively blended into the scene before tonemapping.

flowchart TD
    subgraph Deferred["Deferred Pass 1"]
        D1["Deferred Lighting + Atmospheric Fog"]
        D2["Volumetric Clouds"]
        D3["Output colortex5.a\nCloud Linear Depth"]
    end

    subgraph Composite1["Composite Pass 1"]
        C1A["Read vlFactor from colortex5.a"]
        C1B["Underwater Effects"]
        C1C["VL Ray March\nShadow Map Sampling"]
        C1D["Additive Blend\nsceneColor += vl"]
    end

    subgraph Composite5["Composite Pass 5"]
        C5["Tonemapping + Color Grading"]
    end

    D1 --> D2 --> D3
    D3 -.->|"vlFactor"| C1A
    C1A --> C1B --> C1C --> C1D
    Composite1 --> Composite5

Shadow map ray marching

The algorithm casts a ray from the camera toward each screen pixel, stepping through the scene and sampling the shadow map at each position. If a sample is lit (not occluded in the shadow map), it adds to the accumulated volumetric light, which is tinted and blended into the final color.

flowchart LR
    A["Screen Pixel"] --> B["Reconstruct World Position"]
    B --> C["Compute Ray: camera to world pos"]
    C --> D{"Sun Elevation Gate\npassed?"}
    D -->|No| E["Return black"]
    D -->|Yes| F["March along ray"]
    F --> G["Sample shadow map\nat each step"]
    G --> H["Accumulate lit samples"]
    H --> I["Apply color + modulation"]
    I --> J["Additive blend to scene"]

Sun elevation gating

Before ray marching begins, the system checks whether the sun is high enough above the horizon to produce visible light shafts. This avoids shadow map artifacts that appear at grazing angles where depth precision is limited:

float vlTime = saturate((abs(SdotU) - VL_SUN_DEADZONE) / VL_SUN_FADE_RANGE);
if (vlTime <= 0.0)
    return float3(0.0, 0.0, 0.0);

The term SdotU measures the dot product between the sun direction and the up vector. When |SdotU| falls below the deadzone threshold (0.05), volumetric light is disabled. Between the deadzone and the fade boundary (0.05 to 0.25), light intensity scales linearly to full strength, creating smooth transitions during dawn and dusk.

Ray marching loop

Each ray is divided into evenly spaced steps between the camera and the fragment’s world position. Step counts depend on quality level and time of day:

QualityDay SamplesNight Samples
1 (Low)126
2 (Medium)2010
3 (High)3015
4 (Ultra)5030

An Interleaved Gradient Noise dither offsets the starting position per pixel, breaking up banding patterns before temporal anti-aliasing resolves the frame:

float3 rayStep = (rayDir * maxDist) / float(sampleCount);
float3 currentPos = cameraPos + rayStep * dither; // Staggered start

for (int i = 0; i < sampleCount; i++)
{
    float3 shadowUV = WorldToShadowUV(currentPos, shadowView, shadowProj);

    if (IsValidShadowUV(shadowUV))
    {
        float shadow0 = SampleShadowMap(shadowUV, currentPos, ...);
        vlSample = float3(shadow0, shadow0, shadow0);
    }

    // Weight: far samples contribute more than near samples
    float percentComplete = float(i + 1) / float(sampleCount);
    float sampleMult = lerp(percentComplete * 3.0, 1.0, vlSceneIntensity);
    sampleMult /= float(sampleCount);

    volumetricLight += float4(vlSample, 0.0) * sampleMult;
    currentPos += rayStep;
}

Samples are weighted by their position along the ray. Without cloud modulation, distant samples receive up to 3x the weight of nearby samples to emphasize distant light shafts. When cloud modulation is active, weighting becomes uniform. Samples within five world units of the camera are attenuated to prevent pop-in right in front of the view.

Directional modulation

Accumulated light is modulated by two directional factors before coloring:

View-to-light alignment (VdotL): A linear remap (VdotL + 1) * 0.5 concentrates light intensity when looking toward the sun and reduces it when looking away, while keeping shafts visible across wide viewing angles.

Vertical attenuation (VdotU): Looking directly upward scales down volumetric light, avoiding excess brightness against an already bright sky. The attenuation curve blends between full and partial reduction depending on scene intensity.

Time-of-day color transitions

Light shaft color shifts across the day and night cycle based on sunAngle and sunVisibility:

At noon, shafts adopt a cool blue-white tone (VL_NOON_COLOR = float3(0.4, 0.75, 1.3)) matching the sky. Peak noon reduces intensity to 12.5% because uniform ambient sky brightness makes strong shafts look unnatural.

During sunrise and sunset, light shafts shift to warm orange and gold via pow(float3(0.62, 0.39, 0.24), 1.5 + invNoonFactor) scaled by VL_SUNSET_COLOR_MULT (5.5). The power curve enriches warm hues as the sun approaches the horizon, when light shafts are visually prominent.

At night, shafts use a faint blue (VL_NIGHT_COLOR = float3(0.05, 0.08, 0.16)) at 15% intensity to produce subtle moonlit rays.

Day and night palettes blend through sunVisibility^2 for smooth twilight transitions:

float3 sunsetVLColor = pow(float3(0.62, 0.39, 0.24),
                           float3(1.5 + invNoonFactor, ...)) * VL_SUNSET_COLOR_MULT;
float3 dayVLColor    = lerp(sunsetVLColor, VL_NOON_COLOR, noonFactor * noonFactor);
float3 vlColor       = lerp(VL_NIGHT_COLOR, dayVLColor, sunVisibility * sunVisibility);
Volumetric light at sunrise with warm golden shafts cutting through the atmosphere
Sunrise volumetric light with warm orange-gold color from the dynamic sunset calculation
Volumetric light at sunrise viewed from a mountain, showing long light shafts across the terrain
Sunrise light shafts viewed from a mountain, where the low sun angle produces the strongest VL intensity
Volumetric light at sunset viewed from a mountain with deep warm tones
Sunset volumetric light from a mountain vantage point, showing the deepened warm tones at low sun angles
Volumetric light at noon viewed from a plain, showing subtle blue-white shafts
Noon volumetric light on a plain, reduced to 12.5% intensity with cool blue-white coloring
Volumetric light from the moon at night with faint cool blue shafts
Nighttime moonlight shafts, using the subtle VL_NIGHT_COLOR with 15% intensity multiplier

Cloud depth integration

The volumetric cloud system (detailed in the companion cloud post) outputs a linear cloud depth value to colortex5.a during the deferred pass. The composite pass reads this as vlFactor to modulate light shaft intensity.

When vlFactor is 1.0, no cloud occludes the ray and light evaluates at normal strength. When a cloud lies between the camera and the sun, vlFactor drops below 1.0, attenuating the shaft so light does not shine through solid cloud masses:

// deferred1: cloud depth output
cloudLinearDepth = sqrt(lTracePos / renderDistance);
output.color1 = float4(0.0, 0.0, 0.0, cloudLinearDepth);

// composite1: read and apply
float vlFactor = colortex5.Sample(sampler1, input.TexCoord).a;

The cloud system writes depth, and the volumetric light pass reads it. Because the composite pass does not need to re-evaluate cloud density, the passes remain decoupled.

Underwater volumetric light

Submerging the camera switches the shader to an underwater path that simulates colored light rays passing through the water surface:

Dual shadow map testing

Colored underwater rays rely on testing two shadow maps:

  • shadowtex0 stores depth for all geometry including translucent water surfaces
  • shadowtex1 stores depth for opaque geometry only

When a ray sample is occluded by shadowtex0 but remains unoccluded in shadowtex1, the light is passing through water. The shader samples shadowcolor1 to obtain the surface’s transmission color:

if (eyeInWater == EYE_IN_WATER)
{
    float shadow0 = SampleShadowForVL(shadowUV, shadowTex0, samp);

    if (shadow0 < 0.5) // Occluded by water surface
    {
        float shadow1 = SampleShadowForVL(shadowUV, shadowTex1, samp);
        if (shadow1 > 0.5) // But NOT by opaque geometry
        {
            // Light passes through water: sample tinted color
            float3 colSample = shadowColTex.Sample(samp, shadowUV.xy).rgb * 4.0;
            colSample *= colSample;  // Square for intensity
            colSample *= vlColorReducer;  // Normalize to prevent double-tinting
            vlSample = colSample;
        }
    }
}

Underwater adaptations

Underwater ray marching adjusts several parameters:

  • Maximum march distance is limited to 80 world units, reflecting rapid light loss in water
  • Scene intensity is set to 1.0, bypassing cloud attenuation so colored shafts remain visible
  • Shadow sampling uses binary comparisons (hard 0 or 1) rather than filtered tests, sharpening shaft boundaries underwater
  • Sample weights are scaled by 0.85 to moderate underwater brightness

After marching, underwater attenuation scales the accumulated light using the water fog profile:

float3 underwaterMult = UNDERWATER_MULT_DAY * lerp(UNDERWATER_NIGHT_MULT, 1.0, sunVis2);
float3 uwMult071 = underwaterMult * 0.71;
vl *= uwMult071 * uwMult071; // Squared attenuation

Noise texture

A repeating, bilinear-filtered texture (noise.png) provides supplementary noise patterns that break up regularity in underwater shafts:

The noise.png texture used for underwater volumetric light sampling and dithering
The noise.png texture with bilinear filtering enabled, used for underwater VL noise sampling
Underwater volumetric light showing colored light rays filtering through the water surface via dual shadow map testing

Configuration options

All parameters are configured in settings.hlsl using compile-time defines with slider ranges matching the Iris/OptiFine specification.

Core parameters

ParameterDefaultRangePurpose
LIGHTSHAFT_QUALI41, 2, 3, 4Quality level (sample count)
VL_STRENGTH0.50.25 to 1.5Overall VL intensity
VL_SUNSET_COLOR_MULT5.52.0 to 8.0Sunrise/sunset color intensity
VL_SUN_DEADZONE0.050.05 to 0.25Min sun elevation for VL
VL_SUN_FADE_RANGE0.200.10 to 0.30Elevation range for VL ramp

Underwater parameters

ParameterDefaultRangePurpose
WATER_VL_STRENGTH1.00.0 to 2.0Underwater VL intensity
WATER_FOG_MULT10025 to 300Underwater fog density (%)
WATER_UW_FOG_DISTANCE48.016.0 to 96.0Base fog distance in blocks
UNDERWATER_NIGHT_MULT0.60.3 to 1.0Night underwater brightness

Color constants

// Noon: cool blue-white
VL_NOON_COLOR  = float3(0.4, 0.75, 1.3);

// Night: subtle cool blue
VL_NIGHT_COLOR = float3(0.05, 0.08, 0.16);
VL_NIGHT_MULT  = 0.15;

// Sunset: dynamically computed
// pow(float3(0.62, 0.39, 0.24), 1.5 + invNoonFactor) * VL_SUNSET_COLOR_MULT

Results

0:00
/0:00
Full day-night cycle demonstrating the VL color transitions from warm sunrise through cool noon to faint moonlit night

Design decisions

Shadow map reuse

The shader samples existing shadow maps (shadowtex0, shadowtex1, shadowcolor1) instead of allocating a separate light volume or running radial blur passes. This avoids additional render targets and passes, within the coverage boundaries of the shadow frustum.

Directional and elevation tuning

The sun elevation cutoff, noon intensity reduction, and view-angle alignment are parameterized to balance atmospheric scattering with visibility. Sunset multipliers are boosted to emphasize horizon shafts, while midday shafts are scaled back to match high-angle sunlight.

Inter-pass communication

The cloud pass writes linear depth to a render target, and the light shaft shader reads it directly. Underwater mode alters ray marching behavior via parameter adjustments rather than branching inside the cloud shader, keeping atmospheric stages decoupled.

Time-based budget allocation

Sample counts and intensities vary with sun position. Sunrise and sunset use full sample counts and warmer palettes where rays are prominent, while midday and night use lower sample counts and reduced brightness.