From ff658633fb14cc777e6155a6d4e61c23b5afeb46 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Mon, 28 Sep 2026 11:25:21 +0300 Subject: [PATCH 1/2] feat(render3d): terrain_chunk_heights - the ground's B-spline heights a chunk at a time (23.5) n x n samples of chunk (i, j) of a size_m grid from the terrain's corner, row-major into the caller's buffer with nothing allocated, each the (1 4 1) / 6 B-spline filter of the texels under it (ter_spline_at): what ludic.physics' jph_shape_heightfield_bspline makes of the whole map, so a chunk's physics ground matches the drawn one and its neighbours' to the bit. The read-back lives for the whole map (terrain_generate to terrain_unload). examples/rendering/chunks.ludic, in the suite, checks the shared edges, a sample against the filter by hand, and a short buffer refused. Co-Authored-By: Claude Opus 5.5 --- changes/render3d-chunk-heights.md | 10 +++ examples/rendering/chunks.ludic | 80 ++++++++++++++++++++ packages/ludic.render3d/r3d.ludic | 1 + packages/ludic.render3d/terrain_chunks.ludic | 40 ++++++++++ tools/ludic-cli/test.ludic | 1 + 5 files changed, 132 insertions(+) create mode 100644 changes/render3d-chunk-heights.md create mode 100644 examples/rendering/chunks.ludic create mode 100644 packages/ludic.render3d/terrain_chunks.ludic diff --git a/changes/render3d-chunk-heights.md b/changes/render3d-chunk-heights.md new file mode 100644 index 00000000..f85b83e0 --- /dev/null +++ b/changes/render3d-chunk-heights.md @@ -0,0 +1,10 @@ +bump: minor +type: feature +**The ground's heights a chunk at a time: `terrain_chunk_heights(i, j, size_m, n, out)`.** For +whatever is built per chunk (plan 23.5 of maroon-lake: the physics ground by 64 m chunks): n x n +samples of chunk (i, j) of a grid laid from the terrain's corner, row-major into the caller's +buffer, nothing allocated. Each is the ground's cubic B-spline at the texel under it - the +separable (1 4 1) / 6 filter `ter_spline_at`, the same numbers ludic.physics' B-spline +heightfield makes of the whole map - so neighbouring chunks share their edges bit for bit and +agree with the drawn ground. The read-back lives from `terrain_generate` to `terrain_unload`. +`examples/rendering/chunks.ludic` holds it. diff --git a/examples/rendering/chunks.ludic b/examples/rendering/chunks.ludic new file mode 100644 index 00000000..dbe117b6 --- /dev/null +++ b/examples/rendering/chunks.ludic @@ -0,0 +1,80 @@ +# chunks.ludic - the height field a chunk at a time (terrain_chunk_heights): 64 m chunks of 33 x 33 +# B-spline heights, each the (1 4 1) / 6 filter of the texels under it, their edges shared with their +# neighbours bit for bit. Over a known surface written into the read-back. Prints CHUNKS OK. +# +# bin/ludic build examples/rendering/chunks.ludic --headless && ./build/chunks_headless +program Chunks { + 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(`chunks: FAILED - {what}`) } + return cond + } + + 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) + render3d_st.TERRAIN_HALF = 1000 + render3d_st.ter_smooth = true + render3d_st.r3d_sky_path = "packages/ludic.lab/plate/sky.hdr" + if not r3d_init(render3d_st, 320, 180, "Chunks") { + quit() + return + } + # a surface of its own in the read-back: what is tested is the chunking and the filter, not + # the ground's generation + for z in 0 .. TERRAIN_RES { + for x in 0 .. TERRAIN_RES { + render3d_st.ter_heights[z * TERRAIN_RES + x] = Math.sin(float(x) * 0.013) * 40.0 + Math.cos(float(z) * 0.021) * 25.0 + float((x * 7 + z * 13) % 11) * 0.1 + } + } + var ok = true + let a = floats(33 * 33) + let b = floats(33 * 33) + let c = floats(33 * 33) + ok = check("a chunk reads", terrain_chunk_heights(render3d_st, 10, 12, 64.0, 33, a)) and ok + ok = check("its east neighbour reads", terrain_chunk_heights(render3d_st, 11, 12, 64.0, 33, b)) and ok + ok = check("its north neighbour reads", terrain_chunk_heights(render3d_st, 10, 13, 64.0, 33, c)) and ok + var east = true + var north = true + for k in 0 .. 33 { + if a[k * 33 + 32] != b[k * 33] { east = false } + if a[32 * 33 + k] != c[k] { north = false } + } + ok = check("the east edge is shared bit for bit", east) and ok + ok = check("the north edge is shared bit for bit", north) and ok + # sample (5, 7) of chunk (10, 12): the filter of the texels round the one under it, by hand + let texel = 2.0 * float(render3d_st.TERRAIN_HALF) / float(TERRAIN_RES) + let tx = int((10.0 * 64.0 + 5.0 * 2.0) / texel + 0.5) + let tz = int((12.0 * 64.0 + 7.0 * 2.0) / texel + 0.5) + let d = floats(33 * 33) + terrain_chunk_heights(render3d_st, 10, 12, 64.0, 33, d) + var want = 0.0 + for dz in -1 .. 2 { + let r = (tz + dz) * TERRAIN_RES + let row = (render3d_st.ter_heights[r + tx - 1] + 4.0 * render3d_st.ter_heights[r + tx] + render3d_st.ter_heights[r + tx + 1]) / 6.0 + if dz == 0 { want = want + 4.0 * row } else { want = want + row } + } + ok = check("a sample is the B-spline at its texel", d[7 * 33 + 5] == want / 6.0) and ok + var flat = true + for k in 1 .. 33 * 33 { if a[k] != a[0] { flat = false } } + ok = check("the ground has relief in it (the test reads something)", not flat) and ok + ok = check("too small a buffer is refused, not overrun", not terrain_chunk_heights(render3d_st, 0, 0, 64.0, 34, a)) and ok + if ok { print("CHUNKS OK") } else { print("CHUNKS FAILED") } + quit() + } + + handler Present(render3d_st: mut Render3dState) phase Render { + r3d_present(render3d_st) + } +} diff --git a/packages/ludic.render3d/r3d.ludic b/packages/ludic.render3d/r3d.ludic index 695579ff..321ad3be 100644 --- a/packages/ludic.render3d/r3d.ludic +++ b/packages/ludic.render3d/r3d.ludic @@ -23,6 +23,7 @@ import "camera.ludic" import "sky.ludic" import "daylight.ludic" import "terrain.ludic" +import "terrain_chunks.ludic" import "overlay.ludic" import "shadow.ludic" import "post.ludic" diff --git a/packages/ludic.render3d/terrain_chunks.ludic b/packages/ludic.render3d/terrain_chunks.ludic new file mode 100644 index 00000000..c5cf19f4 --- /dev/null +++ b/packages/ludic.render3d/terrain_chunks.ludic @@ -0,0 +1,40 @@ +# terrain_chunks.ludic - the height field a chunk at a time, for whatever is built per chunk (the +# ground's physics, plan 23.5 of maroon-lake): the texels the renderer read back, as they are. + +# the ground's height at texel (tx, tz) as the renderer draws it - the cubic B-spline, which at a +# texel's centre is the separable (1 4 1) / 6 filter of its neighbours, clamped at the map's edge: +# the same numbers ludic.physics' jph_shape_heightfield_bspline makes of the whole map +function ter_spline_at(render3d_st: Render3dState, tx: int, tz: int) -> float { + var sum = 0.0 + for d in -1 .. 2 { + let z = min(max(tz + d, 0), TERRAIN_RES - 1) + let r = z * TERRAIN_RES + let row = (render3d_st.ter_heights[r + max(tx - 1, 0)] + 4.0 * render3d_st.ter_heights[r + tx] + render3d_st.ter_heights[r + min(tx + 1, TERRAIN_RES - 1)]) / 6.0 + if d == 0 { sum = sum + 4.0 * row } else { sum = sum + row } + } + return sum / 6.0 +} + +# The heights of chunk (i, j) of a size_m grid laid from the terrain's corner (ter_ox - TERRAIN_HALF, +# ter_oz - TERRAIN_HALF): n x n samples, row-major (z rows, x across), sample (a, b) at corner + +# (i * size_m + a * size_m / (n - 1), j * size_m + b * size_m / (n - 1)), each the ground's +# B-spline height at the texel under it (ter_spline_at: what the physics ground is built from). +# Neighbours share their edge row and column, so seams agree to the bit. Into `out` (n * n floats, +# the caller's, reused), nothing allocated. The read-back lives from terrain_generate to +# terrain_unload - the whole of a map - so a chunk can be read any time between; false when there +# is none (a plate, or before the map is made). +function terrain_chunk_heights(render3d_st: Render3dState, i: int, j: int, size_m: float, n: int, out: floats) -> bool { + if render3d_st.ter_heights == null or n < 2 or len(out) < n * n { return false } + let texel = 2.0 * float(render3d_st.TERRAIN_HALF) / float(TERRAIN_RES) + let step = size_m / float(n - 1) + let x0 = float(i) * size_m + let z0 = float(j) * size_m + for b in 0 .. n { + let tz = min(max(int((z0 + float(b) * step) / texel + 0.5), 0), TERRAIN_RES - 1) + for a in 0 .. n { + let tx = min(max(int((x0 + float(a) * step) / texel + 0.5), 0), TERRAIN_RES - 1) + out[b * n + a] = ter_spline_at(render3d_st, tx, tz) + } + } + return true +} diff --git a/tools/ludic-cli/test.ludic b/tools/ludic-cli/test.ludic index f2a66f60..ea6b28a2 100644 --- a/tools/ludic-cli/test.ludic +++ b/tools/ludic-cli/test.ludic @@ -1054,6 +1054,7 @@ function cmd_dev_test() -> int { 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)") headless_line_case("rendering/bc", "BC OK", "bc.ludic (a .dds beside a .png arrives BC7-compressed with every mip level)") + headless_line_case("rendering/chunks", "CHUNKS OK", "chunks.ludic (the ground's B-spline heights a 64 m chunk at a time, edges shared bit for bit)") 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") From 0c3c0c5e6ae3fe7e2eae11ae9b888b76426547f7 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Mon, 28 Sep 2026 11:43:40 +0300 Subject: [PATCH 2/2] feat(ludic.physics): phys_shape_free - a shape let go hands its id to the next, so a ground by chunk grows nothing (23.5) Co-Authored-By: Claude Opus 5.5 --- packages/ludic.physics/index.ludic | 1 + packages/ludic.physics/shape_ids.ludic | 33 +++++++++++++ packages/ludic.physics/shapes.ludic | 13 +---- packages/ludic.physics/state.ludic | 1 + .../ludic.physics/tests/shape_free_test.ludic | 48 +++++++++++++++++++ 5 files changed, 84 insertions(+), 12 deletions(-) create mode 100644 packages/ludic.physics/shape_ids.ludic create mode 100644 packages/ludic.physics/tests/shape_free_test.ludic diff --git a/packages/ludic.physics/index.ludic b/packages/ludic.physics/index.ludic index d269641f..9fd169bc 100644 --- a/packages/ludic.physics/index.ludic +++ b/packages/ludic.physics/index.ludic @@ -5,6 +5,7 @@ numbers float import "ludic.base" import "native.ludic" import "state.ludic" +import "shape_ids.ludic" import "shapes.ludic" import "bodies.ludic" import "queries.ludic" diff --git a/packages/ludic.physics/shape_ids.ludic b/packages/ludic.physics/shape_ids.ludic new file mode 100644 index 00000000..199bc482 --- /dev/null +++ b/packages/ludic.physics/shape_ids.ludic @@ -0,0 +1,33 @@ +# shape_ids.ludic - a shape id is its place in ph_shapes. A shape let go leaves its place to the next +# one made, so a ground that comes and goes by chunk grows nothing + +function ph_keep(physics_st: mut PhysicsState, s: pointer) -> int { + if s == null { return -1 } + let n = len(physics_st.ph_spare) + if n > 0 { + let id = physics_st.ph_spare[n - 1] + List.pop(physics_st.ph_spare) + physics_st.ph_shapes[id] = s + return id + } + push(physics_st.ph_shapes, s) + return len(physics_st.ph_shapes) - 1 +} + +function ph_shape(physics_st: PhysicsState, id: int) -> pointer { + if physics_st.ph_shapes == null or id < 0 or id >= len(physics_st.ph_shapes) { return null } + return physics_st.ph_shapes[id] +} + +# a shape let go: a body made with it keeps its own hold until it is removed, and the id may be +# handed to the next shape made +export function phys_shape_free(physics_st: mut PhysicsState, id: int) -> void { + let s = ph_shape(physics_st, id) + if s == null { return } + jph_shape_free(s) + physics_st.ph_shapes[id] = null + push(physics_st.ph_spare, id) +} + +# how many shapes are held: a ground that comes and goes must hold this steady +export function phys_shapes(physics_st: PhysicsState) -> int { return len(physics_st.ph_shapes) - len(physics_st.ph_spare) } diff --git a/packages/ludic.physics/shapes.ludic b/packages/ludic.physics/shapes.ludic index ead672d0..3ec7a04d 100644 --- a/packages/ludic.physics/shapes.ludic +++ b/packages/ludic.physics/shapes.ludic @@ -32,23 +32,12 @@ export function phys_close(physics_st: mut PhysicsState) -> void { } } physics_st.ph_shapes = new []pointer + List.clear(physics_st.ph_spare) List.clear(physics_st.ph_floats) List.clear(physics_st.ph_float_k) if physics_st.ph_facts != null { q_clear(physics_st.ph_facts) } } -function ph_keep(physics_st: mut PhysicsState, s: pointer) -> int { - if s == null { return -1 } - ph_buffers() - push(physics_st.ph_shapes, s) - return len(physics_st.ph_shapes) - 1 -} - -function ph_shape(physics_st: PhysicsState, id: int) -> pointer { - if physics_st.ph_shapes == null or id < 0 or id >= len(physics_st.ph_shapes) { return null } - return physics_st.ph_shapes[id] -} - # a crate: half extents in metres export function phys_box(physics_st: mut PhysicsState, hx: float, hy: float, hz: float) -> int { return ph_keep(physics_st, jph_shape_box(hx, hy, hz)) } export function phys_sphere(physics_st: mut PhysicsState, r: float) -> int { return ph_keep(physics_st, jph_shape_sphere(r)) } diff --git a/packages/ludic.physics/state.ludic b/packages/ludic.physics/state.ludic index b99bb926..dd5cf4cc 100644 --- a/packages/ludic.physics/state.ludic +++ b/packages/ludic.physics/state.ludic @@ -37,6 +37,7 @@ export const PH_CAP: int = 512 # contacts drained a step, queries' buffer export state PhysicsState { ph_world: pointer = null ph_shapes: []pointer = new []pointer # a shape id is its place here; null once freed + ph_spare: []int = new []int # the freed places, handed out again (shape_ids.ludic) ph_step: float = 0.016666668 # seconds a step ph_sub: int = 1 # collision steps in each ph_max_steps: int = 4 # a slow frame catches up this far and no further diff --git a/packages/ludic.physics/tests/shape_free_test.ludic b/packages/ludic.physics/tests/shape_free_test.ludic new file mode 100644 index 00000000..11e2831c --- /dev/null +++ b/packages/ludic.physics/tests/shape_free_test.ludic @@ -0,0 +1,48 @@ +# shape_free_test.ludic - a shape let go: its body stands until removed, its id goes to the next +# shape made, and a ground that comes and goes a thousand times holds no more shapes than at once +import "ludic.physics" +import "ludic.base" +program ShapeFreeTest { + numbers float + function down(physics_st: mut PhysicsState, x: float, z: float) -> PhysHit { + let h = new PhysHit + phys_ray(physics_st, x, 50.0, z, 0.0, -100.0, 0.0, PHYS_M_ALL, h) + return h + } + # a flat 33 x 33 field at height y, 2 m cells, its first sample at (ox, 0) + function field(physics_st: mut PhysicsState, h: []float, ox: float, y: float) -> int { + for i in 0 .. 33 * 33 { h[i] = y } + return phys_heightfield(physics_st, h, 33, ox, 0.0, 2.0) + } + + test "a body outlives its shape's id, and the id is handed out again" (physics_st: mut PhysicsState) { + expect(phys_open(physics_st, 64, 1)) + let h = floats(33 * 33) + let s = field(physics_st, h, 0.0, 3.0) + let b = phys_ground_add(physics_st, s) + phys_shape_free(physics_st, s) + phys_settle(physics_st) + expect(Math.abs(down(physics_st, 10.0, 10.0).y - 3.0) < 0.01) + expect_eq(field(physics_st, h, 100.0, 5.0), s) + phys_remove(physics_st, b) + expect(down(physics_st, 10.0, 10.0).body < 0) + phys_close(physics_st) + } + + test "a thousand chunks in and out hold two shapes and grow nothing" (physics_st: mut PhysicsState) { + expect(phys_open(physics_st, 64, 1)) + let h = floats(33 * 33) + let keep = phys_box(physics_st, 1.0, 1.0, 1.0) + for k in 0 .. 1000 { + let s = field(physics_st, h, float(k % 7) * 64.0, 1.0) + let b = phys_ground_add(physics_st, s) + phys_remove(physics_st, b) + phys_shape_free(physics_st, s) + } + expect_eq(phys_shapes(physics_st), 1) + expect(phys_box(physics_st, 1.0, 1.0, 1.0) != keep) + expect_eq(phys_shapes(physics_st), 2) + phys_close(physics_st) + expect_eq(phys_shapes(physics_st), 0) + } +}