From f2b976c976de673a9e8eb86aae4bd56ea50c18ff Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Tue, 15 Sep 2026 14:46:55 +0300 Subject: [PATCH] feat(render3d): load steps for a loading screen, four quality switches, and the ring's uniform usage - r3d_open opens the window with nothing baked; r3d_load_count / r3d_load_step run the rest (sky and light, terrain, shadow and screen targets, cover and actors, grass) so a game can present a loading frame between them. r3d_init is the same calls in a row. - sky_set_quality(width): the prefiltered sky light at 256 / 512 / 1024, baked again. - post_set_msaa(samples): multisampling on OpenGL, remade in place (post_msaa_live is false on Vulkan); post_free frees the multisampled framebuffer too. - STREAM_BUDGET_US is a variable; r3d_fog_scale multiplies the fog the day sets. - Vulkan buffers carry UNIFORM_BUFFER usage: the frame's ring is bound as uniform buffers and was created without it (VUID-VkWriteDescriptorSet-descriptorType-00330, found on MoltenVK). OpenGL frames at the five viewpoints unchanged; the game's 59 self-tests pass. Co-Authored-By: Claude Opus 5 --- changes/quality-switches.md | 11 +++++ changes/r3d-load-steps.md | 7 +++ packages/ludic.render3d/daylight.ludic | 2 +- packages/ludic.render3d/gpu_vk_res.ludic | 2 +- packages/ludic.render3d/post.ludic | 17 ++++++++ packages/ludic.render3d/render.ludic | 54 +++++++++++++++++------- packages/ludic.render3d/sky.ludic | 15 +++++-- packages/ludic.render3d/stream.ludic | 2 +- 8 files changed, 89 insertions(+), 21 deletions(-) create mode 100644 changes/quality-switches.md create mode 100644 changes/r3d-load-steps.md diff --git a/changes/quality-switches.md b/changes/quality-switches.md new file mode 100644 index 00000000..ed61304d --- /dev/null +++ b/changes/quality-switches.md @@ -0,0 +1,11 @@ +bump: minor +type: feat +**More of the renderer's quality is a switch a game can offer.** + +- `sky_set_quality(width)`: the prefiltered sky light at 256, 512 or 1024 wide (half as tall), + baked again at once. +- `post_set_msaa(samples)`: 1, 2 or 4 samples for the scene on OpenGL, remaking the screen targets in + place (`post_msaa_live()` is false on Vulkan, which has no sample counts yet); `post_free` now + frees the multisampled framebuffer too. +- `STREAM_BUDGET_US` is a variable: the microseconds a frame may spend generating streamed cover. +- `r3d_fog_scale`: a multiplier over the fog density the daylight and the weather set. diff --git a/changes/r3d-load-steps.md b/changes/r3d-load-steps.md new file mode 100644 index 00000000..92222b42 --- /dev/null +++ b/changes/r3d-load-steps.md @@ -0,0 +1,7 @@ +bump: minor +type: feat +**A loading screen can show the renderer's start-up as it happens.** `r3d_open(w, h, title)` opens +the window and the graphics backend with nothing baked, and `r3d_load_count()` / `r3d_load_step(i)` +run the rest - the sky and its light, the terrain, the shadow and screen targets, the scattered cover +and actors, the grass - one step at a time, so a game can draw and present a frame between them. +`r3d_init` is those same calls in a row and is unchanged for everything that uses it. diff --git a/packages/ludic.render3d/daylight.ludic b/packages/ludic.render3d/daylight.ludic index 9e4cfbf9..2a7c16c8 100644 --- a/packages/ludic.render3d/daylight.ludic +++ b/packages/ludic.render3d/daylight.ludic @@ -143,7 +143,7 @@ function daylight_set(hours: int) -> void { let dim = f_sub(F_ONE, f_mul(fl(0.55), oc)) for i in 0 .. 3 { day_ibl[i] = f_mul(f_lerp(day_ibl[i], grey, f_mul(fl(0.7), oc)), dim) } if day_fog_base == 0 { day_fog_base = r3d_fog_density } - r3d_fog_density = f_mul(day_fog_base, day_fog_mul) + r3d_fog_density = f_mul(f_mul(day_fog_base, day_fog_mul), r3d_fog_scale) # 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 # full-moon night and a new-moon one diff --git a/packages/ludic.render3d/gpu_vk_res.ludic b/packages/ludic.render3d/gpu_vk_res.ludic index 870e9a8f..4cc5e3b9 100644 --- a/packages/ludic.render3d/gpu_vk_res.ludic +++ b/packages/ludic.render3d/gpu_vk_res.ludic @@ -585,7 +585,7 @@ function gvk_buf_reserve(b: int, n: int) -> bool { Vk.zero(bci, VkBufferCreateInfo_sizeof) Vk.put_i32(bci, VkBufferCreateInfo_sType, VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO) Vk.put_i64(bci, VkBufferCreateInfo_size, size_l) - Vk.put_i32(bci, VkBufferCreateInfo_usage, VK_BUFFER_USAGE_VERTEX_BUFFER_BIT | VK_BUFFER_USAGE_INDEX_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_INDIRECT_BUFFER_BIT) + Vk.put_i32(bci, VkBufferCreateInfo_usage, VK_BUFFER_USAGE_VERTEX_BUFFER_BIT | VK_BUFFER_USAGE_INDEX_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_INDIRECT_BUFFER_BIT | VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT) Vk.put_i32(bci, VkBufferCreateInfo_sharingMode, VK_SHARING_MODE_EXCLUSIVE) let out = bytes(8) var r = Vk.create_buffer(gvk_dev, bci, null, out) diff --git a/packages/ludic.render3d/post.ludic b/packages/ludic.render3d/post.ludic index fed1592c..f5217f93 100644 --- a/packages/ludic.render3d/post.ludic +++ b/packages/ludic.render3d/post.ludic @@ -50,11 +50,28 @@ var post_grain: int = 0 # the screen-sized targets go away before post_init makes them at a new size 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) for i in 0 .. len(post_bloom) { target_free(post_bloom[i]) } post_hdr = null } +# Multisampled scene: 1 (temporal AA alone), 2 or 4. OpenGL remakes the scene targets at once; +# the Vulkan backend has no sample counts yet and keeps drawing single-sampled (post_msaa_live says so). +function post_msaa_live() -> bool { return gpu_is_gl() } +function post_set_msaa(n: int) -> void { + var want = n + if want < 1 { want = 1 } + if not post_msaa_live() { want = 1 } + if want == post_ms_samples { return } + post_ms_samples = want + if post_hdr != null { + let w = post_w; let h = post_h + post_free() + post_init(w, h) + } +} + function post_init(w: int, h: int) -> void { post_w = w; post_h = h post_hdr = target_new(w, h, GL_RGBA16F, GL_RGBA, GL_HALF_FLOAT, true, GL_LINEAR) diff --git a/packages/ludic.render3d/render.ludic b/packages/ludic.render3d/render.ludic index a43b76f5..737c1d32 100644 --- a/packages/ludic.render3d/render.ludic +++ b/packages/ludic.render3d/render.ludic @@ -7,6 +7,7 @@ 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 var r3d_time: int = 0 @@ -70,7 +71,16 @@ function r3d_env_flags() -> void { 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 } @@ -79,21 +89,35 @@ function r3d_init(w: int, h: int, title: string) -> bool { print(`r3d: {gl_w}x{gl_h} on {renderer}`) prof_init() cam_init(fr(gl_w, gl_h)) - if not sky_load(r3d_assets + "/hdri/kloofendal_48d_partly_cloudy_puresky_4k.hdr") { return false } - daylight_init() - 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() - shadow_init() - post_init(gl_w, gl_h) - scatter_init() - actor_init() - grass_init() - r3d_sky_prog = r3d_program("fullscreen.vert", "sky.frag", "") - r3d_fog_density = fl(0.00014) - r3d_fog_falloff = fl(0.002) - r3d_ready = true - gpu_check("r3d init") + return true +} + +# The load, as steps a loading screen can show between: 0 the sky and its image-based light, +# 1 the terrain (height, normals, shadow, materials), 2 the shadow maps and the screen targets, +# 3 the scattered cover and the actors, 4 the grass and the sky pass. The order is r3d_init's. +function r3d_load_count() -> int { return 5 } +function r3d_load_step(i: int) -> bool { + if i == 0 { + if not sky_load(r3d_assets + "/hdri/kloofendal_48d_partly_cloudy_puresky_4k.hdr") { return false } + daylight_init() + } else if i == 1 { + 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() + } else if i == 2 { + shadow_init() + post_init(gl_w, gl_h) + } else if i == 3 { + scatter_init() + actor_init() + } else if i == 4 { + grass_init() + r3d_sky_prog = r3d_program("fullscreen.vert", "sky.frag", "") + r3d_fog_density = fl(0.00014) + r3d_fog_falloff = fl(0.002) + r3d_ready = true + gpu_check("r3d init") + } return true } diff --git a/packages/ludic.render3d/sky.ludic b/packages/ludic.render3d/sky.ludic index fee6ec86..10e336f7 100644 --- a/packages/ludic.render3d/sky.ludic +++ b/packages/ludic.render3d/sky.ludic @@ -85,6 +85,15 @@ function sky_convolve(prog: int, target_tex: int, layer: int, w: int, h: int, ro gpu_fb_free(fbo) } +# the prefiltered specular's width per level (its height is half): 256, 512 or 1024. The image +# the reflections and the rough sheen are lit from; sky_set_quality bakes it again at a new size. +var sky_prefilter_w: int = 512 +function sky_set_quality(w: int) -> void { + if w < 64 or w == sky_prefilter_w { return } + sky_prefilter_w = w + if sky_irradiance != 0 { sky_precompute() } +} + function sky_precompute() -> void { gpu_depth_test(false) if sky_irradiance != 0 { @@ -99,17 +108,17 @@ function sky_precompute() -> void { gpu_tex_bind(GPU_TEX2D, sky_irradiance) gpu_tex_param(GPU_TEX2D, GL_TEXTURE_WRAP_S, GL_REPEAT) sky_convolve(p_irr, sky_irradiance, -1, 128, 64, F_ZERO) - # prefiltered specular: 6 roughness levels, 512 x 256 each, as a 2D array + # prefiltered specular: 6 roughness levels, sky_prefilter_w x half each, as a 2D array let p_pre = sky_p_pre sky_prefilter = gpu_tex_new() gpu_tex_bind(GPU_TEX2D_ARRAY, sky_prefilter) - gpu_tex_image3d(GL_RGB16F, 512, 256, SKY_PREFILTER_LEVELS, GL_RGB, GL_FLOAT, null) + gpu_tex_image3d(GL_RGB16F, sky_prefilter_w, sky_prefilter_w / 2, SKY_PREFILTER_LEVELS, GL_RGB, GL_FLOAT, null) gpu_tex_param(GPU_TEX2D_ARRAY, GL_TEXTURE_WRAP_S, GL_REPEAT) gpu_tex_param(GPU_TEX2D_ARRAY, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE) gpu_tex_param(GPU_TEX2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_LINEAR) gpu_tex_param(GPU_TEX2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_LINEAR) for l in 0 .. SKY_PREFILTER_LEVELS { - sky_convolve(p_pre, sky_prefilter, l, 512, 256, fr(l, SKY_PREFILTER_LEVELS - 1)) + sky_convolve(p_pre, sky_prefilter, l, sky_prefilter_w, sky_prefilter_w / 2, fr(l, SKY_PREFILTER_LEVELS - 1)) } # BRDF LUT let p_brdf = sky_p_brdf diff --git a/packages/ludic.render3d/stream.ludic b/packages/ludic.render3d/stream.ludic index 15db50a5..61399a9d 100644 --- a/packages/ludic.render3d/stream.ludic +++ b/packages/ludic.render3d/stream.ludic @@ -143,7 +143,7 @@ function stream_band(s: Stream, d: int) -> int { # by band and kind. With a real microsecond clock the budget can just be the thing we # actually care about — how long this frame is allowed to spend growing ground cover. # Overshoot is bounded by one chunk, so keep chunks small on the dense near streams. -const STREAM_BUDGET_US: int = 2500 # microseconds of generation per frame +var STREAM_BUDGET_US: int = 2500 # microseconds of generation per frame; a setting may move it var stream_deadline: long = 0 const STREAM_BUDGET: int = 8000 # kept for the work counter only # The worst frame is now bounded by one chunk, not by the budget: stream_fill emits a