ludic/packages/ludic.render3d/render.ludic
Orkuncakilkaya 2ce2d997be render3d: the fog wall culls on the CPU - nothing past it costs a draw, a vertex or a caster
cam_sphere_visible refuses a sphere wholly past the wall (terrain patches, scatter cells, stream
chunks, grass tiles, water), actors past it are skipped for draws, casters and outlines, the grass
reach and the streams' generate-and-gather reach end at it, and the card shadows cast only from
the wall's share of a layer (fog_casters.ludic, rebuilt every 4 m). r3d_beyond_fog is exported for
the game to skip animating what will not be drawn. Render-only: no query of the ground or the world
changes, and off is the old frame (0 pixels over 8 against 160ce96, twice per side).

Draws per frame at the overlook: 2757 off, 1104 at 175 m, 484 at 30 m.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:38:00 +03:00

326 lines
19 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.
# ============================================================================
# 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.
# 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.
# 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.
# set before r3d_init to build the landscape from a real height map
# A plate: no terrain, no grass, no water - the sky, the sun, the shadows and whatever the scene
# draws (a lab's stage, ludic.lab). Set before the load; nothing of the landscape is made, so
# its hundreds of megabytes of height field, materials and grass never exist.
function r3d_plate_mode(render3d_st: mut Render3dState, on: bool) -> void { render3d_st.r3d_plate = on }
# the sky's HDR image, when it is not the default photograph under r3d_assets
# R3D_NOPREPASS=1: light the foliage the old way, every card behind the front one included
function r3d_prepass_off(render3d_st: mut Render3dState) -> bool {
if render3d_st.r3d_prepass_env < 0 { render3d_st.r3d_prepass_env = 0; if r3d_env_has(render3d_st, "R3D_NOPREPASS") { render3d_st.r3d_prepass_env = 1 } }
return render3d_st.r3d_prepass_env == 1
}
function fog_bind(render3d_st: mut Render3dState, prog: int) -> void {
u_f(render3d_st, gpu_uniform(render3d_st, prog, "u_clip_y"), render3d_st.r3d_clip_y)
u_f(render3d_st, gpu_uniform(render3d_st, prog, "u_spec_scale"), 1.0)
u_f(render3d_st, gpu_uniform(render3d_st, prog, "u_fog_density"), render3d_st.r3d_fog_density)
u_f(render3d_st, gpu_uniform(render3d_st, prog, "u_fog_height_falloff"), render3d_st.r3d_fog_falloff)
u_f(render3d_st, gpu_uniform(render3d_st, prog, "u_fog_base"), render3d_st.r3d_fog_base)
u_f(render3d_st, gpu_uniform(render3d_st, prog, "u_fog_inscatter"), render3d_st.r3d_fog_inscatter)
u_f(render3d_st, gpu_uniform(render3d_st, prog, "u_fog_desat"), render3d_st.r3d_fog_desat)
u_f(render3d_st, gpu_uniform(render3d_st, prog, "u_fog_wall"), render3d_st.r3d_fog_wall)
u_f3(render3d_st, gpu_uniform(render3d_st, prog, "u_ground_alb"), render3d_st.r3d_ground_alb_r, render3d_st.r3d_ground_alb_g, render3d_st.r3d_ground_alb_b)
u_f3(render3d_st, gpu_uniform(render3d_st, prog, "u_ground_alb_hi"), render3d_st.r3d_ground_hi_r, render3d_st.r3d_ground_hi_g, render3d_st.r3d_ground_hi_b)
u_f(render3d_st, gpu_uniform(render3d_st, prog, "u_ground_hi_y"), render3d_st.r3d_ground_hi_y)
u_f(render3d_st, gpu_uniform(render3d_st, prog, "u_ground_hi_w"), render3d_st.r3d_ground_hi_w)
var cs = render3d_st.r3d_cloud_shadow
if r3d_env_has(render3d_st, "R3D_NOCLOUD") { cs = 0.0 }
u_f(render3d_st, gpu_uniform(render3d_st, prog, "u_cloud_shadow"), cs)
u_f(render3d_st, gpu_uniform(render3d_st, prog, "u_time"), render3d_st.r3d_time)
}
# The fog wall (a game's fog setting): dist > 0 hides everything past dist in the sky's colour - from
# three tenths of it to all of it - and draws nothing past it in any pass: the far plane, every scatter
# layer's reach, the shadow cascades. dist <= 0 leaves everything as it was.
const R3D_FOG_MARGIN: float = 8.0
export function r3d_fog_wall(render3d_st: mut Render3dState, dist: float) -> void {
var d = dist
if d < 0.0 { d = 0.0 }
if d == render3d_st.r3d_fog_wall { return }
render3d_st.r3d_fog_wall = d
# every layer and stream re-gathers for the new reach
render3d_st.sc_view_gen += 1
cam_update(render3d_st)
}
# how far anything is drawn: the fog wall and a margin, or `far` (0: no limit of its own) without one
function r3d_reach(render3d_st: Render3dState, far: float) -> float {
if render3d_st.r3d_fog_wall <= 0.0 { return far }
let w = render3d_st.r3d_fog_wall + R3D_FOG_MARGIN
if far <= 0.0 or far > w { return w }
return far
}
# profiling switches (environment): R3D_NOSHADOW R3D_NOGI R3D_MSAA=n R3D_NOBLADES R3D_NOCARDS R3D_NOTREES R3D_NEAR=m
function r3d_env_flags(render3d_st: mut Render3dState) -> void {
render3d_st.r3d_no_shadow = r3d_env_has(render3d_st, "R3D_NOSHADOW")
render3d_st.r3d_no_trees = r3d_env_has(render3d_st, "R3D_NOTREES")
render3d_st.r3d_no_refl = r3d_env_has(render3d_st, "R3D_NOREFL")
# R3D_FOG_WALL=<m>: the fog wall at that distance, for a shot (a game sets it with r3d_fog_wall)
if r3d_env_has(render3d_st, "R3D_FOG_WALL") { render3d_st.r3d_fog_wall = float(Text.to_int(r3d_env(render3d_st, "R3D_FOG_WALL"))) }
if r3d_env_has(render3d_st, "R3D_DEBUG") { render3d_st.r3d_debug = true }
if r3d_env_has(render3d_st, "R3D_DBGSHADOW") { render3d_st.r3d_debug_shadow = true }
if r3d_env_has(render3d_st, "R3D_NOGI") { render3d_st.post_gi_strength = 0.0; render3d_st.post_ao_strength = 0.0; render3d_st.post_no_gi = true }
if r3d_env_has(render3d_st, "R3D_MSAA") { render3d_st.post_ms_samples = Text.to_int(r3d_env(render3d_st, "R3D_MSAA")) }
render3d_st.sc_skip_blade = r3d_env_has(render3d_st, "R3D_NOBLADES")
render3d_st.sc_skip_grass = r3d_env_has(render3d_st, "R3D_NOGRASS")
render3d_st.grass_force = r3d_env_has(render3d_st, "R3D_BLADES")
render3d_st.grass_env_off = r3d_env_has(render3d_st, "R3D_NOBLADES")
render3d_st.sc_skip_card = r3d_env_has(render3d_st, "R3D_NOCARDS")
render3d_st.sc_dbg_lod = r3d_env_has(render3d_st, "R3D_LODDBG")
if r3d_env_has(render3d_st, "R3D_ANISO") {
let a = Text.to_int(r3d_env(render3d_st, "R3D_ANISO"))
render3d_st.tex_anisotropy = 1.0
if a >= 2 { render3d_st.tex_anisotropy = 2.0 }
if a >= 4 { render3d_st.tex_anisotropy = 4.0 }
if a >= 8 { render3d_st.tex_anisotropy = 8.0 }
if a >= 16 { render3d_st.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(render3d_st: mut Render3dState, w: int, h: int, title: string) -> bool {
if not r3d_open(render3d_st, w, h, title) { return false }
for i in 0 .. r3d_load_count() { if not r3d_load_step(render3d_st, i) { return false } }
return true
}
# the window and the graphics backend; nothing is baked yet, but a frame can be presented
function r3d_open(render3d_st: mut Render3dState, w: int, h: int, title: string) -> bool {
r3d_env_flags(render3d_st)
# Vulkan or nothing: a machine it cannot start on stops here, with the reason already printed
if gpu_select(render3d_st) != GPU_VK { return false }
if not gpu_open(render3d_st, w, h, title) { print("r3d: the Vulkan window or device could not be made"); return false }
if r3d_env_has(render3d_st, "R3D_NOVSYNC") { gpu_vsync(render3d_st, 0) }
var renderer: string = gpu_renderer_name(render3d_st)
print(`r3d: {gl_width()}x{gl_height()} on {renderer}`)
prof_init(render3d_st)
cam_init(render3d_st, float(gl_width()) / float(gl_height()))
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(render3d_st: mut Render3dState, i: int) -> bool {
let t = i - 1
if i == 0 {
# the default sky's path is made only when no path was given, and goes once the sky is read
var sky = render3d_st.r3d_sky_path
var made: string = null
if sky == null {
made = render3d_st.r3d_assets + "/hdri/kloofendal_48d_partly_cloudy_puresky_4k.hdr"
sky = made
}
let got = sky_load(render3d_st, sky)
if made != null { free(made) }
if not got { return false }
daylight_init(render3d_st)
} else if t >= 0 and t < TERRAIN_INIT_STEPS {
if render3d_st.r3d_plate { return true }
if t == 0 {
if render3d_st.r3d_dem_path != null { terrain_use_dem(render3d_st, render3d_st.r3d_dem_path, render3d_st.r3d_dem_min, render3d_st.r3d_dem_max, render3d_st.r3d_dem_base, render3d_st.r3d_dem_ox, render3d_st.r3d_dem_oz) }
if render3d_st.r3d_ortho_path != null { terrain_use_ortho(render3d_st, render3d_st.r3d_ortho_path) }
}
terrain_init_step(render3d_st, t)
} else if i == 1 + TERRAIN_INIT_STEPS {
shadow_init(render3d_st)
post_init(render3d_st, gl_width(), gl_height())
} else if i == 2 + TERRAIN_INIT_STEPS {
scatter_init(render3d_st)
actor_init(render3d_st)
} else if i == 3 + TERRAIN_INIT_STEPS {
if not render3d_st.r3d_plate { grass_init(render3d_st) }
render3d_st.r3d_sky_prog = r3d_program(render3d_st, "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.
render3d_st.r3d_fog_density = 0.00024
render3d_st.r3d_fog_falloff = 0.002
render3d_st.r3d_ready = true
gpu_check(render3d_st, "r3d init")
}
return true
}
function r3d_draw_sky(render3d_st: mut Render3dState) -> void {
gpu_depth_func(render3d_st, GL_LEQUAL)
gpu_depth_write(render3d_st, false)
gpu_cull(render3d_st, false)
let p = render3d_st.r3d_sky_prog
gpu_use_program(render3d_st, p)
r3d_bind_2d(render3d_st, p, "u_sky", 0, render3d_st.sky_tex)
sky_bind_rot(render3d_st, p)
sky_bind_lighting(render3d_st, p)
u_mat4(render3d_st, gpu_uniform(render3d_st, p, "u_inv_vp"), render3d_st.cam_inv_vp)
u_f(render3d_st, gpu_uniform(render3d_st, p, "u_sky_gain"), 0.95)
u_f(render3d_st, gpu_uniform(render3d_st, p, "u_sky_relight"), render3d_st.r3d_sky_relight)
u_f(render3d_st, gpu_uniform(render3d_st, p, "u_sky_sat"), 1.35)
u_f(render3d_st, gpu_uniform(render3d_st, p, "u_fog_wall"), render3d_st.r3d_fog_wall)
u_f(render3d_st, gpu_uniform(render3d_st, p, "u_fog_inscatter"), render3d_st.r3d_fog_inscatter)
u_f(render3d_st, gpu_uniform(render3d_st, p, "u_time"), render3d_st.r3d_time)
mesh_draw(render3d_st, render3d_st.sky_fullscreen)
gpu_depth_write(render3d_st, true)
gpu_depth_func(render3d_st, 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(render3d_st: mut Render3dState, path: string) -> bool { return gpu_screenshot(render3d_st, path) }
function r3d_present(render3d_st: mut Render3dState) -> void { gpu_present(render3d_st) }
# the drawable changed size: the camera's aspect and every screen-sized target follow
function r3d_resize(render3d_st: mut Render3dState) -> void {
render3d_st.cam_aspect = float(gl_width()) / float(gl_height())
cam_update(render3d_st)
post_free(render3d_st)
post_init(render3d_st, gl_width(), gl_height())
if render3d_st.water_refl != null { target_free(render3d_st, render3d_st.water_refl); render3d_st.water_refl = null }
r3d_say_resized()
}
function r3d_frame(render3d_st: mut Render3dState, time: float) -> void {
outline_frame(render3d_st)
if not render3d_st.r3d_ready { return }
if gpu_resize_check(render3d_st) { r3d_resize(render3d_st) }
# 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.
render3d_st.r3d_test_frame += 1
if render3d_st.r3d_test_resize == 0 and r3d_env_has(render3d_st, "R3D_RESIZE_AT") { render3d_st.r3d_test_resize = Text.to_int(r3d_env(render3d_st, "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 render3d_st.r3d_test_resize > 0 and render3d_st.r3d_test_frame >= render3d_st.r3d_test_resize and render3d_st.r3d_test_frame % 4 == 0 {
let step = (render3d_st.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_set_drawable(w, h)
r3d_resize(render3d_st)
}
render3d_st.r3d_time = time
gsl_frame_start(render3d_st)
# 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(render3d_st)
prof_mark_start(render3d_st)
cam_begin_frame(render3d_st, render3d_st.post_frame, gl_width(), gl_height())
# 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 render3d_st.r3d_cam_log < 0 { render3d_st.r3d_cam_log = 0; if r3d_env_has(render3d_st, "R3D_CAM_LOG") { render3d_st.r3d_cam_log = 1 } }
if render3d_st.r3d_cam_log == 1 {
r3d_say_test_frame(render3d_st)
}
# the height-field shadow rebakes as the light moves in steps (daylight), or with the sky yaw when there is no clock
if not render3d_st.r3d_plate and ((not render3d_st.day_on and render3d_st.ter_shadow_yaw != render3d_st.sky_yaw) or render3d_st.ter_shadow_gen != render3d_st.day_gen) {
render3d_st.ter_shadow_gen = render3d_st.day_gen
let t_bk = gl_now_us()
terrain_bake_shadow(render3d_st)
prof_bake_add(render3d_st, gl_now_us() - t_bk)
}
scatter_begin_frame(render3d_st)
prof_cpu_mark(render3d_st, "shadow rebake")
stream_update_all(render3d_st)
prof_cpu_mark(render3d_st, "streaming")
gg_cull_frame(render3d_st) # before any pass: a dispatch inside one would split it
if not render3d_st.r3d_no_shadow { prof_begin(render3d_st, "shadow"); shadow_pass(render3d_st); prof_end(render3d_st) }
prof_cpu_mark(render3d_st, "shadow pass")
if render3d_st.water_on and not render3d_st.r3d_no_refl and water_reflect_visible(render3d_st) { prof_begin(render3d_st, "water reflection"); water_reflection_pass(render3d_st); prof_end(render3d_st) }
prof_cpu_mark(render3d_st, "reflection")
post_begin_scene(render3d_st)
# 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(render3d_st) {
prof_begin(render3d_st, "foliage prepass")
gpu_color_write(render3d_st, false)
scatter_draw_depth(render3d_st)
gpu_color_write(render3d_st, true)
prof_end(render3d_st)
render3d_st.sc_prepass = true
}
prof_cpu_mark(render3d_st, "foliage prepass")
if not render3d_st.r3d_plate {
prof_begin(render3d_st, "terrain sun")
terrain_sun_prepare(render3d_st)
prof_end(render3d_st)
prof_begin(render3d_st, "terrain")
terrain_draw(render3d_st)
prof_end(render3d_st)
}
prof_cpu_mark(render3d_st, "terrain")
prof_begin(render3d_st, "scene (vegetation)")
r3d_scene_draw(render3d_st)
prof_end(render3d_st)
render3d_st.sc_prepass = false
prof_cpu_mark(render3d_st, "vegetation")
prof_begin(render3d_st, "grass")
grass_draw(render3d_st)
prof_end(render3d_st)
prof_cpu_mark(render3d_st, "grass")
prof_begin(render3d_st, "sky")
r3d_draw_sky(render3d_st)
prof_end(render3d_st)
prof_begin(render3d_st, "resolve MSAA")
post_resolve(render3d_st)
prof_end(render3d_st)
# 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 render3d_st.water_on {
prof_begin(render3d_st, "water surface")
post_capture_scene(render3d_st)
target_bind(render3d_st, render3d_st.post_hdr)
gpu_depth_test(render3d_st, true)
gpu_depth_func(render3d_st, GL_LESS)
water_draw(render3d_st, render3d_st.post_depth_copy.depth)
prof_end(render3d_st)
}
render3d_st.post_color = render3d_st.post_hdr.color; render3d_st.post_color_w = render3d_st.post_w; render3d_st.post_color_h = render3d_st.post_h
# DLSS super resolution: the lit frame up to the display's size, before anything reads it
if gsl_dlss_live(render3d_st) { prof_begin(render3d_st, "DLSS"); render3d_st.post_color = gsl_dlss_eval(render3d_st); prof_end(render3d_st) }
# Before bloom and before the tonemap: a shaft of light is a bright thing in the air and
# should bloom like one.
prof_begin(render3d_st, "volumetric"); post_volumetric_pass(render3d_st); prof_end(render3d_st)
prof_begin(render3d_st, "dof"); post_dof_pass(render3d_st); prof_end(render3d_st)
if not render3d_st.post_no_gi { prof_begin(render3d_st, "SSAO/GI"); post_ssao_pass(render3d_st); prof_end(render3d_st) }
if render3d_st.r3d_debug_max { tex_max(render3d_st, render3d_st.post_hdr.color, render3d_st.post_hdr.w, render3d_st.post_hdr.h, "hdr") }
prof_begin(render3d_st, "bloom")
post_bloom_pass(render3d_st)
prof_end(render3d_st)
prof_begin(render3d_st, "tonemap+exposure")
post_tonemap(render3d_st, render3d_st.post_color)
prof_end(render3d_st)
prof_cpu_mark(render3d_st, "post")
prof_begin(render3d_st, "prev-colour copy")
post_capture_prev(render3d_st)
prof_end(render3d_st)
prof_collect(render3d_st)
gpu_check(render3d_st, "frame")
}
# messages, each built in a function of its own so the path that says it holds no allocation
@alloc_ok("a message, built only when it is said: a failure, a warning or a debug switch")
function r3d_say_resized() -> void { print(`r3d: resized to {gl_width()}x{gl_height()}`) }
@alloc_ok("a message, built only when it is said: a failure, a warning or a debug switch")
function r3d_say_test_frame(render3d_st: Render3dState) -> void { print(`cam {render3d_st.r3d_test_frame} pos {int(render3d_st.cam_pos[0] * 1000.0)} {int(render3d_st.cam_pos[1] * 1000.0)} {int(render3d_st.cam_pos[2] * 1000.0)} yaw {int(render3d_st.cam_yaw * 1000.0)} pitch {int(render3d_st.cam_pitch * 1000.0)} jitter {int(render3d_st.gsl_jitter_x * 1000000.0)} {int(render3d_st.gsl_jitter_y * 1000000.0)} render {render3d_st.post_w}x{render3d_st.post_h} reset {render3d_st.gsl_reset} evalok {render3d_st.gsl_eval_ok} fresh {render3d_st.gsl_fresh}`) }