diff --git a/changes/model-release.md b/changes/model-release.md new file mode 100644 index 00000000..705979c7 --- /dev/null +++ b/changes/model-release.md @@ -0,0 +1,10 @@ +bump: minor +type: feature +**A loaded model can be let go of: `model_release(model)`.** Its meshes are freed at once, and +each texture once no other model uses it - the glTF loader shares textures between models (one +path loaded once, a LOD chain borrowing its LOD0's material), so every cached texture now counts +the primitives using it, and the last one frees it and forgets it, with any remembered material +that named it. A game can unload a species' kit or a map's models when nothing of them is left, +and loading one again afterwards is a fresh load. A scatter layer takes room for what it holds +instead of its whole capacity up front (it doubles as it fills, to its cap), which in Maroon Lake +was 0.4 GB of never-filled instance arrays. `examples/rendering/release.ludic` is the check. diff --git a/examples/rendering/release.ludic b/examples/rendering/release.ludic new file mode 100644 index 00000000..9a1b787c --- /dev/null +++ b/examples/rendering/release.ludic @@ -0,0 +1,69 @@ +# release.ludic - a loaded model let go of (model_release): its meshes go at once, a texture it +# shares with another model stays until that one goes too, and a model loaded again afterwards is +# whole. Prints RELEASE OK. +# +# bin/ludic build examples/rendering/release.ludic --headless && ./build/release_headless +program Release { + numbers float + import "ludic.render3d/r3d.ludic" + + property Marker { on: int = 1 } + model Anchor { Marker } + + function scene_draw(render3d_st: mut Render3dState) -> void { } + function scene_draw_casters(render3d_st: mut Render3dState, light_vp: floats) -> void { } + function stream_fill(s: Stream, cx: int, cz: int, band: int) -> void { } + + function check(what: string, cond: bool) -> bool { + if not cond { print(`release: FAILED - {what}`) } + return cond + } + # whether handle `tex` is still a texture the renderer knows (its recorded kind is set) + function tex_live(render3d_st: mut Render3dState, tex: int) -> bool { + let o = gpu_tx_at(render3d_st, tex) + return tex != 0 and o >= 0 and render3d_st.gpu_tx[o] != 0 + } + + handler Boot(render3d_st: mut Render3dState) phase Start { + spawn Anchor {} + r3d_on_draw(render3d_st, fn scene_draw) + r3d_on_casters(render3d_st, fn scene_draw_casters) + r3d_on_stream_fill(render3d_st, fn stream_fill) + r3d_plate_mode(render3d_st, true) + let dir = "packages/ludic.lab/plate" + render3d_st.r3d_sky_path = `{dir}/sky.hdr` + if not r3d_init(render3d_st, 320, 180, "Release") { + quit() + return + } + var ok = true + # two models of the same file share its two textures + let a = gltf_load(render3d_st, dir, "plate.gltf", "plate") + let b = gltf_load(render3d_st, dir, "plate.gltf", "plate") + ok = check("both plates load", a != null and b != null and len(a.prims) > 0) and ok + let diff = a.prims[0].diff + ok = check("the plates share their textures", diff != 0 and b.prims[0].diff == diff and render3d_st.gltf_tex_n == 2) and ok + model_release(render3d_st, a) + ok = check("the first plate is emptied", len(a.prims) == 0) and ok + ok = check("what the second still uses stays", tex_live(render3d_st, diff) and render3d_st.gltf_tex_n == 2) and ok + model_release(render3d_st, b) + ok = check("the last user frees the textures", not tex_live(render3d_st, diff) and render3d_st.gltf_tex_n == 0) and ok + ok = check("and its material is forgotten", render3d_st.gltf_mat_n == 0) and ok + # one model whose two materials share a texture + let p = gltf_load(render3d_st, dir, "probe.gltf", "probe") + ok = check("the probe loads its three textures", p != null and render3d_st.gltf_tex_n == 3) and ok + model_release(render3d_st, p) + ok = check("the probe gives all three back", render3d_st.gltf_tex_n == 0 and render3d_st.gltf_mat_n == 0) and ok + # and loading again after a release is a fresh load, whole + let c = gltf_load(render3d_st, dir, "plate.gltf", "plate") + ok = check("a plate loaded again is whole", c != null and len(c.prims) > 0 and tex_live(render3d_st, c.prims[0].diff) and render3d_st.gltf_tex_n == 2) and ok + model_release(render3d_st, c) + ok = check("and goes again", render3d_st.gltf_tex_n == 0) and ok + if ok { print("RELEASE OK") } else { print("RELEASE FAILED") } + quit() + } + + handler Present(render3d_st: mut Render3dState) phase Render { + r3d_present(render3d_st) + } +} diff --git a/packages/ludic.render3d/env.ludic b/packages/ludic.render3d/env.ludic index 915b3520..13929b02 100644 --- a/packages/ludic.render3d/env.ludic +++ b/packages/ludic.render3d/env.ludic @@ -99,6 +99,7 @@ export state Render3dState { gltf_comps: int = 0 gltf_tex_paths: []pointer = null gltf_tex_ids: words = null + gltf_tex_refs: words = null # how many primitives use each cached texture (model_release) gltf_tex_n: int = 0 gltf_white: int = 0 gltf_flat: int = 0 diff --git a/packages/ludic.render3d/gltf.ludic b/packages/ludic.render3d/gltf.ludic index 2862f985..c23879a4 100644 --- a/packages/ludic.render3d/gltf.ludic +++ b/packages/ludic.render3d/gltf.ludic @@ -50,17 +50,17 @@ function gltf_mat_remember(render3d_st: mut Render3dState, name: string, diff: i } function gltf_texture(render3d_st: mut Render3dState, uri: string, srgb: bool) -> int { - if render3d_st.gltf_tex_paths == null { render3d_st.gltf_tex_paths = new []pointer; render3d_st.gltf_tex_ids = words(256) } + if render3d_st.gltf_tex_paths == null { render3d_st.gltf_tex_paths = new []pointer; render3d_st.gltf_tex_ids = words(256); render3d_st.gltf_tex_refs = words(256) } var png: string = uri let n = len(uri) if n > 4 and uri[n - 4] == '.' and uri[n - 3] == 'j' { png = uri[0 .. n - 4] + ".png" } let path = render3d_st.gltf_dir + "/" + png var i = 0 - while i < render3d_st.gltf_tex_n { if render3d_st.gltf_tex_paths[i] == path { return render3d_st.gltf_tex_ids[i] }; i += 1 } + while i < render3d_st.gltf_tex_n { if render3d_st.gltf_tex_paths[i] == path { render3d_st.gltf_tex_refs[i] += 1; return render3d_st.gltf_tex_ids[i] }; i += 1 } var dil = 0 if render3d_st.gltf_cutout { dil = 24 } let id = tex_load_ex(render3d_st, path, srgb, dil) - if render3d_st.gltf_tex_n < 256 { push(render3d_st.gltf_tex_paths, path); render3d_st.gltf_tex_ids[render3d_st.gltf_tex_n] = id; render3d_st.gltf_tex_n += 1 } + if render3d_st.gltf_tex_n < 256 { push(render3d_st.gltf_tex_paths, path); render3d_st.gltf_tex_ids[render3d_st.gltf_tex_n] = id; render3d_st.gltf_tex_refs[render3d_st.gltf_tex_n] = 1; render3d_st.gltf_tex_n += 1 } return id } @@ -150,7 +150,10 @@ function gltf_prim(render3d_st: mut Render3dState, p: Val) -> Prim { if ud != null { gltf_mat_remember(render3d_st, mname, pr.diff, pr.nrm, pr.arm) } else { let k = gltf_mat_find(render3d_st, mname) - if k >= 0 { pr.diff = render3d_st.gltf_mat_diff[k]; pr.nrm = render3d_st.gltf_mat_nrm[k]; pr.arm = render3d_st.gltf_mat_arm[k] } + if k >= 0 { + pr.diff = render3d_st.gltf_mat_diff[k]; pr.nrm = render3d_st.gltf_mat_nrm[k]; pr.arm = render3d_st.gltf_mat_arm[k] + gltf_tex_ref(render3d_st, pr.diff); gltf_tex_ref(render3d_st, pr.nrm); gltf_tex_ref(render3d_st, pr.arm) + } else { print(`gltf: material {mname} has no textures and none were loaded before it`) } } } diff --git a/packages/ludic.render3d/gltf_release.ludic b/packages/ludic.render3d/gltf_release.ludic new file mode 100644 index 00000000..76654087 --- /dev/null +++ b/packages/ludic.render3d/gltf_release.ludic @@ -0,0 +1,70 @@ +# gltf_release.ludic - a loaded model let go of: its meshes, and each texture once nothing else uses +# it. The glTF loader shares textures between models (a path loaded once, a LOD chain borrowing its +# LOD0's material), so each cached texture carries a count of the primitives using it. + +# one more primitive uses the cached texture `id` (0, or a texture the cache did not load: nothing) +function gltf_tex_ref(render3d_st: mut Render3dState, id: int) -> void { + if id == 0 or render3d_st.gltf_tex_refs == null { return } + for i in 0 .. render3d_st.gltf_tex_n { if render3d_st.gltf_tex_ids[i] == id { render3d_st.gltf_tex_refs[i] += 1; return } } +} + +# one primitive fewer: the last one frees the texture and forgets it, with every material naming it +function gltf_tex_unref(render3d_st: mut Render3dState, id: int) -> void { + if id == 0 or render3d_st.gltf_tex_refs == null { return } + for i in 0 .. render3d_st.gltf_tex_n { + if render3d_st.gltf_tex_ids[i] == id { + render3d_st.gltf_tex_refs[i] -= 1 + if render3d_st.gltf_tex_refs[i] > 0 { return } + gpu_tex_free(render3d_st, id) + gltf_tex_forget(render3d_st, i) + gltf_mats_forget(render3d_st, id) + return + } + } +} + +# the cache entry i goes; the last entry takes its place +function gltf_tex_forget(render3d_st: mut Render3dState, i: int) -> void { + let last = render3d_st.gltf_tex_n - 1 + let paths = render3d_st.gltf_tex_paths + paths[i] = paths[last] + render3d_st.gltf_tex_ids[i] = render3d_st.gltf_tex_ids[last] + render3d_st.gltf_tex_refs[i] = render3d_st.gltf_tex_refs[last] + List.pop(paths) + render3d_st.gltf_tex_n = last +} + +# a remembered material whose texture was freed is forgotten, so no later load borrows it +function gltf_mats_forget(render3d_st: mut Render3dState, id: int) -> void { + if render3d_st.gltf_mat_names == null { return } + var k = 0 + while k < render3d_st.gltf_mat_n { + if render3d_st.gltf_mat_diff[k] == id or render3d_st.gltf_mat_nrm[k] == id or render3d_st.gltf_mat_arm[k] == id { + let last = render3d_st.gltf_mat_n - 1 + let names = render3d_st.gltf_mat_names + names[k] = names[last] + render3d_st.gltf_mat_diff[k] = render3d_st.gltf_mat_diff[last] + render3d_st.gltf_mat_nrm[k] = render3d_st.gltf_mat_nrm[last] + render3d_st.gltf_mat_arm[k] = render3d_st.gltf_mat_arm[last] + List.pop(names) + render3d_st.gltf_mat_n = last + } else { + k += 1 + } + } +} + +# Let a model go: every primitive's mesh, and its textures once no other model uses them. The +# caller says when - no actor may draw the model afterwards (a released model's prims are emptied, +# so a stale draw of it draws nothing). +function model_release(render3d_st: mut Render3dState, m: Model) -> void { + if m == null or m.prims == null { return } + for i in 0 .. len(m.prims) { + let pr = m.prims[i] + if pr.mesh != null { gpu_mesh_free(render3d_st, pr.mesh); pr.mesh = null } + gltf_tex_unref(render3d_st, pr.diff); gltf_tex_unref(render3d_st, pr.nrm); gltf_tex_unref(render3d_st, pr.arm) + pr.diff = 0; pr.nrm = 0; pr.arm = 0 + } + let prims = m.prims + while len(prims) > 0 { List.pop(prims) } +} diff --git a/packages/ludic.render3d/gpu_vk_res.ludic b/packages/ludic.render3d/gpu_vk_res.ludic index a740ed96..2bdccff3 100644 --- a/packages/ludic.render3d/gpu_vk_res.ludic +++ b/packages/ludic.render3d/gpu_vk_res.ludic @@ -409,7 +409,7 @@ function gvk_tex_read(render3d_st: mut Render3dState, tex: int, ifmt: int, w: in let depth = gvk_is_depth(ifmt) let cb = gvk_once_begin(render3d_st) gvk_barrier(render3d_st, cb, image, depth, 0, 1, 1, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL) - let bic = bytes(VkBufferImageCopy_sizeof) + let bic = gvk_tmp(render3d_st, VkBufferImageCopy_sizeof) Vk.zero(bic, VkBufferImageCopy_sizeof) if depth { Vk.put_i32(bic, VkBufferImageCopy_imageSubresource + VkImageSubresourceLayers_aspectMask, VK_IMAGE_ASPECT_DEPTH_BIT) } else { Vk.put_i32(bic, VkBufferImageCopy_imageSubresource + VkImageSubresourceLayers_aspectMask, VK_IMAGE_ASPECT_COLOR_BIT) } diff --git a/packages/ludic.render3d/r3d.ludic b/packages/ludic.render3d/r3d.ludic index 55fa9d89..ae94eacb 100644 --- a/packages/ludic.render3d/r3d.ludic +++ b/packages/ludic.render3d/r3d.ludic @@ -26,6 +26,7 @@ import "shadow.ludic" import "post.ludic" import "quat.ludic" import "gltf.ludic" +import "gltf_release.ludic" import "skin.ludic" import "scatter.ludic" import "actor.ludic" diff --git a/packages/ludic.render3d/scatter.ludic b/packages/ludic.render3d/scatter.ludic index 2f24fe8d..773a1b91 100644 --- a/packages/ludic.render3d/scatter.ludic +++ b/packages/ludic.render3d/scatter.ludic @@ -24,6 +24,7 @@ property Layer { inst: floats, # INST_FLOATS per instance count: int = 0, cap: int = 0, + have: int = 0, # instances inst and scratch have room for now: grown toward cap as filled near: float = 0.0, # float bits; instances beyond it draw as impostors (or not at all) cull: float = 0.0, # float bits; instances beyond it are skipped (0 = never) buf: int = 0, @@ -386,8 +387,11 @@ function layer_new(render3d_st: mut Render3dState, model: Model, cap: int, folia l.near = near l.cull = cull l.tint = v3_new(1.0, 1.0, 1.0) - l.inst = floats(cap * INST_FLOATS) - l.scratch = floats(cap * INST_FLOATS) + # room for what the layer holds, not for what it might: 45 layers at full capacity up front were + # 0.4 GB, most of it never filled (plan 23 of maroon-lake) + l.have = min(cap, 256) + l.inst = floats(l.have * INST_FLOATS) + l.scratch = floats(l.have * INST_FLOATS) l.last_cam = v3_new(100000.0, 0.0, 0.0) l.buf = gpu_buffer_new(render3d_st) l.imp_buf = gpu_buffer_new(render3d_st) @@ -398,8 +402,23 @@ function layer_new(render3d_st: mut Render3dState, model: Model, cap: int, folia return l } +# room for n instances (at most the layer's cap), doubling what there is so a fill costs a few copies +function layer_room(l: Layer, n: int) -> void { + if n <= l.have { return } + var want = max(l.have * 2, n) + if want > l.cap { want = l.cap } + let inst = floats(want * INST_FLOATS) + if l.count > 0 { mem_copy(data_of(inst), data_of(l.inst), l.count * INST_FLOATS * 4) } + free(l.inst) + free(l.scratch) + l.inst = inst + l.scratch = floats(want * INST_FLOATS) + l.have = want +} + function layer_add(l: Layer, x: float, y: float, z: float, scale: float, yaw: float, seed: float, wind: float) -> void { if l.count >= l.cap { return } + layer_room(l, l.count + 1) let o = l.count * INST_FLOATS l.inst[o] = x; l.inst[o + 1] = y; l.inst[o + 2] = z; l.inst[o + 3] = scale l.inst[o + 4] = Math.sin(yaw); l.inst[o + 5] = Math.cos(yaw); l.inst[o + 6] = seed; l.inst[o + 7] = wind diff --git a/packages/ludic.render3d/stream.ludic b/packages/ludic.render3d/stream.ludic index 754c9c4c..a1696134 100644 --- a/packages/ludic.render3d/stream.ludic +++ b/packages/ludic.render3d/stream.ludic @@ -254,6 +254,7 @@ function stream_update(render3d_st: mut Render3dState, s: Stream, cam_x: float, } if c != null and c.count > 0 and l.count + c.count <= l.cap and stream_chunk_visible(render3d_st, s, cx, cz, c) { let tg = gl_now_us() + layer_room(l, l.count + c.count) mem_copy(mem_off(l.inst, l.count * INST_FLOATS * 4), data_of(c.data), c.count * INST_FLOATS * 4) l.count += c.count render3d_st.stream_us_gather = render3d_st.stream_us_gather + (gl_now_us() - tg) diff --git a/tools/ludic-cli/test.ludic b/tools/ludic-cli/test.ludic index 6e47833e..5c230149 100644 --- a/tools/ludic-cli/test.ludic +++ b/tools/ludic-cli/test.ludic @@ -608,6 +608,13 @@ function headless_case(path: pointer, exp: pointer, label: pointer) -> void { let got = capture_line(`{out} < /dev/null`) if got == exp { ok(label) } else { bad2(label, `got [{got}] want [{exp}]`) } } +# a program built headless whose verdict is one of its lines (the renderer logs before it) +function headless_line_case(path: pointer, exp: pointer, label: pointer) -> void { + let out = `{tmp_dir()}/h_{flat(path)}` + if not shq(`bin/ludicc --headless examples/{path}.ludic -o {out} > {out}.log 2>&1`) { bad2(label, capture_line(`grep -i error {out}.log | head -1`)); return } + let got = capture_line(`{out} < /dev/null 2>&1 | grep -c '^{exp}$'`) + if got == "1" { ok(label) } else { bad2(label, capture_line(`{out} < /dev/null 2>&1 | grep -i fail | head -1`)) } +} # ludic migrate state and components: the header names what every member needs, a field read in a # member is not edited, a module named like a package keeps a state of its own, and the program # runs as it did @@ -1045,6 +1052,7 @@ function cmd_dev_test() -> int { reject_case("rejected/state_component_ro", "look is read-only here (look: Look)", "a component's state is read-only unless its header says mut") controller_case("state/component", "", " #10 big 10 1", "component.ludic (0.S: a component's header names its states; its getters, functions and events take them and the template never sees them)") headless_case("rendering/ui_render3d", "ok", "ui_render3d.ludic (ludic.ui's render3d backend builds against the renderer)") + headless_line_case("rendering/release", "RELEASE OK", "release.ludic (model_release: meshes go, a shared texture stays until its last user goes)") migrate_component_case() migrate_foreign_case() reject_case("rejected/runtime_type_clash", "PadButton is the runtime's enum", "a program's type named like one of the runtime's is refused")