From 2bb7ae188355af3d5a97803130741ac9ee4bd5c1 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Tue, 29 Sep 2026 14:45:02 +0300 Subject: [PATCH 1/2] render3d/vk: a buffer re-filled every frame ping-pongs between two instead of being made again With one frame in flight, a buffer filled every frame (the interface's vertex buffer, ov_vbo) was still being read by the frame on the GPU when the next frame filled it, so gvk_buf_reserve released it and made a new VkBuffer and its memory every frame - and every new buffer took a prime submit of its own. Each handle now keeps a second buffer (gvk_bsp_*): a busy one swaps with it when it is free, else the busy one becomes the second and one new buffer is made; releasing a handle lets both go. The launcher window (--launcher-ui, R3D_VK_PROF, per 120 frames): buffers made/destroyed 120/120 -> 0/0, command buffers 240 -> 120 (one a frame). Its footprint does not move with it (358 MB at 40 s, both): the churn was work, not memory. Co-Authored-By: Claude Opus 5.5 --- packages/ludic.render3d/env.ludic | 5 ++ packages/ludic.render3d/gpu_vk_res.ludic | 77 +++++++++++++++++++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/packages/ludic.render3d/env.ludic b/packages/ludic.render3d/env.ludic index be8e2417..e5d2d485 100644 --- a/packages/ludic.render3d/env.ludic +++ b/packages/ludic.render3d/env.ludic @@ -382,6 +382,11 @@ export state Render3dState { gvk_buf_size: []int = null gvk_buf_map: []pointer = null # host-visible buffers stay mapped for their whole life gvk_buf_used: []int = null # the frame (gvk_frame_no) a draw last bound the buffer in + gvk_bsp_buf: []long = null # per handle, a second buffer a re-filled one swaps with (gvk_buf_reserve) + gvk_bsp_mem: []long = null + gvk_bsp_size: []int = null + gvk_bsp_map: []pointer = null + gvk_bsp_used: []int = null gvk_frame_no: int = 1 @max(4096) gvk_retired_buf: []long = null @max(4096) gvk_retired_mem: []long = null diff --git a/packages/ludic.render3d/gpu_vk_res.ludic b/packages/ludic.render3d/gpu_vk_res.ludic index 94b27e96..efa903eb 100644 --- a/packages/ludic.render3d/gpu_vk_res.ludic +++ b/packages/ludic.render3d/gpu_vk_res.ludic @@ -643,8 +643,11 @@ function gvk_buf_new(render3d_st: mut Render3dState) -> int { if render3d_st.gvk_buf == null { render3d_st.gvk_buf = new []long; render3d_st.gvk_buf_mem = new []long; render3d_st.gvk_buf_size = new []int; render3d_st.gvk_buf_map = new []pointer render3d_st.gvk_buf_used = new []int; render3d_st.gvk_retired_buf = new []long; render3d_st.gvk_retired_mem = new []long + render3d_st.gvk_bsp_buf = new []long; render3d_st.gvk_bsp_mem = new []long; render3d_st.gvk_bsp_size = new []int + render3d_st.gvk_bsp_map = new []pointer; render3d_st.gvk_bsp_used = new []int # handle 0 is "no buffer", as it is on OpenGL push(render3d_st.gvk_buf, zero); push(render3d_st.gvk_buf_mem, zero); push(render3d_st.gvk_buf_size, 0); push(render3d_st.gvk_buf_map, null); push(render3d_st.gvk_buf_used, 0) + gvk_bsp_grow(render3d_st) } # a handle a deleted buffer gave back first (gvk_buf_delete) if render3d_st.gvk_buf_spare != null and len(render3d_st.gvk_buf_spare) > 0 { @@ -654,6 +657,7 @@ function gvk_buf_new(render3d_st: mut Render3dState) -> int { return b } push(render3d_st.gvk_buf, zero); push(render3d_st.gvk_buf_mem, zero); push(render3d_st.gvk_buf_size, 0); push(render3d_st.gvk_buf_map, null); push(render3d_st.gvk_buf_used, 0) + gvk_bsp_grow(render3d_st) return len(render3d_st.gvk_buf) - 1 } @@ -670,6 +674,7 @@ function gvk_buf_delete(render3d_st: mut Render3dState, b: int) -> void { } function gvk_buf_release(render3d_st: mut Render3dState, b: int) -> void { + gvk_bsp_drop(render3d_st, b) if render3d_st.gvk_buf[b] == 0 { return } let zero: long = 0 if gvk_buf_busy(render3d_st, b) { @@ -740,7 +745,15 @@ function gvk_buf_reserve(render3d_st: mut Render3dState, b: int, n: int) -> bool if b <= 0 or b >= len(render3d_st.gvk_buf) { return false } # already big enough, and no draw this frame reads what is there: fill it in place if render3d_st.gvk_buf[b] != 0 and render3d_st.gvk_buf_size[b] >= n and (not gvk_buf_busy(render3d_st, b) or gvk_buf_is_gpu(render3d_st, b)) { return true } - gvk_buf_release(render3d_st, b) + # big enough but read by a frame still on the GPU: its second buffer, when that one is free, and + # else the busy one becomes the second and a new one is made - a buffer re-filled every frame (the + # interface's vertices) ping-pongs between two, where it was made and destroyed every frame + if render3d_st.gvk_buf[b] != 0 and render3d_st.gvk_buf_size[b] >= n { + if gvk_bsp_swap(render3d_st, b, n) { return true } + gvk_bsp_park(render3d_st, b) + } else { + gvk_buf_release(render3d_st, b) + } var size = n if size < 64 { size = 64 } let size_l: long = size @@ -853,3 +866,65 @@ function gvk_prime_tables(render3d_st: mut Render3dState) -> void { let none: long = 0 for i in 0 .. GVK_PRIME_MAX { push(hs, none) } } + +# ---- a re-filled buffer's second: gvk_buf_reserve swaps to it while the first is still being read ---- +@alloc_ok("one slot per buffer handle, beside the handle's own (gvk_buf_new)") +function gvk_bsp_grow(render3d_st: mut Render3dState) -> void { + let zero: long = 0 + push(render3d_st.gvk_bsp_buf, zero) + push(render3d_st.gvk_bsp_mem, zero) + push(render3d_st.gvk_bsp_size, 0) + push(render3d_st.gvk_bsp_map, null) + push(render3d_st.gvk_bsp_used, 0) +} +function gvk_bsp_busy(render3d_st: Render3dState, b: int) -> bool { + let u = render3d_st.gvk_bsp_used[b] + return u == render3d_st.gvk_frame_no or (render3d_st.gvk_frame_pending and u == render3d_st.gvk_frame_pending_no) +} +# the second in place of the first when it is there, big enough and read by nothing still running +function gvk_bsp_swap(render3d_st: mut Render3dState, b: int, n: int) -> bool { + if render3d_st.gvk_bsp_buf == null or b >= len(render3d_st.gvk_bsp_buf) { return false } + if render3d_st.gvk_bsp_buf[b] == 0 or render3d_st.gvk_bsp_size[b] < n or gvk_bsp_busy(render3d_st, b) { return false } + let tb = render3d_st.gvk_buf[b] + let tm = render3d_st.gvk_buf_mem[b] + let ts = render3d_st.gvk_buf_size[b] + let tp = render3d_st.gvk_buf_map[b] + let tu = render3d_st.gvk_buf_used[b] + render3d_st.gvk_buf[b] = render3d_st.gvk_bsp_buf[b]; render3d_st.gvk_buf_mem[b] = render3d_st.gvk_bsp_mem[b] + render3d_st.gvk_buf_size[b] = render3d_st.gvk_bsp_size[b]; render3d_st.gvk_buf_map[b] = render3d_st.gvk_bsp_map[b] + render3d_st.gvk_buf_used[b] = render3d_st.gvk_bsp_used[b] + render3d_st.gvk_bsp_buf[b] = tb; render3d_st.gvk_bsp_mem[b] = tm; render3d_st.gvk_bsp_size[b] = ts + render3d_st.gvk_bsp_map[b] = tp; render3d_st.gvk_bsp_used[b] = tu + return true +} +# the busy first becomes the second (the old second let go), leaving the handle empty for a new one +function gvk_bsp_park(render3d_st: mut Render3dState, b: int) -> void { + let zero: long = 0 + gvk_bsp_drop(render3d_st, b) + render3d_st.gvk_bsp_buf[b] = render3d_st.gvk_buf[b]; render3d_st.gvk_bsp_mem[b] = render3d_st.gvk_buf_mem[b] + render3d_st.gvk_bsp_size[b] = render3d_st.gvk_buf_size[b]; render3d_st.gvk_bsp_map[b] = render3d_st.gvk_buf_map[b] + render3d_st.gvk_bsp_used[b] = render3d_st.gvk_buf_used[b] + render3d_st.gvk_buf[b] = zero; render3d_st.gvk_buf_mem[b] = zero; render3d_st.gvk_buf_size[b] = 0 + render3d_st.gvk_buf_map[b] = null; render3d_st.gvk_buf_used[b] = 0 +} +# the second let go: destroyed now, or retired until the frame that read it has finished +function gvk_bsp_drop(render3d_st: mut Render3dState, b: int) -> void { + if render3d_st.gvk_bsp_buf == null or b >= len(render3d_st.gvk_bsp_buf) or render3d_st.gvk_bsp_buf[b] == 0 { return } + let zero: long = 0 + if gvk_bsp_busy(render3d_st, b) { + @alloc_ok("within the retire lists' room made at start-up (@max 4096)") + push(render3d_st.gvk_retired_buf, render3d_st.gvk_bsp_buf[b]) + @alloc_ok("within the retire lists' room made at start-up (@max 4096)") + push(render3d_st.gvk_retired_mem, render3d_st.gvk_bsp_mem[b]) + @alloc_ok("within the retire lists' room made at start-up (@max 4096)") + while len(render3d_st.gvk_retired_frame) < len(render3d_st.gvk_retired_buf) - 1 { push(render3d_st.gvk_retired_frame, 0) } + @alloc_ok("within the retire lists' room made at start-up (@max 4096)") + push(render3d_st.gvk_retired_frame, render3d_st.gvk_bsp_used[b]) + } else { + render3d_st.gvk_mk_x_buf += 1 + Vk.destroy_buffer(render3d_st.gvk_dev, render3d_st.gvk_bsp_buf[b], render3d_st.gvk_ac) + gvk_mem_free(render3d_st, gvk_mem_id(render3d_st.gvk_bsp_mem[b])) + } + render3d_st.gvk_bsp_buf[b] = zero; render3d_st.gvk_bsp_mem[b] = zero; render3d_st.gvk_bsp_size[b] = 0 + render3d_st.gvk_bsp_map[b] = null; render3d_st.gvk_bsp_used[b] = 0 +} From db7c21e5d805a9300383c5dd718952685615fb46 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Tue, 29 Sep 2026 14:45:02 +0300 Subject: [PATCH 2/2] render3d/overlay: the font atlas kept as its coverage, one byte a texel The atlas is white glyphs on alpha and the overlay reads only the coverage, but it was uploaded RGBA8: Maroon Lake's 3072 x 2688 atlas was 43 MB of Metal memory with its mips, the largest single thing in the launcher window. overlay_font now keeps the alpha alone as R8 (ov_font_tex; any other PNG shape is uploaded as before) and overlay.frag reads .r - the white fallback texture reads 1 there as it did from .a. Launcher window, graphics regions: the atlas 43.0 -> 11.5 MB; process footprint at 25 s 402 -> 378 MB. The headless launcher shot is identical to the unchanged build's (0 pixels differ; two unchanged runs differ from each other by 1.69%, and the new one by the same against them). Co-Authored-By: Claude Opus 5.5 --- packages/ludic.render3d/overlay.ludic | 25 +++++++++++++++++- packages/ludic.render3d/shaders/overlay.frag | 4 +-- .../shaders/spv/1b654972.frag.spv | Bin 4852 -> 4852 bytes .../shaders/spv/e86f78e8.frag.spv | Bin 6824 -> 6824 bytes 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/ludic.render3d/overlay.ludic b/packages/ludic.render3d/overlay.ludic index f9c4ec1d..31e35b65 100644 --- a/packages/ludic.render3d/overlay.ludic +++ b/packages/ludic.render3d/overlay.ludic @@ -67,6 +67,29 @@ function overlay_init(render3d_st: mut Render3dState, font_dir: string) -> bool return true } +# The atlas is white glyphs on alpha, and the overlay reads only the coverage: it is kept as one +# byte a texel (R8). As RGBA a 3072 x 2688 atlas was 44 MB with its mips, the most of any UI texture. +@alloc_ok("loading a font: a load, not a frame") +function ov_font_tex(render3d_st: mut Render3dState, path: string) -> int { + let px = png_decode(render3d_st, path) + if px == null { return 0 } + if render3d_st.tex_channels != 4 or render3d_st.tex_depth != 8 { + let id0 = tex_upload(render3d_st, px, false, true) + free(px) + tex_note_size(render3d_st, id0) + return id0 + } + let n = render3d_st.tex_w * render3d_st.tex_h + let a = bytes(n) + for i in 0 .. n { a[i] = px[i * 4 + 3] } + free(px) + render3d_st.tex_channels = 1 + let id = tex_upload(render3d_st, a, false, true) + free(a) + tex_note_size(render3d_st, id) + return id +} + # Load (or swap to) the font atlas in `font_dir`: font.png and font.json. A game calls it # again to change fonts at run time - a language whose script the default atlas does not # carry brings its own. Returns false, and keeps the font it had, if there is none there. @@ -74,7 +97,7 @@ function overlay_init(render3d_st: mut Render3dState, font_dir: string) -> bool function overlay_font(render3d_st: mut Render3dState, font_dir: string) -> bool { let meta = Fs.read_text(font_dir + "/font.json") if meta == null { return false } - let tex = tex_load_ex(render3d_st, font_dir + "/font.png", false, 0) + let tex = ov_font_tex(render3d_st, font_dir + "/font.png") if tex == 0 { return false } let j = Json.parse(meta) render3d_st.ov_font_cols = jint(j, "cols", 16); render3d_st.ov_font_rows = jint(j, "rows", 6) diff --git a/packages/ludic.render3d/shaders/overlay.frag b/packages/ludic.render3d/shaders/overlay.frag index 1b3d08f9..0d3a9c30 100644 --- a/packages/ludic.render3d/shaders/overlay.frag +++ b/packages/ludic.render3d/shaders/overlay.frag @@ -1,4 +1,4 @@ -// 2D overlay: an image texture, the font atlas (white glyphs on alpha) or a flat colour, times a +// 2D overlay: an image texture, the font atlas (glyph coverage, R8) or a flat colour, times a // colour. Which one is the vertex's own: v carries 4 x the mode (0 image, 1 font, 2 flat) on top of // its real coordinate, so text, panels and an image's quads share a draw (overlay.ludic, ov_use_tex). in vec2 v_uv; @@ -27,7 +27,7 @@ void main() { if (mode > 1.5) { o_color = v_col; } else if (mode > 0.5) { - o_color = vec4(v_col.rgb, v_col.a * texture(u_font, uv).a); + o_color = vec4(v_col.rgb, v_col.a * texture(u_font, uv).r); // the atlas is its coverage alone (R8) } else { vec4 t = texture(u_tex, uv); o_color = vec4(v_col.rgb * t.rgb, v_col.a * t.a); diff --git a/packages/ludic.render3d/shaders/spv/1b654972.frag.spv b/packages/ludic.render3d/shaders/spv/1b654972.frag.spv index 2c38afa027d7b9198c5933d7dcd90f28b34a3dab..46c181f1a522968bf0767065d19ea2099e02881a 100644 GIT binary patch delta 14 VcmeyO`bBla839Iy&1VJFnE)~r1$h7f delta 14 VcmeyO`bBla839J-&1VJFnE)~-1$+Pi diff --git a/packages/ludic.render3d/shaders/spv/e86f78e8.frag.spv b/packages/ludic.render3d/shaders/spv/e86f78e8.frag.spv index eedac340ad4347728419fd85eda0a9e9447deda9..5cc0f51d4cc66e4a7b9fac179d301cfddc64ca5e 100644 GIT binary patch delta 14 VcmZ2sy25lrga{+U=138LHUK7g1Wf<{ delta 14 VcmZ2sy25lrga{+^=138LHUK7y1W*6~