SSR Water in Deferred Rendering
This post covers the water rendering system I built for a custom DirectX 12 deferred rendering engine. The engine runs an Iris-compatible shader pipeline (the modding framework behind Minecraft Java Edition shader packs), and the water system is part of the EnigmaDefault ShaderBundle targeting a Complementary Reimagined visual style.
The system implements screen-space reflections via view-space ray marching with binary refinement, multi-layer normal distortion that simulates wind-driven waves, depth-based transparency that reveals the seabed in shallow water, and a full underwater post-processing pipeline with colored volumetric light shafts, distance fog, and color attenuation.
Rendering Pipeline Architecture
Water rendering spans multiple passes in the deferred pipeline. The key architectural decision is that SSR is computed inline during the gbuffers_water pass rather than deferred to a composite pass. This matches ComplementaryReimagined’s approach where water reflection is fully resolved before writing to colortex0.
flowchart TD
subgraph GBuffer["GBuffer Pass"]
GT["gbuffers_terrain\nOpaque geometry"]
GW["gbuffers_water\nForward lit + Inline SSR\n+ Depth alpha + Foam"]
end
subgraph Deferred["Deferred Pass"]
D1["deferred1\nDeferred lighting\n+ Atmospheric fog\n+ Underwater distance fog"]
end
subgraph Composite["Composite Passes"]
C0["composite\nReserved for opaque SSR"]
C1["composite1\nRefraction + Underwater color\n+ Volumetric light\n+ Underwater VL attenuation"]
C5["composite5\nTonemapping"]
end
GT --> Deferred
Deferred --> GW
GW -->|"colortex0: lit water + SSR\ncolortex2: perturbed normal\ncolortex4: material mask"| Composite
C0 --> C1 --> C5
The gbuffers_water pass runs after deferred lighting, so colortex0 already contains the fully lit opaque scene. SSR samples colortex0 at reflected screen positions (which are always opaque pixels, never water), so there is no read-write hazard. Water itself receives forward lighting via CalculateLighting since it bypasses the deferred path.
Water Surface Normal Distortion
The water surface appearance is driven entirely by normal perturbation sampled from a shared cloud-water.png texture. The RG channels store normal map data, and the alpha channel provides height information for parallax mapping.
Multi-Layer Normal Sampling
Three frequency layers are blended to produce the final surface normal, each scrolling at different speeds to simulate wind:
float2 SampleWaterNormals(float2 waterPos, float2 wind, float geoFresnel)
{
float2 normalMed = waterTex.Sample(sampler0, waterPos + wind).rg - 0.5;
float2 normalSmall = waterTex.Sample(sampler0, waterPos * 4.0 - 2.0 * wind).rg - 0.5;
float2 normalBig = waterTex.Sample(sampler0, waterPos * 0.25 - 0.5 * wind).rg - 0.5;
normalBig += waterTex.Sample(sampler0, waterPos * 0.05 - 0.05 * wind).rg - 0.5;
float2 normalXY = normalMed * 0.5 + normalSmall * 1.0 + normalBig * 0.3;
// Attenuate bumpiness at grazing angles to reduce noise
float bumpScale = 6.0 * (1.0 - 0.7 * geoFresnel) * (WATER_BUMPINESS * 0.01);
normalXY *= bumpScale * 0.035;
return normalXY;
}
| Layer | UV Scale | Wind Multiplier | Weight | Purpose |
|---|---|---|---|---|
| Medium | 1.0x | 1.0x | 0.5 | Mid-frequency ripples |
| Small | 4.0x | 2.0x (reversed) | 1.0 | High-frequency detail |
| Big | 0.25x + 0.05x | 0.5x + 0.05x | 0.3 | Low-frequency ocean swell |
The wind vector is derived from frameTimeCounter and WATER_WAVE_SPEED, scrolling the UV coordinates over time. The small layer scrolls in the opposite direction to the medium layer (-2.0 * wind), creating a natural interference pattern that mimics cross-wave interaction. The big layer combines two sub-frequencies (0.25x and 0.05x) for broad, slow-moving swells.
A critical detail is the grazing angle attenuation: bumpScale *= (1.0 - 0.7 * geoFresnel). At steep viewing angles (looking straight down), the full bump intensity applies. At grazing angles (looking across the water surface), bumpiness is reduced by 70%. This prevents excessive noise in the reflection at shallow view angles where SSR is already working at its limits.
Normal Safety Correction
After computing the perturbed normal, a safety check prevents the reflection direction from pointing below the water surface. If the reflected view vector has a negative dot product with the geometric normal (meaning it reflects into the water), the perturbed normal is blended back toward the flat geometric normal:
float3 reflectCheck = reflect(nViewPos, normalize(result.normalM));
float norMix = pow(1.0 - max(dot(geoNormal, reflectCheck), 0.0), 8.0) * 0.5;
result.normalM = lerp(result.normalM, geoNormal, norMix);
This prevents reflection angles exceeding 180 degrees at extreme perturbations, which would otherwise produce visible artifacts where the water surface reflects the underwater scene.
Screen-Space Reflection
The SSR implementation uses view-space ray marching with exponential step growth and binary refinement. This is a port of ComplementaryReimagined’s Method 1 (reflections.glsl), adapted from GLSL to HLSL with engine-specific coordinate system adjustments.
Ray March Algorithm
flowchart LR
A["Water Fragment"] --> B["Transform to\nView Space"]
B --> C["Compute Reflect\nDirection"]
C --> D["Bias Start Position\nAvoid Self-Hit"]
D --> E["Exponential\nStep March"]
E --> F{"Depth\nHit?"}
F -->|No| G{"Screen\nBounds?"}
G -->|Inside| E
G -->|Outside| H["Miss: Use\nSky Fallback"]
F -->|Yes| I["Binary\nRefinement\n10 iterations"]
I --> J["Border Fade\n+ Proximity Reject"]
J --> K["Sample colortex0\nat Hit UV"]
The algorithm works in view space rather than screen space for more accurate distance calculations:
-
View-space setup: Transform the world position and normal into view space using
gbufferView. Compute the reflected direction viareflect(normalize(viewPos), viewNormal). -
Start bias: Offset the ray origin along the surface normal by
lViewPos * 0.025 + 0.05to prevent self-intersection. Distant fragments get a larger bias proportional to their view distance. -
Exponential stepping: Each step doubles in size (
stepVec *= 2.0), covering more distance as the ray travels further. A dither offset (0.95 + 0.1 * dither) using Interleaved Gradient Noise staggers samples across neighboring pixels to reduce banding. -
Depth comparison: At each step, project the ray position to screen space via
SSR_ViewToScreen(View to Render to Clip to NDC to Screen). Sampledepthtex1(opaque-only depth) to avoid hitting other water pixels. If the error metricerr * 0.333 < length(stepVec), a potential hit is found. -
Binary refinement: On hit detection, back up and take 10% smaller steps, repeating up to
SSR_BINARY_STEPS = 10times for sub-pixel precision.
// Core ray march loop (simplified)
for (int i = 0; i < SSR_MAX_STEPS; i++)
{
float3 screenPos = SSR_ViewToScreen(rayPos);
// Boundary check
if (abs(screenPos.x - 0.5) > 0.525 || abs(screenPos.y - 0.5) > 0.525)
break;
// Sample opaque depth (depthtex1 avoids water self-hits)
float sceneDepth = depthtex1.Sample(sampler1, screenPos.xy).r;
float3 sceneViewPos = ReconstructViewPosition(screenPos.xy, sceneDepth, ...);
float err = length(rayPos - sceneViewPos);
if (err * 0.333 < length(stepVec))
{
refinements++;
if (refinements >= SSR_BINARY_STEPS) { hit = true; break; }
accumStep -= stepVec;
stepVec *= 0.1; // Binary refinement: 10% step size
}
stepVec *= 2.0; // Exponential growth
accumStep += stepVec * (0.95 + 0.1 * dither);
rayPos = start + accumStep;
}
SSR Quality and Confidence
The hit result goes through multiple confidence filters before contributing to the final reflection:
| Filter | Formula | Purpose |
|---|---|---|
| Border fade | pow(max(cdist.x, cdist.y), 50) | Hard cutoff near screen edges |
| Edge factor | (1 - cdist^8)^(2 + 3*Luma) | Luminance-adaptive gradual fade |
| Proximity | saturate(posDif + 3.0) | Reject hits too close to source |
| Smoothness | * smoothness | Material roughness modulation |
The final confidence alpha combines all four factors: border * refFactor * proxFade * smoothness. This produces clean reflections in the screen center that gracefully fade at edges, preventing hard cutoff artifacts.
Sky Reflection Fallback
When SSR misses (ray exits screen bounds or finds no hit), a sky-tinted fallback provides a base reflection color. The fallback interpolates between night and day sky colors based on sunVisibility^2:
float3 skyReflectColor = lerp(WATER_SKY_REFLECT_NIGHT, WATER_SKY_REFLECT_DAY, sunVis2);
// Night: float3(0.02, 0.03, 0.06) Day: float3(0.35, 0.55, 0.85)
Upward-facing reflections receive more sky color (skyBlend = lerp(0.3, 1.0, max(reflectDir.z, 0.0))), while downward-facing reflections are darker. The final reflection blends SSR over the fallback: lerp(skyFallback, ssr.color, ssr.alpha).
Parallel-to-Surface View Angle
When the camera is nearly parallel to the water surface, SSR faces its toughest challenge. The reflected rays travel nearly horizontally across the screen, requiring many steps to find hits. At these angles, the Fresnel effect pushes the water to near-full opacity and maximum reflectivity, making any SSR noise more visible.
The system handles this through several mechanisms: the grazing-angle normal attenuation reduces perturbation noise, the exponential step growth covers more screen distance per iteration, and the border fade gracefully handles rays that exit the screen. Some noise remains visible at extreme angles, which is an inherent limitation of screen-space techniques.
Depth-Based Water Transparency
A flat alpha value for water looks wrong. Shallow water near shorelines should be transparent enough to see the seabed, while deep ocean water should be opaque. The system reads depthtex1 (opaque-only depth buffer) to compute the distance between the water surface and the nearest opaque geometry below it.
float3 waterViewPos = mul(gbufferView, float4(worldPos, 1.0)).xyz;
float waterViewDist = length(waterViewPos);
float3 opaqueViewPos = ReconstructViewPosition(screenUV, opaqueDepth, ...);
float opaqueViewDist = length(opaqueViewPos);
float depthDiff = max(opaqueViewDist - waterViewDist, 0.0);
float waterFogAlpha = max(0.0, 1.0 - exp(-depthDiff * WATER_DEPTH_FOG_DENSITY));
alpha *= WATER_DEPTH_ALPHA_MIN + (1.0 - WATER_DEPTH_ALPHA_MIN) * waterFogAlpha;
The exponential decay formula 1 - exp(-depth * density) produces a natural falloff: near-zero depth gives near-zero fog (transparent), while large depth saturates toward 1.0 (opaque). WATER_DEPTH_ALPHA_MIN = 0.6 sets a floor so that even zero-depth water retains some visibility of the surface.
After depth fog modifies the alpha, the Fresnel term is re-applied: alpha = lerp(alpha, 1.0, fresnel^4). This ensures that at grazing angles, the water always appears opaque regardless of depth, matching real-world behavior where you cannot see through water at shallow viewing angles.
Shoreline Water Culling
Before any pixel shader runs, the engine’s ChunkMeshHelper already eliminates unnecessary water geometry at the mesh generation stage. The ShouldRenderFace() function applies a three-level culling strategy for water blocks:
-
Water-to-water culling:
LiquidBlock::SkipRendering()checks if the neighboring block is the same fluid type. If both sides are water, the shared face is skipped entirely, eliminating all internal faces between adjacent water blocks. -
Water-to-solid culling: When a water face’s neighbor is a solid block (
CanOcclude() == true), that face is culled. This is the key shoreline optimization. Water blocks sitting against dirt, stone, or sand at the water’s edge have their occluded faces removed during mesh building, so the GPU never processes geometry that would be invisible behind terrain. -
Backface generation for underwater view: For the top face of a water block where the block above is not the same fluid (the actual water surface), a backface is generated with flipped normals and reversed winding order. This allows the water surface to be visible from both above and below without doubling the face count for submerged internal faces.
// Water-to-solid culling (ChunkMeshHelper.cpp)
if (neighborBlock->CanOcclude())
{
if (currentRenderType == RenderType::TRANSLUCENT
&& !currentBlock->GetFluidState().IsEmpty())
{
return false; // Water face against solid block: cull
}
}
This CPU-side culling runs once during chunk mesh rebuilds, so the per-frame GPU cost is zero. The depth-based alpha system in the pixel shader then handles the remaining visual transition for faces that do render, with foam masking the boundary where shallow water meets the shore.
Fresnel and Reflection Blending
The Fresnel coefficient controls how much of the reflection versus the water’s own color is visible. The implementation uses a cubic approximation with a guaranteed minimum:
float fresnel = saturate(1.0 + dot(normalM, -viewDir));
float fresnelM = fresnel^3 * 0.85 + 0.15;
The + 0.15 term ensures at least 15% reflection even when looking straight down at the water (normal incidence). At grazing angles, fresnelM approaches 1.0 for near-total reflection. The final water color blends between the lit water surface and the reflection:
waterColor = lerp(litWaterColor, reflectionColor, fresnel * reflectMult);
When the camera is underwater, reflectMult drops to 0.25, significantly reducing reflection strength. This matches the physical behavior where the water-to-air interface reflects less light from the underwater side.
Day-Night Cycle
The water rendering responds to the full day-night cycle through multiple time-dependent parameters. Sky reflection color interpolates between warm day tones and cool night tones. Forward lighting on the water surface shifts from warm sunlight to cold moonlight. The depth fog color transitions between day and night variants.
Underwater Effects
When the camera submerges, the rendering pipeline activates a full underwater post-processing chain across multiple passes.
Underwater Distance Fog
Applied in deferred1, the underwater fog uses a squared exponential formula for a natural falloff curve:
float fog = (dist / WATER_UW_FOG_DISTANCE) * (dist / WATER_UW_FOG_DISTANCE);
float factor = 1.0 - exp(-fog);
The squared term keeps near-field visibility clear while producing smooth attenuation at distance. At 48 blocks (WATER_UW_FOG_DISTANCE), fog reaches approximately 63%. At 96 blocks, fog is 98%. The fog color transitions between WATER_FOG_COLOR_NIGHT and WATER_FOG_COLOR_DAY based on sunVisibility^2.
Underwater Color Attenuation
In composite1, a gamma-space color multiplier simulates how water absorbs different wavelengths of light. Red is absorbed most aggressively, green moderately, and blue least:
// Applied in gamma space BEFORE pow(2.2) linearization
float3 underwaterMult = float3(0.80, 0.87, 0.97) * 0.85;
sceneColor *= underwaterMult;
// Later: sceneColor = pow(sceneColor, 2.2);
The key insight is that this attenuation is applied in gamma space before linearization. When pow(2.2) is applied afterward, the effective attenuation is amplified: a gamma-space value of 0.68 becomes pow(0.68, 2.2) = 0.43 in linear space, a 57% reduction rather than the 32% it would be if applied directly in linear space. Sky pixels are replaced entirely with the water fog color to hide unloaded chunk boundaries.
Underwater Volumetric Light
The volumetric light system receives special treatment when the camera is underwater. Several modifications ensure that light shafts through the water surface look correct:
-
Forced scene intensity:
vlSceneIntensity = 1.0bypasses the noon reduction that normally dims VL when the sun is overhead. Underwater, colored light shafts should remain visible at all sun angles. -
Shortened ray march:
maxDist = min(maxDist, 80.0)limits the ray march distance. Water attenuates light quickly, so marching beyond 80 blocks wastes samples. -
Dual shadow test: The system samples both
shadowtex0(includes water) andshadowtex1(opaque only). Whenshadow0 < 0.5(shadowed by water) butshadow1 > 0.5(not shadowed by opaque geometry), the sample is in a colored light shaft passing through the water surface. The color is read fromshadowcolor1and squared for intensity. -
Fully-lit sample zeroing: When a sample is fully lit (
shadow0 > 0.5), it means the sample point is not behind any shadow caster. Underwater, this would produce plain white VL that washes out the scene. These samples are zeroed so that VL only comes from colored shafts through the water surface. -
VL attenuation: After the ray march, the accumulated VL is attenuated by
(underwaterMult * 0.71)^2, matching the water’s color absorption profile.
The noise texture (noise.png) provides the dither pattern for the ray march via its green channel, using Interleaved Gradient Noise to stagger samples across pixels and reduce banding artifacts. This is the same noise sampling approach used in the volumetric cloud system, adapted here for the underwater light path.
Vanilla vs EnigmaDefault Comparison
The engine maintains both a vanilla rendering path and the EnigmaDefault ShaderBundle as separate, swappable systems. The vanilla path uses simple flat-shaded water with no SSR, no depth-based transparency, and no underwater post-processing.
Water Configuration
All water parameters are exposed in settings.hlsl as compile-time defines with slider ranges, following the Iris/OptiFine shader options convention.
| Parameter | Default | Range | Purpose |
|---|---|---|---|
WATER_REFLECT_QUALITY | 2 | 0, 1, 2 | 0=off, 1=sky fallback, 2=SSR + sky |
SSR_MAX_STEPS | 38 | fixed | Ray march maximum iterations |
SSR_BINARY_STEPS | 10 | fixed | Binary refinement iterations |
WATER_BUMPINESS | 80 | 1 to 200 | Normal map intensity |
WATER_WAVE_SPEED | 2 | 0 to 10 | Wind animation speed |
WATER_DEPTH_FOG_DENSITY | 0.075 | fixed | Depth transparency decay rate |
WATER_DEPTH_ALPHA_MIN | 0.6 | fixed | Minimum alpha at zero depth |
WATER_FOAM_I | 50 | 0 to 200 | Shoreline foam intensity |
WATER_REFRACTION_INTENSITY | 100 | 0 to 300 | Screen-space refraction strength |
WATER_ALPHA_MULT | 100 | 25 to 400 | Overall transparency multiplier |
WATER_VL_STRENGTH | 1.0 | 0.0 to 2.0 | Underwater volumetric light intensity |
WATER_CAUSTICS_STRENGTH | 1.0 | 0.0 to 3.0 | Underwater caustics intensity |
Design Philosophy
Across the water rendering system, several principles guided the architecture and implementation decisions:
Inline SSR Over Deferred Reflection — SSR is computed directly in gbuffers_water rather than deferred to a composite pass. This eliminates the need to store intermediate reflection data in render targets and avoids a full-screen pass for an effect that only applies to water pixels. The tradeoff is that future opaque surface reflections (metals, ice) will need their own path in the composite pass, but water is the dominant reflective surface and benefits most from the simplified pipeline.
Depth as Implicit Culling — Rather than implementing explicit geometry culling for thin shoreline water, the depth-based alpha system naturally handles it. Zero-depth water becomes nearly transparent, foam masks the transition, and the Fresnel re-application ensures grazing angles still look correct. This approach requires no CPU-side culling logic and adapts automatically to any terrain configuration.
Gamma-Space Underwater Processing — Applying underwater color attenuation in gamma space before linearization is a deliberate choice that amplifies the visual effect without requiring extreme multiplier values. A gamma-space multiplier of 0.68 produces the same visual darkening as a linear-space multiplier of 0.43, but the gamma-space value is more intuitive for artists to tune and produces smoother gradients in the dark end of the range.
Dual Shadow Architecture for Colored Shafts — The underwater volumetric light uses a two-texture shadow test (shadowtex0 vs shadowtex1) to distinguish between opaque shadows and translucent water shadows. This enables colored light shafts through the water surface without requiring a separate translucent shadow pass. The zeroing of fully-lit samples ensures VL only appears where light actually passes through the water, preventing the washed-out look that plain white volumetric light would produce underwater.
Configuration as First-Class — Every artistic parameter lives in settings.hlsl with explicit slider ranges, following the Iris shader options convention. Artists can reshape water appearance (bumpiness, transparency, foam, reflection quality) without touching shader logic. The compile-time define approach means unused quality paths are eliminated by the compiler, keeping the GPU cost predictable across quality tiers.
Looking For More?
Check out some of our other blogs if you haven't already!