ludic/packages/ludic.render3d/render.ludic
Orkuncakilkaya d34fb5bc63 feat(render3d): the air and the light are the hour's, not one constant apiece
Aerial perspective is a curve now. A low sun shines through far more air than a
high one and shines ALONG the ground rather than down onto it, so density, height
falloff and forward scatter all ride the sun's elevation; overcast thickens the air
and flattens the scatter, because a grey sky has no disc to scatter from. `lowsun`
falls away BELOW the horizon as well as above it, or the middle of the night gets a
dawn's haze with no dawn to justify it.

The term that was missing entirely is distance DESATURATION. Blending a saturated
green ridge toward a saturated blue noon sky leaves a saturated ridge - which is why
the same valley read as a photograph at dusk, where the fog colour happened to be a
warm grey, and as a toy at one o'clock. A surface is now pulled toward its own
luminance faster than the fog itself arrives. Measured far/near saturation at the
camp: 07:00 1.11 -> 0.89, 09:00 1.04 -> 0.93, 13:00 0.98 -> 0.89.

The grade is the hour's too - nine literals bound at the draw, written by
daylight_set now. Noon is the case worth naming: direct sun is warm-white and the
only thing filling a midday shadow is a blue sky, so noon gets a cool balance over a
blue-lifted shadow with hard contrast, and dawn and dusk the reverse. Ground R-B,
lit vs shadowed: 07:00 +42.8/+14.2 -> +48.9/+15.1, 13:00 +32.2/+14.8 -> +25.2/+2.9.
Gain is left alone deliberately: the grade is `c * gain + lift * (1 - c)`, so warming
it warms the whole frame, and warming it at noon made one o'clock yellower than seven
in the morning - the opposite of the point.

The visible sky is relit. Turning a photograph on its axis does not change what
colour it was taken at, so every sunset had a mid-morning blue overhead. An analytic
sky supplies the chroma and the photograph keeps the luminance: the cloud stays where
it is and goes orange at dusk, the zenith goes deep blue at noon, and no second sky
is shipped. It fades out under the horizon and eases off under cloud.

The ground bounce follows the ground, crossing meadow to rock at the map's treeline
instead of being one green constant everywhere including above the scree.

R3D_NOAIR=1 restores all of it, so a before-and-after comes from one binary at one
hour; it joins R3D_NOCLOUD / R3D_NOSHADOW / R3D_NOGI.

Verified on macOS OpenGL, macOS Vulkan (MoltenVK) and Windows Vulkan (RTX 3070 Ti).
Backends agree: mean difference 0.15-0.88/255 within a machine. Across machines the
ORIGINAL renderer already differed by 5.02/255 at 19:12 and this build differs by
2.80, so cross-platform variance is pre-existing and did not grow. 400 frames: GL
7.4 s before and after, VK 7.0 s before and after.

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

302 lines
13 KiB
Text

# ============================================================================
# render.ludic — the frame. Shadow cascades, the HDR scene pass (terrain, the
# scene's objects, the sky), bloom, and the tonemapped composite to the screen.
# The scene (what the game places in the world) hooks in through scene_draw /
# scene_draw_casters, which the demo defines.
# ============================================================================
var r3d_sky_prog: int = 0
var r3d_fog_density: int = 0
var r3d_fog_scale: int = 0x3F800000 # float bits: a setting's multiplier over the density the day sets
var r3d_fog_falloff: int = 0
var r3d_fog_base: int = 0 # the height fog is measured from (float bits); 0 = y = 0
# Both of these are the DAY's, set by daylight_set from the sun's own elevation. The inscatter
# is how hard the air scatters the sun forward - nearly nothing at noon, and the glow a ridge
# is silhouetted against at dusk. The desaturation is how fast distance takes a surface's own
# colour away, which is the term that was missing entirely and is most of why a midday frame
# had no depth in it at all.
var r3d_fog_inscatter: int = 0x3CA3D70A # 0.02, what the shader used to hard-code
var r3d_fog_desat: int = 0x3FACCCCD # 1.35
# How much of the visible sky's colour comes from the analytic model rather than from the
# photograph (sky.frag). The day fades it in: at night the sky has its own tint, a star field
# and a moon, and relighting on top of those would only wash them out.
var r3d_sky_relight: int = 0
# What the ground reflects back up, low and high, and the height they cross at. The defaults
# are Maroon Lake's - a green basin under a grey-rock treeline at about 480 m over the datum.
var r3d_ground_alb_r: int = 0x3E99999A; var r3d_ground_alb_g: int = 0x3EAE147B; var r3d_ground_alb_b: int = 0x3E0F5C29
var r3d_ground_hi_r: int = 0x3E93F7CF; var r3d_ground_hi_g: int = 0x3E8F5C29; var r3d_ground_hi_b: int = 0x3E851EB8
var r3d_ground_hi_y: int = 0x43F00000 # 480
var r3d_ground_hi_w: int = 0x43160000 # 150
var r3d_time: int = 0
var r3d_ready: bool = false
# set before r3d_init to build the landscape from a real height map
var r3d_dem_path: string = null
var r3d_dem_min: int = 0
var r3d_dem_max: int = 0
var r3d_dem_base: int = 0
var r3d_dem_ox: int = 0
var r3d_dem_oz: int = 0
var r3d_ortho_path: string = null
var r3d_debug: bool = false
var r3d_debug_shadow: bool = false
var r3d_debug_max: bool = false
var r3d_cloud_shadow: int = 0x3F000000 # 0.5
var r3d_clip_y: int = 0xCF000000 # -2^31: no clipping
# R3D_NOPREPASS=1: light the foliage the old way, every card behind the front one included
var r3d_prepass_env: int = -1
function r3d_prepass_off() -> bool {
if r3d_prepass_env < 0 { r3d_prepass_env = 0; if r3d_env_has("R3D_NOPREPASS") { r3d_prepass_env = 1 } }
return r3d_prepass_env == 1
}
function fog_bind(prog: int) -> void {
u_f(gpu_uniform(prog, "u_clip_y"), r3d_clip_y)
u_f(gpu_uniform(prog, "u_spec_scale"), F_ONE)
u_f(gpu_uniform(prog, "u_fog_density"), r3d_fog_density)
u_f(gpu_uniform(prog, "u_fog_height_falloff"), r3d_fog_falloff)
u_f(gpu_uniform(prog, "u_fog_base"), r3d_fog_base)
u_f(gpu_uniform(prog, "u_fog_inscatter"), r3d_fog_inscatter)
u_f(gpu_uniform(prog, "u_fog_desat"), r3d_fog_desat)
u_f3(gpu_uniform(prog, "u_ground_alb"), r3d_ground_alb_r, r3d_ground_alb_g, r3d_ground_alb_b)
u_f3(gpu_uniform(prog, "u_ground_alb_hi"), r3d_ground_hi_r, r3d_ground_hi_g, r3d_ground_hi_b)
u_f(gpu_uniform(prog, "u_ground_hi_y"), r3d_ground_hi_y)
u_f(gpu_uniform(prog, "u_ground_hi_w"), r3d_ground_hi_w)
var cs = r3d_cloud_shadow
if r3d_env_has("R3D_NOCLOUD") { cs = F_ZERO }
u_f(gpu_uniform(prog, "u_cloud_shadow"), cs)
u_f(gpu_uniform(prog, "u_time"), r3d_time)
}
# profiling switches (environment): R3D_NOSHADOW R3D_NOGI R3D_MSAA=n R3D_NOBLADES R3D_NOCARDS R3D_NOTREES R3D_NEAR=m
var r3d_test_frame: int = 0
var r3d_test_resize: int = 0
var r3d_no_shadow: bool = false
var r3d_no_trees: bool = false
var r3d_no_refl: bool = false
function r3d_env_flags() -> void {
r3d_no_shadow = r3d_env_has("R3D_NOSHADOW")
r3d_no_trees = r3d_env_has("R3D_NOTREES")
r3d_no_refl = r3d_env_has("R3D_NOREFL")
if r3d_env_has("R3D_DEBUG") { r3d_debug = true }
if r3d_env_has("R3D_DBGSHADOW") { r3d_debug_shadow = true }
if r3d_env_has("R3D_NOGI") { post_gi_strength = F_ZERO; post_ao_strength = F_ZERO; post_no_gi = true }
if r3d_env_has("R3D_MSAA") { post_ms_samples = Text.to_int(r3d_env("R3D_MSAA")) }
sc_skip_blade = r3d_env_has("R3D_NOBLADES")
sc_skip_card = r3d_env_has("R3D_NOCARDS")
sc_dbg_lod = r3d_env_has("R3D_LODDBG")
if r3d_env_has("R3D_ANISO") {
let a = Text.to_int(r3d_env("R3D_ANISO"))
tex_anisotropy = 1.0
if a >= 2 { tex_anisotropy = 2.0 }
if a >= 4 { tex_anisotropy = 4.0 }
if a >= 8 { tex_anisotropy = 8.0 }
if a >= 16 { tex_anisotropy = 16.0 }
}
}
# Start the renderer in one call: the window, then every load step in order. A game that shows
# a loading screen calls r3d_open, draws its screen, and runs r3d_load_step itself between frames.
function r3d_init(w: int, h: int, title: string) -> bool {
if not r3d_open(w, h, title) { return false }
for i in 0 .. r3d_load_count() { if not r3d_load_step(i) { return false } }
return true
}
# the window and the graphics backend; nothing is baked yet, but a frame can be presented
function r3d_open(w: int, h: int, title: string) -> bool {
r3d_env_flags()
gpu_select()
if not gpu_open(w, h, title) { print("r3d: no OpenGL context"); return false }
if r3d_env_has("R3D_NOVSYNC") { gpu_vsync(0) }
var renderer: string = gpu_renderer_name()
print(`r3d: {gl_w}x{gl_h} on {renderer}`)
prof_init()
cam_init(fr(gl_w, gl_h))
return true
}
# The load, as steps a loading screen can show between: 0 the sky and its image-based light,
# 1 - 4 the terrain (the height field and normals, its sun shadow, its materials, its patches and
# programs), 5 the shadow maps and the screen targets, 6 the scattered cover and the actors, 7 the
# grass and the sky pass. The order is r3d_init's.
function r3d_load_count() -> int { return 4 + TERRAIN_INIT_STEPS }
function r3d_load_step(i: int) -> bool {
let t = i - 1
if i == 0 {
if not sky_load(r3d_assets + "/hdri/kloofendal_48d_partly_cloudy_puresky_4k.hdr") { return false }
daylight_init()
} else if t >= 0 and t < TERRAIN_INIT_STEPS {
if t == 0 {
if r3d_dem_path != null { terrain_use_dem(r3d_dem_path, r3d_dem_min, r3d_dem_max, r3d_dem_base, r3d_dem_ox, r3d_dem_oz) }
if r3d_ortho_path != null { terrain_use_ortho(r3d_ortho_path) }
}
terrain_init_step(t)
} else if i == 1 + TERRAIN_INIT_STEPS {
shadow_init()
post_init(gl_w, gl_h)
} else if i == 2 + TERRAIN_INIT_STEPS {
scatter_init()
actor_init()
} else if i == 3 + TERRAIN_INIT_STEPS {
grass_init()
r3d_sky_prog = r3d_program("fullscreen.vert", "sky.frag", "")
# The base is NOON's air, and it was tuned when nothing desaturated with distance - so it
# was doing its whole job through colour and could not be raised without the valley going
# blue. With the desaturation term carrying the depth, the air can be as thick as real air
# at 2900 m and the far ridge recedes instead of tinting.
r3d_fog_density = fl(0.00024)
r3d_fog_falloff = fl(0.002)
r3d_ready = true
gpu_check("r3d init")
}
return true
}
function r3d_draw_sky() -> void {
gpu_depth_func(GL_LEQUAL)
gpu_depth_write(false)
gpu_cull(false)
let p = r3d_sky_prog
gpu_use_program(p)
r3d_bind_2d(p, "u_sky", 0, sky_tex)
sky_bind_rot(p)
sky_bind_lighting(p)
u_mat4(gpu_uniform(p, "u_inv_vp"), cam_inv_vp)
u_f(gpu_uniform(p, "u_sky_gain"), fl(0.95))
u_f(gpu_uniform(p, "u_sky_relight"), r3d_sky_relight)
u_f(gpu_uniform(p, "u_sky_sat"), fl(1.35))
u_f(gpu_uniform(p, "u_time"), r3d_time)
mesh_draw(sky_fullscreen)
gpu_depth_write(true)
gpu_depth_func(GL_LESS)
}
# The end of the game's frame, whichever backend draws it: a screenshot first if one is wanted,
# then the present. A game calls these rather than Gl.swap / Gl.screenshot.
function r3d_screenshot(path: string) -> bool { return gpu_screenshot(path) }
function r3d_present() -> void { gpu_present() }
# the drawable changed size: the camera's aspect and every screen-sized target follow
function r3d_resize() -> void {
cam_aspect = fr(gl_w, gl_h)
cam_update()
post_free()
post_init(gl_w, gl_h)
if water_refl != null { target_free(water_refl); water_refl = null }
print(`r3d: resized to {gl_w}x{gl_h}`)
}
var r3d_cam_log: int = -1
function r3d_frame(time: int) -> void {
gpu_glcheck_after("the time between frames")
outline_frame()
if not r3d_ready { return }
if gpu_resize_check() { r3d_resize() }
# R3D_RESIZE_AT=<frame>: rebuild every screen-sized buffer mid-run, as a window resize
# or a fullscreen change does. Headless has no window to resize, and this path is where
# a stale attachment or a texture freed twice shows up.
r3d_test_frame += 1
if r3d_test_resize == 0 and r3d_env_has("R3D_RESIZE_AT") { r3d_test_resize = Text.to_int(r3d_env("R3D_RESIZE_AT")) }
# R3D_RESIZE_AT=<n>: from frame n on, rebuild every screen-sized buffer every few
# frames at a different size, as dragging a window edge or entering fullscreen does.
if r3d_test_resize > 0 and r3d_test_frame >= r3d_test_resize and r3d_test_frame % 4 == 0 {
let step = (r3d_test_frame / 4) % 4
var w = 1920; var h = 1080
if step == 1 { w = 1440; h = 810 }
if step == 2 { w = 2560; h = 1440 }
if step == 3 { w = 1281; h = 721 }
gl_w = w; gl_h = h
r3d_resize()
}
r3d_time = time
gsl_frame_start()
# the frame's counters close here, before any of its own work: the window each of them
# covers is exactly one frame, from this point to the same point next time
prof_gen_frame()
prof_mark_start()
cam_begin_frame(post_frame, gl_w, gl_h)
# R3D_CAM_LOG=1: the camera each frame, in millimetres and thousandths of a radian - to tell a
# camera that moves while the hiker stands still from a picture that shakes on its own
if r3d_cam_log < 0 { r3d_cam_log = 0; if r3d_env_has("R3D_CAM_LOG") { r3d_cam_log = 1 } }
if r3d_cam_log == 1 {
print(`cam {r3d_test_frame} pos {f_to_int(f_mul(cam_pos[0], fi(1000)))} {f_to_int(f_mul(cam_pos[1], fi(1000)))} {f_to_int(f_mul(cam_pos[2], fi(1000)))} yaw {f_to_int(f_mul(cam_yaw, fi(1000)))} pitch {f_to_int(f_mul(cam_pitch, fi(1000)))} jitter {f_to_int(f_mul(gsl_jitter_x, fi(1000000)))} {f_to_int(f_mul(gsl_jitter_y, fi(1000000)))} render {post_w}x{post_h} reset {gsl_reset} evalok {gsl_eval_ok} fresh {gsl_fresh}`)
}
# the height-field shadow rebakes as the light moves in steps (daylight), or with the sky yaw when there is no clock
if (not day_on and ter_shadow_yaw != sky_yaw) or ter_shadow_gen != day_gen {
ter_shadow_gen = day_gen
let t_bk = gl_now_us()
terrain_bake_shadow()
prof_bake_add(gl_now_us() - t_bk)
}
scatter_begin_frame()
prof_cpu_mark("shadow rebake")
stream_update_all()
prof_cpu_mark("streaming")
if not r3d_no_shadow { prof_begin("shadow"); shadow_pass(); prof_end() }
prof_cpu_mark("shadow pass")
if water_on and not r3d_no_refl and water_reflect_visible() { prof_begin("water reflection"); water_reflection_pass(); prof_end() }
prof_cpu_mark("reflection")
post_begin_scene()
# the near tree foliage lays its depth down before anything is shaded, so the terrain
# under the stands and the cards behind the front ones are rejected before lighting.
# It has to come straight after post_begin_scene: terrain_sun_prepare binds its own
# target, and depth drawn after it lands in the sun buffer, not the scene's.
if not r3d_prepass_off() {
prof_begin("foliage prepass")
gpu_color_write(false)
scatter_draw_depth()
gpu_color_write(true)
prof_end()
sc_prepass = true
}
prof_cpu_mark("foliage prepass")
prof_begin("terrain sun")
terrain_sun_prepare()
prof_end()
prof_begin("terrain")
terrain_draw()
prof_end()
prof_cpu_mark("terrain")
prof_begin("scene (vegetation)")
scene_draw()
prof_end()
sc_prepass = false
prof_cpu_mark("vegetation")
prof_begin("grass")
grass_draw()
prof_end()
prof_cpu_mark("grass")
prof_begin("sky")
r3d_draw_sky()
prof_end()
prof_begin("resolve MSAA")
post_resolve()
prof_end()
# transparent water over the resolved frame: it tests against the frame's own
# depth and reads a copy of it for the depth tint and soft shores
if water_on {
prof_begin("water surface")
post_capture_scene()
target_bind(post_hdr)
gpu_depth_test(true)
gpu_depth_func(GL_LESS)
water_draw(post_depth_copy.depth)
prof_end()
}
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() }
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")
post_bloom_pass()
prof_end()
prof_begin("tonemap+exposure")
post_tonemap(post_color)
prof_end()
prof_cpu_mark("post")
prof_begin("prev-colour copy")
post_capture_prev()
prof_end()
prof_collect()
gpu_check("frame")
}