Volumetric Cloud in Deferred Rendering
This post walks through the cloud rendering system I 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, which targets a Complementary Reimagined visual style.
The system features two distinct cloud rendering approaches: a vanilla geometry cloud pipeline driven by CPU-side mesh generation (ported from Sodium’s architecture), and a volumetric cloud pipeline using screen-space ray marching in the deferred lighting pass. The volumetric approach replaces the geometry clouds entirely in the EnigmaDefault ShaderBundle, producing clouds with self-shadowing, height-based shading gradients, and forward scattering.
Rendering Pipeline Architecture
The cloud system integrates into the engine’s multi-pass deferred rendering pipeline. Vanilla geometry clouds are rendered during the GBuffer pass (gbuffers_clouds), while volumetric clouds are computed entirely in screen space during the first deferred lighting pass (deferred1). The volumetric cloud depth output feeds into the composite pass for volumetric light (god ray) modulation.
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 is a faithful port of Sodium’s CloudRenderer (Minecraft Java Edition performance mod). Clouds are generated as CPU-side 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. This ensures clouds closest to the camera are generated first.
The traversal works in three phases:
- Center cell at the player’s grid position
- Diamond expansion from layer 1 to the configured radius, visiting cells in a diamond pattern
- Corner fill for layers beyond the base radius, completing the square coverage area
Each visible cell generates either a single horizontal quad (Fast mode) or a full 12x12x4 box with up to 6 exterior faces plus interior backfaces (Fancy mode). A face culling system eliminates faces that cannot be seen from the current camera orientation, reducing vertex count significantly.
The coordinate system maps from Minecraft conventions (Y-up) to the engine’s Z-up system:
| Minecraft | Engine | Description |
|---|---|---|
| +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 simulate basic ambient occlusion: top faces at full brightness (1.0), bottom faces darkened (0.7), and side faces at intermediate values (0.8 and 0.9).
Vanilla Clouds in Action
Volumetric Cloud Ray Marching
The EnigmaDefault ShaderBundle replaces vanilla geometry clouds entirely with screen-space ray marched volumetric clouds. The vanilla gbuffers_clouds pixel shader simply calls discard on every fragment, and the volumetric system takes over in deferred1.ps.hlsl.
This approach is a faithful port of Complementary Reimagined’s cloud system (reimaginedClouds.glsl), adapted from GLSL to HLSL with engine-specific coordinate system adjustments.
Ray March Algorithm
The core idea is straightforward: for each screen pixel, cast a ray from the camera through the pixel into the scene. If the ray intersects the cloud layer (a horizontal slab defined by CLOUD_ALT1 ± CLOUD_STRETCH), march along the ray within 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 intersection test computes where the view ray enters and exits the cloud slab:
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);
The sample count scales with quality level: 16 samples at low, 32 at medium, and 48 at high. A dither offset (interleaved gradient noise) staggers samples across neighboring pixels to reduce banding artifacts, which temporal anti-aliasing then smooths out.
Texture-Driven Cloud Shape
Unlike many volumetric cloud implementations that rely on 3D Perlin or Worley noise, this system uses a 2D texture lookup for cloud density. The cloud-water.png texture (256x256) stores cloud density in its blue channel. This is a key design choice from Complementary Reimagined that trades some volumetric complexity for significantly lower sampling cost.
The sampling pipeline works as follows:
-
Wind animation: The world position is offset by a time-based wind vector using
worldTime(synchronized to the game world clock, not real time). This means clouds accelerate when the game uses time scaling. -
Coordinate mapping: The wind-animated position is scaled by
CLOUD_NARROWNESS(0.07) and converted to a 256x256 texture coordinate usingGetRoundedCloudCoord, which appliessmoothsteprounding for soft cloud edges. -
Height masking: The raw texture density is multiplied by a height-based falloff that peaks at the cloud altitude center and drops to zero at the slab boundaries.
-
Sharp threshold: The masked density is raised to the 8th power (
Pow2(Pow2(Pow2(noise)))), producing the characteristic sharp-edged Reimagined cloud look. 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;
}
Self-Shadow Computation
At quality level 2 and above, the system computes self-shadowing by sampling cloud density along the light direction from each cloud hit point. Two additional texture samples are taken at increasing distances along the shadow light vector, and each sample attenuates the light value proportionally.
#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
The shadow sampling uses a blurrier roundness value (0.35 vs 0.125 for shape) to produce softer shadow boundaries. The cloudShadingM weight is height-dependent (1 - heightGrad^2), making shadows stronger at the cloud bottom and weaker at the top, which matches how real clouds scatter light.
Cloud Coloring and Lighting
The cloud color model blends ambient and direct illumination based on several factors:
Height gradient shading maps each sample’s vertical position within the cloud slab to a 0.0 (bottom) to 1.0 (top) range, then applies a power curve (CLOUD_SHADING_POWER = 2.5). This creates naturally darker cloud bottoms and brighter tops.
Forward scattering uses a half-Lambert transform of the view-to-sun dot product (VdotS * 0.5 + 0.5), brightening clouds when looking toward the sun.
Time-of-day color interpolates between warm sunlight tones during the day and cool blue moonlight at night, driven by sunVisibility^2 for a smooth transition through sunrise and sunset.
The final color formula combines these terms:
float3 cloudColor = cloudAmbient * 0.95 * (1.0 - 0.35 * cloudShading)
+ cloudLight * (0.1 + cloudShading);
A distance fog factor is applied to the cloud itself, blending toward the sky color at the render distance boundary to avoid hard cutoffs.
Volumetric Clouds in Action
Cloud Configuration
All cloud parameters are exposed in settings.hlsl as compile-time defines with slider ranges, following the Iris/OptiFine shader options convention. This allows artists to tune the cloud appearance without modifying shader logic.
| Parameter | Default | Range | Purpose |
|---|---|---|---|
CLOUD_QUALITY | 3 | 1, 2, 3 | Ray march sample count (16/32/48) |
CLOUD_ALT1 | 192 | -96 to 800 | Primary cloud layer altitude |
CLOUD_STRETCH | 4.2 | fixed | Cloud slab vertical thickness |
CLOUD_SPEED_MULT | 100 | 0 to 900 | Wind animation speed multiplier |
CLOUD_NARROWNESS | 0.07 | fixed | Height density falloff rate |
CLOUD_SHADING_POWER | 2.5 | 1.0 to 3.5 | Height gradient curve exponent |
CLOUD_SHADOW_STRENGTH | 0.35 | 0.1 to 0.7 | Self-shadow attenuation per sample |
CLOUD_SHADOW_MIN | 0.3 | 0.1 to 0.5 | Minimum light (prevents full black) |
CLOUD_R/G/B | 100 | 25 to 300 | RGB color tint (percentage) |
DOUBLE_REIM_CLOUDS | 0 | 0, 1 | Enable dual cloud layers |
The night lighting uses separate ambient and moonlight colors with their own multipliers, giving fine control over the cloud appearance across the full day-night cycle:
#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 system outputs a cloud linear depth value to colortex5.a, which the composite pass reads to modulate volumetric light (god ray) intensity. When a god ray sample falls behind a cloud, the vlFactor attenuates the light contribution, preventing light shafts from incorrectly shining through cloud bodies.
// In deferred1: output cloud depth for VL modulation
cloudLinearDepth = sqrt(lTracePos / renderDistance);
This creates a natural interaction between the two atmospheric effects without requiring the volumetric light pass to re-sample cloud density.
Final Results
Design Philosophy
Across the cloud rendering system, several principles guided the architecture and implementation decisions:
Dual-Layer Compatibility — The engine maintains both vanilla geometry clouds and volumetric ray marched clouds as separate, swappable systems. The vanilla pipeline serves as a reliable fallback, while the EnigmaDefault ShaderBundle cleanly disables it with a single discard and activates the volumetric path. No shared mutable state exists between the two approaches.
Texture Over Procedural — Cloud shape uses a 2D texture lookup (cloud-water.png blue channel) rather than expensive 3D Perlin or Worley noise. Combined with the 8th-power threshold for sharp edges, this produces visually rich clouds at a fraction of the per-sample cost. The tradeoff is less volumetric depth variation, which the height gradient and self-shadow systems compensate for.
Configuration as First-Class — Every artistic parameter lives in settings.hlsl with explicit slider ranges, following the Iris shader options convention. Artists can reshape clouds (altitude, thickness, speed, color tint, shadow intensity) without touching shader logic. The compile-time define approach means unused quality paths are eliminated by the compiler, keeping the GPU cost predictable.
Pipeline Integration Over Isolation — Rather than treating clouds as a standalone effect, the system feeds cloud depth into the volumetric light pass and respects terrain occlusion per sample. This creates natural interactions (god rays attenuated by clouds, clouds hidden behind mountains) without duplicating sampling work across passes.
Looking For More?
Check out some of our other blogs if you haven't already!