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:
parent
d34fb5bc63
commit
b12228c662
15 changed files with 252 additions and 1 deletions
32
changes/light-you-can-see.md
Normal file
32
changes/light-you-can-see.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
Binary file not shown.
BIN
packages/ludic.render3d/shaders/spv/74258189.frag.spv
Normal file
BIN
packages/ludic.render3d/shaders/spv/74258189.frag.spv
Normal file
Binary file not shown.
BIN
packages/ludic.render3d/shaders/spv/74258189.vert.spv
Normal file
BIN
packages/ludic.render3d/shaders/spv/74258189.vert.spv
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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|
|
||||
|
|
|
|||
95
packages/ludic.render3d/shaders/volumetric.frag
Normal file
95
packages/ludic.render3d/shaders/volumetric.frag
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue