diff --git a/changes/light-you-can-see.md b/changes/light-you-can-see.md new file mode 100644 index 00000000..8c8b83fb --- /dev/null +++ b/changes/light-you-can-see.md @@ -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. diff --git a/packages/ludic.render3d/daylight.ludic b/packages/ludic.render3d/daylight.ludic index 5e08bc28..5f5b015b 100644 --- a/packages/ludic.render3d/daylight.ludic +++ b/packages/ludic.render3d/daylight.ludic @@ -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 diff --git a/packages/ludic.render3d/post.ludic b/packages/ludic.render3d/post.ludic index a398a668..49905b68 100644 --- a/packages/ludic.render3d/post.ludic +++ b/packages/ludic.render3d/post.ludic @@ -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) diff --git a/packages/ludic.render3d/render.ludic b/packages/ludic.render3d/render.ludic index c847f5f2..88a551b9 100644 --- a/packages/ludic.render3d/render.ludic +++ b/packages/ludic.render3d/render.ludic @@ -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") diff --git a/packages/ludic.render3d/shaders/spv/1ce5c5d1.frag.spv b/packages/ludic.render3d/shaders/spv/1ce5c5d1.frag.spv index 5f014a4a..72e2e685 100644 Binary files a/packages/ludic.render3d/shaders/spv/1ce5c5d1.frag.spv and b/packages/ludic.render3d/shaders/spv/1ce5c5d1.frag.spv differ diff --git a/packages/ludic.render3d/shaders/spv/3b183d8f.frag.spv b/packages/ludic.render3d/shaders/spv/3b183d8f.frag.spv index 28ad931a..b7f68ede 100644 Binary files a/packages/ludic.render3d/shaders/spv/3b183d8f.frag.spv and b/packages/ludic.render3d/shaders/spv/3b183d8f.frag.spv differ diff --git a/packages/ludic.render3d/shaders/spv/4dda406a.frag.spv b/packages/ludic.render3d/shaders/spv/4dda406a.frag.spv index ffff9851..979b3edb 100644 Binary files a/packages/ludic.render3d/shaders/spv/4dda406a.frag.spv and b/packages/ludic.render3d/shaders/spv/4dda406a.frag.spv differ diff --git a/packages/ludic.render3d/shaders/spv/74258189.frag.spv b/packages/ludic.render3d/shaders/spv/74258189.frag.spv new file mode 100644 index 00000000..08ccc2ae Binary files /dev/null and b/packages/ludic.render3d/shaders/spv/74258189.frag.spv differ diff --git a/packages/ludic.render3d/shaders/spv/74258189.vert.spv b/packages/ludic.render3d/shaders/spv/74258189.vert.spv new file mode 100644 index 00000000..55a54733 Binary files /dev/null and b/packages/ludic.render3d/shaders/spv/74258189.vert.spv differ diff --git a/packages/ludic.render3d/shaders/spv/7ed7de52.vert.spv b/packages/ludic.render3d/shaders/spv/7ed7de52.vert.spv index e41094fd..7481d30e 100644 Binary files a/packages/ludic.render3d/shaders/spv/7ed7de52.vert.spv and b/packages/ludic.render3d/shaders/spv/7ed7de52.vert.spv differ diff --git a/packages/ludic.render3d/shaders/spv/7f096322.vert.spv b/packages/ludic.render3d/shaders/spv/7f096322.vert.spv index 277a763b..417ebd22 100644 Binary files a/packages/ludic.render3d/shaders/spv/7f096322.vert.spv and b/packages/ludic.render3d/shaders/spv/7f096322.vert.spv differ diff --git a/packages/ludic.render3d/shaders/spv/b4587cd4.frag.spv b/packages/ludic.render3d/shaders/spv/b4587cd4.frag.spv index 5237c174..039ec70f 100644 Binary files a/packages/ludic.render3d/shaders/spv/b4587cd4.frag.spv and b/packages/ludic.render3d/shaders/spv/b4587cd4.frag.spv differ diff --git a/packages/ludic.render3d/shaders/spv/manifest.txt b/packages/ludic.render3d/shaders/spv/manifest.txt index dccebf07..2669e724 100644 --- a/packages/ludic.render3d/shaders/spv/manifest.txt +++ b/packages/ludic.render3d/shaders/spv/manifest.txt @@ -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 diff --git a/packages/ludic.render3d/shaders/variants.list b/packages/ludic.render3d/shaders/variants.list index 8dc8e83e..b7f76b11 100644 --- a/packages/ludic.render3d/shaders/variants.list +++ b/packages/ludic.render3d/shaders/variants.list @@ -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| diff --git a/packages/ludic.render3d/shaders/volumetric.frag b/packages/ludic.render3d/shaders/volumetric.frag new file mode 100644 index 00000000..2250d40e --- /dev/null +++ b/packages/ludic.render3d/shaders/volumetric.frag @@ -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); +}