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>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-19 16:06:16 +03:00
parent d34fb5bc63
commit b12228c662
15 changed files with 252 additions and 1 deletions

View file

@ -0,0 +1,32 @@
bump: minor
type: feat
Light you can see: sun shafts, and mist that lies in the valley.
Everything before this made the air a COLOUR APPLIED TO A SURFACE. Nothing put light in
the space between surfaces, so a basin at dawn had no shafts, no pooled mist and no rays
off a ridge at any hour, however the fog was tuned.
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, which is
why a low sun fills a valley when you look into it and does almost nothing when you look
away. Density and a separate ground-hugging MIST layer ride the sun's elevation from
daylight.ludic, so the mist forms in the cold at either end of the day and burns off by
mid-morning.
It composites into the HDR scene with the bloom pyramid's own tent upsample under ONE/ONE
blending - which is exactly what was wanted and already existed - and before bloom, so a
shaft blooms like the bright thing in the air it is. Into post_hdr and not post_scene:
post_scene is the copy the water refracts, and shafts added there would sit under the lake.
The tuning that mattered was the SKY term. It is added at every step, so at 0.06 it
accumulated into a flat grey wash that lifted lit and shadowed air by the same amount -
which is the contrast a shaft is made of. The valley came out as one pale sheet with no
rays in it. At 0.012 the sun's term dominates and there is light to see rather than fog.
R3D_NOVOL=1 switches it off for an A/B. Off in Settings skips the pass whole rather than
marching once, because a feature is off when it costs nothing.
Measured, 400 frames at 07:00: OpenGL 7.1 -> 7.3 s, Vulkan 7.2 -> 7.4 s. Backends agree
to 0.08/255.

View file

@ -201,6 +201,17 @@ function daylight_set(hours: int) -> void {
# zenith onto a grey day.
r3d_sky_relight = f_mul(smoothf(f_rad(fi(-10)), f_rad(fi(1)), el), f_sub(F_ONE, f_mul(fl(0.75), oc)))
# ---- what is IN the air -------------------------------------------------------------
# The volumetric march (post.ludic) needs the same story the analytic fog tells, or the
# shafts and the haze disagree. Density rides the sun's elevation like everything else; the
# MIST is separate and is the thing a valley actually does - a shallow layer that forms in
# the cold at either end of the day, lies ON the ground rather than filling the basin, and
# burns off by mid-morning. Cloud thickens the air and kills the shafts, because a shaft
# needs a disc to come from.
post_vol_density = f_mul(f_add(fl(0.00030), f_mul(fl(0.00070), lowsun)), f_sub(F_ONE, f_mul(fl(0.55), oc)))
post_vol_mist = f_mul(f_mul(fl(0.40), lowsun), f_sub(F_ONE, f_mul(fl(0.4), oc)))
post_vol_falloff = f_add(fl(0.006), f_mul(fl(0.004), lowsun))
# R3D_NOAIR=1: the air as it was before any of the above - one density, one falloff, the
# inscatter the shader used to hard-code, and no distance desaturation at all. It is here
# so a before-and-after can be shot from ONE binary at one hour, which is the only kind of
@ -215,6 +226,7 @@ function daylight_set(hours: int) -> void {
post_gain_r = fl(0.99); post_gain_g = fl(0.995); post_gain_b = F_ONE
post_contrast = fl(1.12); post_saturation = fl(1.04)
r3d_sky_relight = F_ZERO
post_vol_density = F_ZERO; post_vol_mist = F_ZERO
}
# exposure: auto-exposure must not turn the night into day
# the ceiling has to move with the moon or auto-exposure eats the difference between a

View file

@ -56,6 +56,19 @@ var post_gi_strength: int = 0x3ECCCCCD # 0.4
var post_no_gi: bool = false
var post_ldr: Target = null
var post_depth_copy: Target = null
# ---- volumetric light ----------------------------------------------------------------------
# Half resolution on purpose: in-scattered light is smooth, a shaft has no sharp edge, and the
# march is the whole cost of the pass. post_vol_steps is the quality dial; 0 switches it off
# and the pass is skipped entirely rather than run at one step.
var post_vol: Target = null
var post_p_vol: int = 0
var post_vol_steps: int = 0x41C00000 # 24
var post_vol_density: int = 0x3B03126F # 0.002
var post_vol_falloff: int = 0x3BA3D70A # 0.005
var post_vol_far: int = 0x44480000 # 800 m
var post_vol_g: int = 0x3F19999A # 0.6: air throws light forward
var post_vol_mist: int = 0 # the day sets these two
var post_vol_mist_h: int = 0x42200000 # 40 m
var post_prev: Target = null # last frame's scene colour, for the SSGI bounce only
var post_scene: Target = null # this frame's scene colour before the water, for refraction
var post_frame: int = 0
@ -75,7 +88,7 @@ function post_free() -> void {
if post_hdr == null { return }
if post_ms_fbo != 0 { gpu_fb_free(post_ms_fbo); post_ms_fbo = 0 }
target_free(post_hdr); target_free(post_ao); target_free(post_ao_blur); target_free(post_ldr)
target_free(post_depth_copy); target_free(post_prev); target_free(post_scene)
target_free(post_depth_copy); target_free(post_prev); target_free(post_scene); target_free(post_vol)
for i in 0 .. len(post_bloom) { target_free(post_bloom[i]) }
post_hdr = null
}
@ -136,6 +149,8 @@ function post_init(w: int, h: int) -> void {
post_ldr = target_new(w, h, post_ldr_fmt(), GL_RGBA, GL_UNSIGNED_BYTE, false, GL_LINEAR)
post_ldr_hdr = gpu_hdr_active()
post_depth_copy = target_new(w, h, GL_R8, GL_RED, GL_UNSIGNED_BYTE, true, GL_NEAREST)
post_vol = target_new(max(w / 2, 1), max(h / 2, 1), GL_RGBA16F, GL_RGBA, GL_HALF_FLOAT, false, GL_LINEAR)
if post_p_vol == 0 { post_p_vol = r3d_program("fullscreen.vert", "volumetric.frag", "") }
post_prev = target_new(w, h, GL_RGBA16F, GL_RGBA, GL_HALF_FLOAT, false, GL_LINEAR)
post_scene = target_new(w, h, GL_RGBA16F, GL_RGBA, GL_HALF_FLOAT, false, GL_LINEAR)
if post_p_sharp == 0 { post_p_sharp = r3d_program("fullscreen.vert", "sharpen.frag", "") }
@ -278,6 +293,48 @@ function post_ssao_pass() -> void {
mesh_draw(post_fs)
}
# The march, then the composite. It is added to the scene BEFORE bloom on purpose: a shaft of
# light is a bright thing in the air and should bloom like one, and compositing it after the
# bloom pyramid would give hard-edged rays with no glow at all.
function post_volumetric_pass() -> void {
if post_vol_steps <= 0 { return }
if r3d_env_has("R3D_NOVOL") { return }
gpu_depth_test(false)
gpu_blend(false)
target_bind(post_vol)
gpu_use_program(post_p_vol)
r3d_bind_2d(post_p_vol, "u_depth", 0, post_hdr.depth)
shadow_bind(post_p_vol)
sky_bind_lighting(post_p_vol)
sky_bind_rot(post_p_vol)
fog_bind(post_p_vol)
u_mat4(gpu_uniform(post_p_vol, "u_inv_vp"), cam_inv_vp)
u_v3(gpu_uniform(post_p_vol, "u_cam_pos"), cam_pos)
u_v3(gpu_uniform(post_p_vol, "u_sun_dir"), sun_dir)
u_v3(gpu_uniform(post_p_vol, "u_sun_color"), sun_color)
u_f(gpu_uniform(post_p_vol, "u_vol_steps"), post_vol_steps)
u_f(gpu_uniform(post_p_vol, "u_vol_density"), post_vol_density)
u_f(gpu_uniform(post_p_vol, "u_vol_falloff"), post_vol_falloff)
u_f(gpu_uniform(post_p_vol, "u_vol_far"), post_vol_far)
u_f(gpu_uniform(post_p_vol, "u_vol_g"), post_vol_g)
u_f(gpu_uniform(post_p_vol, "u_vol_mist"), post_vol_mist)
u_f(gpu_uniform(post_p_vol, "u_vol_mist_h"), post_vol_mist_h)
mesh_draw(post_fs)
# Composite into the HDR scene with the bloom pyramid's own upsample - a 3x3 tent under
# ONE/ONE blending, which is exactly what is wanted here and already exists, rather than a
# blit shader written for one caller. Into post_hdr, not post_scene: post_scene is the copy
# the water refracts, so adding shafts there would put them UNDER the lake.
target_bind(post_hdr)
gpu_blend(true)
gpu_blend_func(GL_ONE, GL_ONE)
gpu_use_program(post_p_up)
r3d_bind_2d(post_p_up, "u_src", 0, post_vol.color)
u_f2(gpu_uniform(post_p_up, "u_texel"), fr(1, post_vol.w), fr(1, post_vol.h))
u_f(gpu_uniform(post_p_up, "u_radius"), F_ONE)
mesh_draw(post_fs)
gpu_blend(false)
}
function post_bloom_pass() -> void {
gpu_depth_test(false)
gpu_blend(false)

View file

@ -285,6 +285,9 @@ function r3d_frame(time: int) -> void {
post_color = post_hdr.color; post_color_w = post_w; post_color_h = post_h
# DLSS super resolution: the lit frame up to the display's size, before anything reads it
if gsl_dlss_live() { prof_begin("DLSS"); post_color = gsl_dlss_eval(); prof_end() }
# Before bloom and before the tonemap: a shaft of light is a bright thing in the air and
# should bloom like one.
prof_begin("volumetric"); post_volumetric_pass(); prof_end()
if not post_no_gi { prof_begin("SSAO/GI"); post_ssao_pass(); prof_end() }
if r3d_debug_max { tex_max(post_hdr.color, post_hdr.w, post_hdr.h, "hdr") }
prof_begin("bloom")

Binary file not shown.

Binary file not shown.

View file

@ -3117,3 +3117,54 @@ T 46e2325c u_scene 7
T 46e2325c u_shadow 8
T 46e2325c u_tershadow 9
T 46e2325c u_ts_height 10
P 74258189 fullscreen.vert volumetric.frag
B 74258189 frag 1 924
U 74258189 frag u_cascade_vp 0 mat4 5 64
U 74258189 frag u_cascade_split 320 float 5 16
U 74258189 frag u_cascade_range 400 float 5 16
U 74258189 frag u_cascade_texel 480 float 5 16
U 74258189 frag u_sun_dir 560 vec3 1 0
U 74258189 frag u_sun_color 576 vec3 1 0
U 74258189 frag u_cam_pos 592 vec3 1 0
U 74258189 frag u_prefilter_levels 604 float 1 0
U 74258189 frag u_fog_density 608 float 1 0
U 74258189 frag u_fog_height_falloff 612 float 1 0
U 74258189 frag u_fog_base 616 float 1 0
U 74258189 frag u_clip_y 620 float 1 0
U 74258189 frag u_spec_scale 624 float 1 0
U 74258189 frag u_sky_rot 632 vec2 1 0
U 74258189 frag u_ground_alb 640 vec3 1 0
U 74258189 frag u_ground_alb_hi 656 vec3 1 0
U 74258189 frag u_ground_hi_y 668 float 1 0
U 74258189 frag u_ground_hi_w 672 float 1 0
U 74258189 frag u_ibl_scale 688 vec3 1 0
U 74258189 frag u_daylight 700 float 1 0
U 74258189 frag u_fire_pos 704 vec3 1 0
U 74258189 frag u_fire_color 720 vec3 1 0
U 74258189 frag u_hand_pos 736 vec3 1 0
U 74258189 frag u_hand_color 752 vec3 1 0
U 74258189 frag u_hand_dir 768 vec3 1 0
U 74258189 frag u_hand_cone 780 float 1 0
U 74258189 frag u_ts_origin 784 vec2 1 0
U 74258189 frag u_ts_half 792 float 1 0
U 74258189 frag u_ts_on 796 float 1 0
U 74258189 frag u_force_cascade 800 int 1 0
U 74258189 frag u_cloud_shadow 804 float 1 0
U 74258189 frag u_time 808 float 1 0
U 74258189 frag u_fog_inscatter 812 float 1 0
U 74258189 frag u_fog_desat 816 float 1 0
U 74258189 frag u_inv_vp 832 mat4 1 0
U 74258189 frag u_vol_steps 896 float 1 0
U 74258189 frag u_vol_density 900 float 1 0
U 74258189 frag u_vol_falloff 904 float 1 0
U 74258189 frag u_vol_far 908 float 1 0
U 74258189 frag u_vol_g 912 float 1 0
U 74258189 frag u_vol_mist 916 float 1 0
U 74258189 frag u_vol_mist_h 920 float 1 0
T 74258189 u_brdf 2
T 74258189 u_depth 3
T 74258189 u_irradiance 4
T 74258189 u_prefilter 5
T 74258189 u_shadow 6
T 74258189 u_tershadow 7
T 74258189 u_ts_height 8

View file

@ -54,3 +54,4 @@ terrain.vert|terrain.frag|#define SUN_INLINE;#define FAR_ONLY;
terrain.vert|terrain.frag|#define SUN_INLINE;#define NEAR_ONLY;
terrain.vert|tersun.frag|
water.vert|water.frag|
fullscreen.vert|volumetric.frag|

View file

@ -0,0 +1,95 @@
// 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);
}