From c2fc8f78f229232e1c325d42dbdf479c934ac79d Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Wed, 30 Sep 2026 02:29:55 +0300 Subject: [PATCH] render3d: terrain_height_file - terrain_height's exact bilinear that never pages: a resident tile is read in place, any other from tt_file into four tiles of scratch of its own (in the state's defaults, forgotten when the file closes), so no slot is taken and no recency bumped - a sweep at boot no longer changes which tiles the cache holds, and so what is drawn from it. With the whole copy it is terrain_height. tests/height_file_test: equal to terrain_height over a grid across many tiles, resident and not, at tile corners and past the edge; a whole-map sweep leaves slot_of, tile_in, ref, the heights, the hand and the read counts unchanged Co-Authored-By: Claude Opus 5.5 --- packages/ludic.render3d/env.ludic | 4 + packages/ludic.render3d/r3d.ludic | 1 + .../ludic.render3d/terrain_height_file.ludic | 52 +++++++ .../ludic.render3d/terrain_tiles_cut.ludic | 1 + .../tests/height_file_test.ludic | 129 ++++++++++++++++++ 5 files changed, 187 insertions(+) create mode 100644 packages/ludic.render3d/terrain_height_file.ludic create mode 100644 packages/ludic.render3d/tests/height_file_test.ludic diff --git a/packages/ludic.render3d/env.ludic b/packages/ludic.render3d/env.ludic index bf8c28a8..0b10dc2d 100644 --- a/packages/ludic.render3d/env.ludic +++ b/packages/ludic.render3d/env.ludic @@ -889,6 +889,10 @@ export state Render3dState { tt_reads: int = 0 # tiles read from the file, over the run and this frame tt_frame_reads: int = 0 tt_warned: bool = false + tf_raw: []byte = buffer(2048) # terrain_height_file's own: one tile's bytes as read, + tf_h: floats = floats(4096) # ... TF_KEEP tiles' heights, and which (tile + 1, 0: none) + tf_tile: words = words(4) + tf_next: int = 0 tp_on: bool = false # the GPU pages the fine tiles round the camera (terrain_pages.ludic) tp_page_tex: int = 0 # R32F tt_n a side: slot + 1 of each resident tile, 0 = the coarse level tp_h_tex: int = 0 # the pool: heights, normals and photograph, a layer a slot diff --git a/packages/ludic.render3d/r3d.ludic b/packages/ludic.render3d/r3d.ludic index db733d8f..59eb573b 100644 --- a/packages/ludic.render3d/r3d.ludic +++ b/packages/ludic.render3d/r3d.ludic @@ -32,6 +32,7 @@ import "daylight.ludic" import "terrain.ludic" import "terrain_chunks.ludic" import "terrain_tiles.ludic" +import "terrain_height_file.ludic" import "terrain_tiles_cut.ludic" import "terrain_ltt2_write.ludic" import "terrain_ltt2_read.ludic" diff --git a/packages/ludic.render3d/terrain_height_file.ludic b/packages/ludic.render3d/terrain_height_file.ludic new file mode 100644 index 00000000..69d664ea --- /dev/null +++ b/packages/ludic.render3d/terrain_height_file.ludic @@ -0,0 +1,52 @@ +# terrain_height_file.ludic — terrain_height's exact answer that never pages: a tile not resident is +# read from the file into a scratch of this query's own, so who asks and in what order never changes +# which tiles the cache holds (and so never moves what is drawn from it). For wide sweeps at boot. +const TF_KEEP: int = 4 # tiles the scratch keeps: a texel square at a corner spans four + +export function terrain_height_file(render3d_st: mut Render3dState, x: float, z: float) -> float { + if not ter_present(render3d_st) { return 0.0 } + let scale = float(TERRAIN_RES) / float(render3d_st.TERRAIN_HALF * 2) + let fx = (x - render3d_st.ter_ox + float(render3d_st.TERRAIN_HALF)) * scale + let fz = (z - render3d_st.ter_oz + float(render3d_st.TERRAIN_HALF)) * scale + let ix = min(max(int(Math.floor(fx)), 0), TERRAIN_RES - 2) + let iz = min(max(int(Math.floor(fz)), 0), TERRAIN_RES - 2) + let tx = Math.clamp(fx - float(ix), 0.0, 1.0) + let tz = Math.clamp(fz - float(iz), 0.0, 1.0) + let h00 = tf_texel(render3d_st, ix, iz) + let h10 = tf_texel(render3d_st, ix + 1, iz) + let h01 = tf_texel(render3d_st, ix, iz + 1) + let h11 = tf_texel(render3d_st, ix + 1, iz + 1) + return Math.lerp(Math.lerp(h00, h10, tx), Math.lerp(h01, h11, tx), tz) +} + +# a height texel: the whole copy's, a resident tile's, else the scratch's (read from the file there) +function tf_texel(render3d_st: mut Render3dState, tx: int, tz: int) -> float { + if render3d_st.ter_heights != null { return render3d_st.ter_heights[tz * TERRAIN_RES + tx] } + let t = (tz >> TT_SHIFT) * render3d_st.tt_n + (tx >> TT_SHIFT) + let at = (tz & TT_MASK) * TT_TEX + (tx & TT_MASK) + let s = render3d_st.tt_slot_of[t] + if s >= 0 { return render3d_st.tt_h[s * TT_TEX * TT_TEX + at] } + return render3d_st.tf_h[tf_entry(render3d_st, t) * TT_TEX * TT_TEX + at] +} + +# the scratch entry holding tile t, read from the file (as tt_decode reads its heights) when none does +function tf_entry(render3d_st: mut Render3dState, t: int) -> int { + for e in 0 .. TF_KEEP { if render3d_st.tf_tile[e] == t + 1 { return e } } + let e = render3d_st.tf_next + render3d_st.tf_next = (e + 1) % TF_KEEP + let n2 = TT_TEX * TT_TEX + let raw = render3d_st.tf_raw + file_seek(render3d_st.tt_file, render3d_st.tt_hoff + t * n2 * 2, 0) + file_read(render3d_st.tt_file, data_of(raw), n2 * 2) + let lo = render3d_st.tt_tab[t * 2] + let step = render3d_st.tt_tab[t * 2 + 1] + for i in 0 .. n2 { render3d_st.tf_h[e * n2 + i] = lo + float(raw[i * 2] | (raw[i * 2 + 1] << 8)) * step } + render3d_st.tf_tile[e] = t + 1 + return e +} + +# the scratch forgotten: the tiles' file closed or replaced (another map's tile t is another tile) +function tf_forget(render3d_st: mut Render3dState) -> void { + for e in 0 .. TF_KEEP { render3d_st.tf_tile[e] = 0 } + render3d_st.tf_next = 0 +} diff --git a/packages/ludic.render3d/terrain_tiles_cut.ludic b/packages/ludic.render3d/terrain_tiles_cut.ludic index 51a49894..00e7f4ef 100644 --- a/packages/ludic.render3d/terrain_tiles_cut.ludic +++ b/packages/ludic.render3d/terrain_tiles_cut.ludic @@ -27,6 +27,7 @@ function terrain_tiles_cut(render3d_st: mut Render3dState) -> void { # the last map's file let go (a world swap, or the map made again) function tt_close(render3d_st: mut Render3dState) -> void { if render3d_st.tt_file != null { file_close(render3d_st.tt_file); render3d_st.tt_file = null } + tf_forget(render3d_st) } function tt_say_no_file(path: string) -> void { print(`r3d: terrain: cannot write {path}; the whole height field stays in memory`) } diff --git a/packages/ludic.render3d/tests/height_file_test.ludic b/packages/ludic.render3d/tests/height_file_test.ludic new file mode 100644 index 00000000..24c33073 --- /dev/null +++ b/packages/ludic.render3d/tests/height_file_test.ludic @@ -0,0 +1,129 @@ +# height_file_test.ludic - terrain_height_file against terrain_height over a tiles file written here (no +# device): the same answer to the bit, resident or not, and a sweep of the whole map leaves the page +# cache - its slots, what they hold and the clock - exactly as it was +import "ludic.render3d/r3d.ludic" +program HeightFileTest { + numbers float + # never called: naming Input links the engine's runtime, which render3d's textures stand on + function links_runtime() -> bool { return Input.key_pressed(0) } + + # an LTT2 file with heights only (no photograph; normals and the coarse level read short, unused here) + function hf_write(path: string) -> bool { + let n = TERRAIN_RES / TT_TEX + let n2 = TT_TEX * TT_TEX + let hat = LT_HEAD + n * n * 8 + let size = hat + n * n * n2 * 2 + let b = buffer(size) + for k in 0 .. LT_HEAD { b[k] = 0 } + hf_put(b, 0, LT_MAGIC) + hf_put(b, 4, LT_VERSION) + hf_put(b, 8, TT_TEX) + hf_put(b, 12, n) + hf_put(b, 20, TERRAIN_RES) + hf_put(b, 28, TT_COARSE) + hf_put(b, 32, LT_HEAD) + for k in 10 .. 15 { hf_put(b, k * 4, size) } + for t in 0 .. n * n { + hf_put(b, LT_HEAD + t * 8, float_bits(float(t % 50) * 3.5 - 40.0)) + hf_put(b, LT_HEAD + t * 8 + 4, float_bits(0.01 + float(t % 7) * 0.001)) + for i in 0 .. n2 { + let q = (i * 37 + (i >> 5) * 11 + t * 5) & 65535 + b[hat + (t * n2 + i) * 2] = q & 255 + b[hat + (t * n2 + i) * 2 + 1] = q >> 8 + } + } + return Fs.write_bytes(path, b, size) + } + function hf_put(b: []byte, at: int, v: int) -> void { + for k in 0 .. 4 { b[at + k] = (v >> (k * 8)) & 255 } + } + + function hf_open(render3d_st: mut Render3dState, tag: string) -> bool { + let path = Os.temp_dir() + "/r3d_height_file_" + tag + ".ltt2" + if not hf_write(path) { return false } + render3d_st.TERRAIN_HALF = 4096 + render3d_st.ter_ox = 0.0 + render3d_st.ter_oz = 0.0 + render3d_st.ter_h_scale = 0.0 + return tt_open_ltt2(render3d_st, path, 0) + } + + test "the same answer as terrain_height, whether its tile is resident or not" (render3d_st: mut Render3dState) { + expect(hf_open(render3d_st, "same")) + terrain_tiles_prefetch(render3d_st, 1000.0, -500.0, 200.0, 100) + let pts = floats(2 * 160 * 160) + let got = floats(160 * 160) + for j in 0 .. 160 { + for i in 0 .. 160 { + let k = j * 160 + i + pts[k * 2] = -3000.0 + float(i) * 37.3 + pts[k * 2 + 1] = -3100.0 + float(j) * 41.7 + got[k] = terrain_height_file(render3d_st, pts[k * 2], pts[k * 2 + 1]) + } + } + var off = 0 + for k in 0 .. 160 * 160 { if terrain_height(render3d_st, pts[k * 2], pts[k * 2 + 1]) != got[k] { off += 1 } } + expect_eq(off, 0) + # and again now that terrain_height has paged most of them in + for k in 0 .. 160 * 160 { if terrain_height_file(render3d_st, pts[k * 2], pts[k * 2 + 1]) != got[k] { off += 1 } } + expect_eq(off, 0) + # a texel square across a tile corner, and the map's edges + for c in 0 .. 5 { + let x = -4096.0 + 64.0 * float(c * 13 + 1) - 1.0 + let z = -4096.0 + 64.0 * float(c * 7 + 2) - 0.5 + expect_eq(terrain_height_file(render3d_st, x, z), terrain_height(render3d_st, x, z)) + } + expect_eq(terrain_height_file(render3d_st, -5000.0, 5000.0), terrain_height(render3d_st, -5000.0, 5000.0)) + } + + test "a sweep of the whole map leaves every slot, what it holds and the clock as they were" (render3d_st: mut Render3dState) { + expect(hf_open(render3d_st, "sweep")) + terrain_tiles_prefetch(render3d_st, -200.0, 300.0, 300.0, 200) + expect(terrain_height(render3d_st, 2000.0, 2000.0) != 0.12345) # a tile paged the ordinary way too + let n2 = len(render3d_st.tt_slot_of) + let slots = len(render3d_st.tt_tile_in) + let slot_of = words(n2) + for t in 0 .. n2 { slot_of[t] = render3d_st.tt_slot_of[t] } + let tile_in = words(slots) + let refs = words(slots) + for s in 0 .. slots { + tile_in[s] = render3d_st.tt_tile_in[s] + refs[s] = render3d_st.tt_ref[s] + } + let h = floats(len(render3d_st.tt_h)) + for i in 0 .. len(h) { h[i] = render3d_st.tt_h[i] } + let hand = render3d_st.tt_hand + let reads = render3d_st.tt_reads + let frame_reads = render3d_st.tt_frame_reads + var sum = 0.0 + var z = -4096.0 + while z <= 4096.0 { + var x = -4096.0 + while x <= 4096.0 { + sum = sum + terrain_height_file(render3d_st, x, z) + x = x + 16.0 + } + z = z + 16.0 + } + expect(sum != 0.0) + var moved = 0 + for t in 0 .. n2 { if render3d_st.tt_slot_of[t] != slot_of[t] { moved += 1 } } + for s in 0 .. slots { if render3d_st.tt_tile_in[s] != tile_in[s] or render3d_st.tt_ref[s] != refs[s] { moved += 1 } } + for i in 0 .. len(h) { if render3d_st.tt_h[i] != h[i] { moved += 1 } } + expect_eq(moved, 0) + expect_eq(render3d_st.tt_hand, hand) + expect_eq(render3d_st.tt_reads, reads) + expect_eq(render3d_st.tt_frame_reads, frame_reads) + } + + test "with the whole copy kept it is terrain_height" (render3d_st: mut Render3dState) { + render3d_st.TERRAIN_HALF = 4096 + render3d_st.ter_heights = floats(TERRAIN_RES * TERRAIN_RES) + for i in 0 .. TERRAIN_RES * TERRAIN_RES { render3d_st.ter_heights[i] = float(i % 997) * 0.25 + float(i / 4096) * 0.01 } + for k in 0 .. 400 { + let x = -4000.0 + float(k) * 19.91 + let z = 3900.0 - float(k) * 17.13 + expect_eq(terrain_height_file(render3d_st, x, z), terrain_height(render3d_st, x, z)) + } + } +}