ludic/packages/ludic.render3d/shaders/volumetric.frag
Orkuncakilkaya b12228c662 feat(render3d): light you can see - sun shafts and valley mist
Everything before this made the air a COLOUR APPLIED TO A SURFACE. Nothing put light
in the space between surfaces, so the basin had no shafts, no pooled mist and no rays
off a ridge at any hour, whatever was done to the fog.

A half-resolution march from the camera to the depth buffer, asking the same shadow
the rest of the frame asks - the cascades, the baked height-field shadow and the cloud
mask - so a shaft is cast by the actual trees and the actual ridge and a passing cloud
dims its own rays. Henyey-Greenstein scattering, because real air throws light forward.
Density and a separate ground-hugging mist layer ride the sun's elevation, so mist
forms in the cold at either end of the day and burns off by mid-morning.

Composited with the bloom pyramid's own tent upsample under ONE/ONE - what was wanted
and already there - and before bloom, so a shaft blooms. Into post_hdr, not post_scene:
post_scene is what the water refracts and shafts added there would sit under the lake.

The tuning that mattered was the sky term, which is added at every step: at 0.06 it
accumulated into a flat grey wash lifting lit and shadowed air equally, which is the
contrast a shaft is made of, and the valley came out one pale sheet. At 0.012 the sun
dominates and there is light rather than fog. I cut the density and mist three times
before the frame looked like air instead of paint.

R3D_NOVOL=1 for an A/B; Off in Settings skips the pass whole.

400 frames at 07:00: GL 7.1 -> 7.3 s, VK 7.2 -> 7.4 s. Backends agree to 0.08/255.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 16:06:16 +03:00

95 lines
4.5 KiB
GLSL

// Light you can see: sun shafts through the canopy, mist lying in the valley, and a ridge at
// dusk standing in glowing air. Everything before this made the AIR a colour applied to a
// surface; nothing put light in the space between surfaces, so the basin had no shafts, no
// pooled mist and no rays off the peaks at any hour.
//
// This is a half-resolution march from the camera to whatever the depth buffer says is in
// front of it. At each step it asks the SAME shadow the rest of the frame asks - the cascades,
// the baked height-field shadow and the cloud mask - so a shaft is cast by the actual trees
// and the actual ridge, and a cloud passing over dims its own rays.
in vec2 v_uv;
out vec4 o_color;
uniform sampler2D u_depth;
uniform mat4 u_inv_vp;
uniform float u_vol_steps;
uniform float u_vol_density; // how much light the air scatters back at you
uniform float u_vol_falloff; // how fast the haze thins with height above the fog datum
uniform float u_vol_far; // stop marching here (m): past it the analytic fog carries on
uniform float u_vol_g; // Henyey-Greenstein anisotropy: how forward-throwing the air is
uniform float u_vol_mist; // a denser layer lying ON the ground at dawn and dusk
uniform float u_vol_mist_h; // how deep that layer is (m)
// One shadow lookup for a point in mid-air. It wants no normal and no slope bias - there is no
// surface here to shadow-acne - so it is the cheap tap rather than the receiver's rotated disc.
float volShadow(vec3 wpos, float viewDepth) {
int c = CASCADES - 1;
for (int i = 0; i < CASCADES - 1; i++) { if (viewDepth < u_cascade_split[i]) { c = i; break; } }
vec4 lp = u_cascade_vp[c] * vec4(wpos, 1.0);
vec3 p = lp.xyz / lp.w * 0.5 + 0.5;
float s = 1.0;
if (p.x >= 0.0 && p.x <= 1.0 && p.y >= 0.0 && p.y <= 1.0 && p.z <= 1.0) {
s = shadowTap(p.xy, c, p.z - 0.0016);
}
return min(s, terrainShadow(wpos)) * cloudShadow(wpos);
}
// how much air there is at a height: the basin's own haze, plus a shallow mist that lies on
// the ground rather than filling the valley
float volDensity(vec3 wpos) {
float y = wpos.y - u_fog_base;
float air = exp(-max(y, 0.0) * u_vol_falloff);
float mist = u_vol_mist * exp(-max(y, 0.0) / max(u_vol_mist_h, 1.0));
return u_vol_density * (air + mist);
}
// Henyey-Greenstein: real air throws light FORWARD, which is why a low sun makes the whole
// valley glow when you look into it and almost nothing when you look away from it.
float phaseHG(float c, float g) {
float g2 = g * g;
float d = 1.0 + g2 - 2.0 * g * c;
return (1.0 - g2) / (4.0 * PI * max(d * sqrt(max(d, 1e-4)), 1e-4));
}
void main() {
float d = texture(u_depth, v_uv).r;
// the world point this pixel looks at, and the ray to it
vec4 far4 = u_inv_vp * vec4(v_uv * 2.0 - 1.0, 1.0, 1.0);
vec3 farP = far4.xyz / far4.w;
vec3 dir = normalize(farP - u_cam_pos);
float march;
if (d >= 1.0) {
march = u_vol_far; // sky: march the whole way
} else {
vec4 h4 = u_inv_vp * vec4(v_uv * 2.0 - 1.0, d * 2.0 - 1.0, 1.0);
march = min(length(h4.xyz / h4.w - u_cam_pos), u_vol_far);
}
int steps = int(u_vol_steps);
float dt = march / float(steps);
// A dither per pixel, not per frame. Advancing it every frame only helps if something
// averages the frames, and there is no temporal resolve here (post.ludic); it would just
// be noise that changes, which is what the old TAA attempt was removed for.
float jitter = ign(gl_FragCoord.xy);
float ph = phaseHG(dot(dir, u_sun_dir), u_vol_g);
vec3 acc = vec3(0.0);
float trans = 1.0;
for (int i = 0; i < 64; i++) {
if (i >= steps) break;
float t = (float(i) + jitter) * dt;
vec3 p = u_cam_pos + dir * t;
float dens = volDensity(p);
if (dens > 1e-5) {
float vis = volShadow(p, t);
// in-scattering from the sun, and a little from the sky so shadowed air is not black
// The sky's own contribution is kept SMALL and deliberately so. It is added at every
// step, so it accumulates into a flat grey wash that lifts lit and shadowed air by the
// same amount - which is exactly the contrast a shaft is made of. At 0.06 the valley
// came out as one pale sheet with no rays in it at all; the sun's term has to dominate
// or there is no light to see, only fog.
vec3 inscat = u_sun_color * vis * ph + skyIrradiance(vec3(0.0, 1.0, 0.0)) * 0.012;
float a = dens * dt;
acc += inscat * a * trans;
trans *= exp(-a);
}
}
o_color = vec4(sane(acc), 1.0);
}