From f8c81e40669cb0b02946798569703899bbd74194 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Tue, 29 Sep 2026 16:22:45 +0300 Subject: [PATCH] render3d: the uniform ring is 16 MB a half and grows when a frame outgrows it; streamed layers are sized for the fog's reach The ring held 2 x 64 MB of host memory for a frame that uses 3 MB at most (2761 draws at the overlook, 1.7 MB in town): it starts at 16 MB a half, and a frame that ever runs out grows it for the frames after (gvk_ring_grow, making the kept sets again; R3D_RING_KB= starts small to watch it). -95 MB at every fog level, off included. A streamed layer's arrays and its stream's arena are made for the reach a fog wall leaves (twice its area's share, at least 4096 instances) and emptied to refill within the frame budget (fog_streams.ludic). The near streams' reach is already inside most walls, so it is -9 MB at 30 m. R3D_VKMEM adds the ring's high-water mark, the largest buffers and the streamed layers' share. Footprint at the overlook: 2008 MB off (2108 before this phase), 1748 MB at 175 m, 1682 MB at 30 m. Co-Authored-By: Claude Opus 5.5 --- packages/ludic.render3d/env.ludic | 3 ++ packages/ludic.render3d/fog_streams.ludic | 48 +++++++++++++++++++ packages/ludic.render3d/gpu_vk_draw.ludic | 26 +++++++++- packages/ludic.render3d/r3d.ludic | 1 + packages/ludic.render3d/render.ludic | 1 + packages/ludic.render3d/stream.ludic | 5 +- packages/ludic.render3d/vkmem_report.ludic | 56 +++++++++++++++++++++- 7 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 packages/ludic.render3d/fog_streams.ludic diff --git a/packages/ludic.render3d/env.ludic b/packages/ludic.render3d/env.ludic index 3a879c4d..2933f465 100644 --- a/packages/ludic.render3d/env.ludic +++ b/packages/ludic.render3d/env.ludic @@ -198,6 +198,9 @@ export state Render3dState { gvk_al_map: []pointer = null gvk_al_tag: []int = null # per allocation id: owner * 4 + image * 2 + host (vkmem_report.ludic) gvk_tag: int = 0 # the owner allocations are made for now (VKM_*) + gvk_ring_half: int = 0 # the ring half a frame writes, once it has had to grow (gvk_ring_grow) + gvk_ring_full: bool = false # a frame ran out of ring: grow it at the next reset + gvk_ring_peak: int = 0 # the most of a ring half a frame has used (R3D_VKMEM) vkmem_at: int = 0 # R3D_VKMEM=: the report is printed at that frame sh_layers: int = 5 # the shadow array's layers: the cascades the fog wall needs (shadow_fog) fog_imp_released: int = 0 # impostor atlases let go under a fog wall (fog_impostors.ludic) diff --git a/packages/ludic.render3d/fog_streams.ludic b/packages/ludic.render3d/fog_streams.ludic new file mode 100644 index 00000000..1c684c2a --- /dev/null +++ b/packages/ludic.render3d/fog_streams.ludic @@ -0,0 +1,48 @@ +# fog_streams.ludic — a streamed layer's arrays and its stream's cache are sized for the reach the fog +# wall leaves it. Both were made for the stream's whole reach (800 m for the far cover) and kept +# whatever the fog; under a wall the ground cover inside it is a sliver of that. + +const FOG_STREAM_SLACK: float = 2.0 # room over the area's share: the bands are not evenly dense +const FOG_STREAM_MIN: int = 4096 # instances: the least a layer is ever given + +# the capacity a stream's layer needs at the wall: its area's share of the whole reach, with slack +function fog_stream_cap(render3d_st: Render3dState, s: Stream) -> int { + let reach = r3d_reach(render3d_st, s.reach) + if render3d_st.r3d_fog_wall <= 0.0 or reach <= 0.0 or reach >= s.reach { return s.cap0 } + let k = (reach + s.size) / (s.reach + s.size) + let share = Math.min(1.0, k * k * FOG_STREAM_SLACK) + return Math.max(FOG_STREAM_MIN, Math.min(s.cap0, int(float(s.cap0) * share))) +} + +# the layer's arrays and the stream's arena made at that capacity, and the cache emptied to refill +@alloc_ok("a fog change from Settings: a streamed layer's arrays made again at the reach the wall leaves") +function fog_stream(render3d_st: mut Render3dState, s: Stream) -> void { + let cap = fog_stream_cap(render3d_st, s) + let l = s.layer + if cap == l.cap { return } + free(l.inst); free(l.scratch) + l.cap = cap; l.have = cap; l.count = 0 + l.inst = floats(cap * INST_FLOATS) + l.scratch = floats(cap * INST_FLOATS) + if l.lvl != null { free(l.lvl); l.lvl = words(cap) } + free(s.arena) + s.arena = words(Math.max(cap, 4096) * 2 * INST_FLOATS) + s.top = 0 + let chunks = s.chunks + while len(chunks) > 0 { + let c = List.pop(chunks) + c.off = -1 + push(s.spare, c) + } + s.n = 0 + for h in 0 .. STREAM_HASH { s.htab[h] = 0 } + # regrown within the frame budget from the next update, not all in one frame + s.last_cx = 999998 + s.pending = true + l.view_gen = -1 +} + +function fog_streams(render3d_st: mut Render3dState) -> void { + if render3d_st.stream_all == null { return } + for i in 0 .. len(render3d_st.stream_all) { fog_stream(render3d_st, render3d_st.stream_all[i]) } +} diff --git a/packages/ludic.render3d/gpu_vk_draw.ludic b/packages/ludic.render3d/gpu_vk_draw.ludic index 585bf974..1625907b 100644 --- a/packages/ludic.render3d/gpu_vk_draw.ludic +++ b/packages/ludic.render3d/gpu_vk_draw.ludic @@ -715,13 +715,30 @@ function gvk_bind_texture(render3d_st: mut Render3dState, p: int, name: string, # Each draw's blocks are copied into one host-visible ring buffer at the device's alignment and # its descriptor set comes from a pool that is reset with the frame. Both are rewound at # gvk_frame_reset. -const GVK_RING_BYTES: int = 64 * 1024 * 1024 +# 16 MB a half: the valley's heaviest measured frame used 3 MB (2761 draws), and a frame that ever +# needs more grows it for the frames after (gvk_ring_grow). At 64 MB it held 128 MB for 3 MB of use. +const GVK_RING_BYTES: int = 16 * 1024 * 1024 const GVK_RING_SMALL: int = 4 * 1024 * 1024 # a UI's frame: a few hundred small blocks function gvk_ring_bytes(render3d_st: Render3dState) -> int { + if render3d_st.gvk_ring_half > 0 { return render3d_st.gvk_ring_half } if render3d_st.r3d_small { return GVK_RING_SMALL } return GVK_RING_BYTES } +# a frame ran out of ring: the next frame starts on one twice the size. The kept descriptor sets bind +# the ring buffer itself, so they are made again; the frame that ran out lost those draws once +function gvk_ring_grow(render3d_st: mut Render3dState) -> void { + render3d_st.gvk_ring_full = false + gvk_frame_wait(render3d_st) + let half = gvk_ring_bytes(render3d_st) * 2 + gvk_buf_release(render3d_st, render3d_st.gvk_ring_buf) + if not gvk_buf_reserve(render3d_st, render3d_st.gvk_ring_buf, half * 2) { return } + render3d_st.gvk_ring_half = half + gvk_kpool_reset(render3d_st) + gvk_say_ring_grew(half) +} +function gvk_say_ring_grew(half: int) -> void { print(`r3d: vulkan: a frame outgrew the uniform ring; {half / 1048576} MB a half from now`) } + @alloc_ok("start-up: the device, its tables, the programs, the passes and the world's first textures are made once, before play") function gvk_frame_init(render3d_st: mut Render3dState) -> bool { let props = bytes(VkPhysicalDeviceProperties_sizeof) @@ -735,6 +752,8 @@ function gvk_frame_init(render3d_st: mut Render3dState) -> bool { render3d_st.gvk_msaa_max = 1 if (counts & VK_SAMPLE_COUNT_2_BIT) != 0 { render3d_st.gvk_msaa_max = 2 } if (counts & VK_SAMPLE_COUNT_4_BIT) != 0 { render3d_st.gvk_msaa_max = 4 } + # R3D_RING_KB=: start on a ring that small, to watch it grow + if r3d_env_has(render3d_st, "R3D_RING_KB") { render3d_st.gvk_ring_half = Text.to_int(r3d_env(render3d_st, "R3D_RING_KB")) * 1024 } render3d_st.gvk_ring_buf = gvk_buf_new(render3d_st) # one half per frame slot: the cached sets bind this one buffer, and each draw's offset says the half if not gvk_buf_reserve(render3d_st, render3d_st.gvk_ring_buf, gvk_ring_bytes(render3d_st) * 2) { return false } @@ -774,6 +793,7 @@ function gvk_frame_reset(render3d_st: mut Render3dState) -> void { # pool filled - 8192 sets, hours of play: past GVK_SC_DEAD_MAX releases the pool starts over, and a # draw still in use makes its set again the first time it is drawn if render3d_st.gvk_sc_dead >= GVK_SC_DEAD_MAX { gvk_kpool_reset(render3d_st) } + if render3d_st.gvk_ring_full { gvk_ring_grow(render3d_st) } let slot = render3d_st.gvk_frame_no & 1 render3d_st.gvk_ring_off = slot * gvk_ring_bytes(render3d_st) render3d_st.gvk_ring_end = (slot + 1) * gvk_ring_bytes(render3d_st) @@ -784,9 +804,11 @@ function gvk_frame_reset(render3d_st: mut Render3dState) -> void { # a block into the ring; its offset, or -1 when the frame has used the whole ring function gvk_ring_put(render3d_st: mut Render3dState, blk: pointer, n: int) -> int { let at = (render3d_st.gvk_ring_off + render3d_st.gvk_ring_align - 1) / render3d_st.gvk_ring_align * render3d_st.gvk_ring_align - if at + n > render3d_st.gvk_ring_end { return -1 } + if at + n > render3d_st.gvk_ring_end { render3d_st.gvk_ring_full = true; return -1 } mem_copy(mem_off(render3d_st.gvk_buf_map[render3d_st.gvk_ring_buf], at), blk, n) render3d_st.gvk_ring_off = at + n + let used = render3d_st.gvk_ring_off - (render3d_st.gvk_ring_end - gvk_ring_bytes(render3d_st)) + if used > render3d_st.gvk_ring_peak { render3d_st.gvk_ring_peak = used } return at } diff --git a/packages/ludic.render3d/r3d.ludic b/packages/ludic.render3d/r3d.ludic index 87d9f40d..90c22f66 100644 --- a/packages/ludic.render3d/r3d.ludic +++ b/packages/ludic.render3d/r3d.ludic @@ -38,6 +38,7 @@ import "fog_casters.ludic" import "fog_impostors.ludic" import "actor.ludic" import "stream.ludic" +import "fog_streams.ludic" import "grass.ludic" import "grass_gpu.ludic" import "water.ludic" diff --git a/packages/ludic.render3d/render.ludic b/packages/ludic.render3d/render.ludic index f2cf20b4..81c63e92 100644 --- a/packages/ludic.render3d/render.ludic +++ b/packages/ludic.render3d/render.ludic @@ -61,6 +61,7 @@ export function r3d_fog_wall(render3d_st: mut Render3dState, dist: float) -> voi cam_update(render3d_st) fog_impostors(render3d_st) shadow_fog(render3d_st) + fog_streams(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 { diff --git a/packages/ludic.render3d/stream.ludic b/packages/ludic.render3d/stream.ludic index 8e00126b..bb2a5463 100644 --- a/packages/ludic.render3d/stream.ludic +++ b/packages/ludic.render3d/stream.ludic @@ -39,7 +39,8 @@ property Stream { htab: words, # open-addressed key -> chunk index + 1 (0 = empty) arena: words, # every kept chunk's instances, in the order of `chunks` top: int = 0, # words of the arena in use - spare: []Chunk # records not in use: all of them are made with the stream + spare: []Chunk, # records not in use: all of them are made with the stream + cap0: int = 0 # the layer's capacity for the whole reach (fog_streams.ludic) } # microseconds spent per frame, split so the hitch can be attributed (R3D_PROF=1) @@ -82,6 +83,8 @@ function stream_new__t(render3d_st: mut Render3dState, layer: Layer, size: float for i in 0 .. STREAM_HASH { s.htab[i] = 0 } if render3d_st.stream_all == null { render3d_st.stream_all = new []Stream } push(render3d_st.stream_all, s) + s.cap0 = layer.cap + fog_stream(render3d_st, s) return s } diff --git a/packages/ludic.render3d/vkmem_report.ludic b/packages/ludic.render3d/vkmem_report.ludic index b8688f59..b34696de 100644 --- a/packages/ludic.render3d/vkmem_report.ludic +++ b/packages/ludic.render3d/vkmem_report.ludic @@ -47,6 +47,9 @@ function vkmem_report(render3d_st: Render3dState) -> void { for k in 0 .. VKM_N { if cnt[k] > 0 { vkmem_say_row(vkmem_name(k), img[k], buf[k], host[k], cnt[k]) } } vkmem_big(render3d_st) vkmem_cpu(render3d_st) + vkmem_layers(render3d_st) + vkmem_big_bufs(render3d_st) + vkmem_say_ring(render3d_st.gvk_ring_peak, gvk_ring_bytes(render3d_st)) } # the largest images, with their owner, size and format @@ -93,11 +96,15 @@ function vkmem_say_img(name: string, n: int, w: int, h: int, layers: int, levels function vkmem_cpu(render3d_st: Render3dState) -> void { var lay = 0 var cap = 0 + var st = 0 for i in 0 .. len(render3d_st.sc_layers) { let l = render3d_st.sc_layers[i] - lay += vkmem_fl(l.inst) + vkmem_fl(l.scratch) + vkmem_fl(l.gsorted) + vkmem_fl(l.vis) + let b = vkmem_fl(l.inst) + vkmem_fl(l.scratch) + vkmem_fl(l.gsorted) + vkmem_fl(l.vis) + lay += b + if l.streamed { st += b } cap += l.cap } + vkmem_say_streamed(st) var str = 0 if render3d_st.stream_all != null { for i in 0 .. len(render3d_st.stream_all) { str += len(render3d_st.stream_all[i].arena) * 4 } } let ter = vkmem_fl(render3d_st.ter_heights) + TERRAIN_RES * TERRAIN_RES * 3 @@ -108,3 +115,50 @@ function vkmem_fl(f: floats) -> int { return len(f) * 4 } function vkmem_say_cpu(n: int, cap: int, lay: int, str: int, ter: int, water: int) -> void { print(`vkmem: cpu: {n} scatter layers (cap {cap} instances) {vkmem_mb(lay)} MB, stream arenas {vkmem_mb(str)} MB, terrain copies {vkmem_mb(ter)} MB, water cells {vkmem_mb(water)} MB`) } + +# the scatter layers' GPU buffers by use: drawn, cards, casters, levels, the GPU cull's +function vkmem_layers(render3d_st: Render3dState) -> void { + var main = 0 + var imp = 0 + var sh = 0 + var lod = 0 + var gc = 0 + var fog = 0 + for i in 0 .. len(render3d_st.sc_layers) { + let l = render3d_st.sc_layers[i] + main += vkmem_buf(render3d_st, l.buf); imp += vkmem_buf(render3d_st, l.imp_buf); sh += vkmem_buf(render3d_st, l.sh_buf) + fog += vkmem_buf(render3d_st, l.fog_buf) + if l.lod_buf != null { for k in 0 .. len(l.lod_buf) { lod += vkmem_buf(render3d_st, l.lod_buf[k]) } } + gc += vkmem_buf(render3d_st, l.g_src) + vkmem_buf(render3d_st, l.g_dst) + vkmem_buf(render3d_st, l.g_cmds) + vkmem_buf(render3d_st, l.g_counts) + } + vkmem_say_layers(main, imp, sh, lod, gc, fog) +} +function vkmem_buf(render3d_st: Render3dState, b: int) -> int { + if b <= 0 or b >= len(render3d_st.gvk_buf_size) { return 0 } + return render3d_st.gvk_buf_size[b] +} +function vkmem_say_layers(main: int, imp: int, sh: int, lod: int, gc: int, fog: int) -> void { print(`vkmem: scatter buffers: drawn {vkmem_mb(main)} MB, cards {vkmem_mb(imp)} MB, whole-map casters {vkmem_mb(sh)} MB, levels {vkmem_mb(lod)} MB, GPU cull {vkmem_mb(gc)} MB, fog casters {vkmem_mb(fog)} MB`) } + +# the largest buffer allocations: their size and owner, and the buffer handle that holds each +@alloc_ok("a report printed once, under R3D_VKMEM") +function vkmem_big_bufs(render3d_st: Render3dState) -> void { + let n = len(render3d_st.gvk_al_len) + let done = words(n) + for i in 0 .. n { done[i] = 0 } + for r in 0 .. 15 { + var best = -1 + var bn = 0 + for a in 1 .. n { + if done[a] != 0 or (render3d_st.gvk_al_tag[a] & 2) != 0 { continue } + if render3d_st.gvk_al_len[a] > bn { bn = render3d_st.gvk_al_len[a]; best = a } + } + if best < 0 { return } + done[best] = 1 + var holder = -1 + for b in 1 .. len(render3d_st.gvk_buf_mem) { if int(render3d_st.gvk_buf_mem[b]) == best { holder = b } } + vkmem_say_buf(vkmem_name(render3d_st.gvk_al_tag[best] / 4), bn, holder) + } +} +function vkmem_say_buf(name: string, n: int, holder: int) -> void { print(`vkmem: buffer {n / 1024} KB handle {holder} {name}`) } +function vkmem_say_ring(peak: int, half: int) -> void { print(`vkmem: uniform ring: {peak / 1024} KB used at most of a frame, {half / 1048576} MB per half`) } +function vkmem_say_streamed(n: int) -> void { print(`vkmem: cpu: of which the streamed layers {vkmem_mb(n)} MB`) }