From e53587926282a49353fc59f6d19ec0cd193a87dc Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Wed, 30 Sep 2026 15:23:53 +0300 Subject: [PATCH 1/4] render3d: ground_fill's candidate is a function of its own; solid layers with stable ids; the trample as clearing discs ground_candidate answers (layer, chunk, band, cell) -> keep, x, z, scale, yaw, seed, wind from pure gf_* steps and the density read between them; ground_fill draws its answers with the same operations in the same order. A solid layer fills at step0 and band 0 whatever the camera, a far band drawing a stable subset (draw 7 under (step0/step_b)^2), each thing an int id from (layer, chunk, cell), listed per chunk into a caller's GroundSolids or answered by id. r3d_ground_clearing hands the trample over as discs. Co-Authored-By: Claude Opus 5.5 --- packages/ludic.render3d/env.ludic | 3 + packages/ludic.render3d/ground_cand.ludic | 68 +++++++++++++++++++ packages/ludic.render3d/ground_clear.ludic | 44 ++++++++++++ packages/ludic.render3d/ground_fill.ludic | 34 ++++------ packages/ludic.render3d/ground_solid.ludic | 65 ++++++++++++++++++ .../ludic.render3d/ground_solid_list.ludic | 60 ++++++++++++++++ packages/ludic.render3d/r3d.ludic | 4 ++ 7 files changed, 259 insertions(+), 19 deletions(-) create mode 100644 packages/ludic.render3d/ground_cand.ludic create mode 100644 packages/ludic.render3d/ground_clear.ludic create mode 100644 packages/ludic.render3d/ground_solid.ludic create mode 100644 packages/ludic.render3d/ground_solid_list.ludic diff --git a/packages/ludic.render3d/env.ludic b/packages/ludic.render3d/env.ludic index 991c1a53..2eeee9df 100644 --- a/packages/ludic.render3d/env.ludic +++ b/packages/ludic.render3d/env.ludic @@ -860,6 +860,9 @@ export state Render3dState { gd_zbuf: []byte = null # one tile's deflated bytes as read gd_hand: int = 0 gd_trample_cb: fn(float, float) -> float = null + gd_clear: floats = floats(GD_CLEAR_MAX * 5) # the clearings as data: (x, z, r_in, r_out, floor) each (ground_clear.ludic) + gd_clear_n: int = 0 + gd_cand: GroundCand = new GroundCand # ground_fill's candidate, filled and reused gb_layer: int = -1 # the densities' layer the blades grow by; -1: the rules (grass_density.ludic) gb_buf: int = 0 # its window round the camera, on the GPU gb_win: words = null # ... and the window's bytes, four texels a word, made once diff --git a/packages/ludic.render3d/ground_cand.ludic b/packages/ludic.render3d/ground_cand.ludic new file mode 100644 index 00000000..c6aa4bf1 --- /dev/null +++ b/packages/ludic.render3d/ground_cand.ludic @@ -0,0 +1,68 @@ +# ground_cand.ludic — one candidate of a ground layer: (layer, chunk, band, cell) to where it stands and what it +# draws with. The gf_* steps are pure (numbers in, a number out); ground_candidate reads the densities between +# them, and nothing but the densities' tile cache changes. The studio's groundFill.ts is these steps. + +# a candidate, filled by the caller's own record (made once, reused) +export property GroundCand { + x: float = 0.0, + z: float = 0.0, + scale: float = 1.0, + yaw: float = 0.0, + seed: float = 0.0, + wind: float = 0.0, + cell: int = 0, # i * 65536 + j: the hashes' fifth input + id: int = -1 # a solid layer's stable id (ground_solid.ludic); -1 for cover +} + +export function gf_cell(i: int, j: int) -> int { return i * 65536 + j } + +# a cell's corner o, moved by the jitter's draw h (k 0 for x, 1 for z) +export function gf_along(o: float, i: int, h: float, jitter: float, step: float) -> float { return o + (float(i) + 0.5 + (h - 0.5) * jitter) * step } + +# kept when draw 2 falls under the chance (the density, times the trample) +export function gf_keeps(g: GroundFill, cx: int, cz: int, band: int, e: int, keep: float) -> bool { return not (gf_hash(g.layer, cx, cz, band, e, 2) >= keep) } + +# its size from the density's G at its place, and its own draw 6 +export function gf_scale(g: GroundFill, cx: int, cz: int, band: int, e: int, gs: float) -> float { + var sc = Math.lerp(g.scale_lo, g.scale_hi, gs) + sc = sc * (1.0 + (gf_hash(g.layer, cx, cz, band, e, 6) * 2.0 - 1.0) * g.scale_var) * (1.0 + g.band_grow * float(band)) + return sc +} + +export function gf_yaw(g: GroundFill, cx: int, cz: int, band: int, e: int) -> float { return gf_hash(g.layer, cx, cz, band, e, 3) * 2.0 * PI } + +export function gf_seed(g: GroundFill, cx: int, cz: int, band: int, e: int) -> float { return gf_hash(g.layer, cx, cz, band, e, 4) } + +export function gf_wind(g: GroundFill, cx: int, cz: int, band: int, e: int) -> float { return Math.lerp(g.wind_lo, g.wind_hi, gf_hash(g.layer, cx, cz, band, e, 5)) } + +# the chance at (x, z) after the trample: the game's callback, then the clearings it handed over as data +function gf_trampled(render3d_st: mut Render3dState, keep: float, x: float, z: float) -> float { + var k = keep + if render3d_st.gd_trample_cb != null { k = k * render3d_st.gd_trample_cb(x, z) } + if render3d_st.gd_clear_n > 0 { k = k * ground_clear_at(render3d_st, x, z) } + return k +} + +# cell (i, j) of chunk (cx, cz) at `band` - the chunk's corner (x0, z0), its side `size` - into `out`: true when +# one stands there +export function ground_candidate(render3d_st: mut Render3dState, g: GroundFill, cx: int, cz: int, band: int, i: int, j: int, x0: float, z0: float, size: float, out: GroundCand) -> bool { + let step = gf_step(g, band) + if step <= 0.0 { return false } + let e = gf_cell(i, j) + let px = gf_along(x0, i, gf_hash(g.layer, cx, cz, band, e, 0), g.jitter, step) + let pz = gf_along(z0, j, gf_hash(g.layer, cx, cz, band, e, 1), g.jitter, step) + if px < x0 or px >= x0 + size or pz < z0 or pz >= z0 + size { return false } + var keep = ground_density_at(render3d_st, g.layer, px, pz, 0) + if keep <= 0.0 { return false } + if g.trample { keep = gf_trampled(render3d_st, keep, px, pz) } + if not gf_keeps(g, cx, cz, band, e, keep) { return false } + out.x = px + out.z = pz + out.cell = e + out.id = -1 + out.scale = gf_scale(g, cx, cz, band, e, ground_density_at(render3d_st, g.layer, px, pz, 1)) + out.yaw = gf_yaw(g, cx, cz, band, e) + out.seed = gf_seed(g, cx, cz, band, e) + out.wind = gf_wind(g, cx, cz, band, e) + return true +} diff --git a/packages/ludic.render3d/ground_clear.ludic b/packages/ludic.render3d/ground_clear.ludic new file mode 100644 index 00000000..9ffc98a5 --- /dev/null +++ b/packages/ludic.render3d/ground_clear.ludic @@ -0,0 +1,44 @@ +# ground_clear.ludic — the trample as data: clearings the game hands over (a camp's ring, a town's yard), each a +# disc trodden to `floor` inside r_in and back to full at r_out on a smoothstep. A layer with `trample` multiplies +# its chance by every clearing's; the editor draws the same discs, where it cannot see a callback. + +const GD_CLEAR_MAX: int = 32 + +# no clearings (a map being opened, before the game hands its own) +export function r3d_ground_clearings_reset(render3d_st: mut Render3dState) -> void { render3d_st.gd_clear_n = 0 } + +# a clearing at (x, z): false when GD_CLEAR_MAX are already held, or the disc is not r_in < r_out +export function r3d_ground_clearing(render3d_st: mut Render3dState, x: float, z: float, r_in: float, r_out: float, floor: float) -> bool { + if render3d_st.gd_clear_n >= GD_CLEAR_MAX or not (r_in < r_out) { return false } + let o = render3d_st.gd_clear_n * 5 + render3d_st.gd_clear[o] = x + render3d_st.gd_clear[o + 1] = z + render3d_st.gd_clear[o + 2] = r_in + render3d_st.gd_clear[o + 3] = r_out + render3d_st.gd_clear[o + 4] = Math.clamp(floor, 0.0, 1.0) + render3d_st.gd_clear_n += 1 + return true +} + +# one clearing's share at (x, z), 0..1: `floor` inside r_in, 1 past r_out +export function gf_clear_one(cx: float, cz: float, r_in: float, r_out: float, floor: float, x: float, z: float) -> float { + let dx = x - cx + let dz = z - cz + let d2 = dx * dx + dz * dz + if d2 > r_out * r_out { return 1.0 } + let d = Math.sqrt(d2) + var t = Math.clamp((d - r_in) / (r_out - r_in), 0.0, 1.0) + t = t * t * (3.0 - 2.0 * t) + return floor + (1.0 - floor) * t +} + +# every clearing's share at (x, z), multiplied in the order they were handed over +export function ground_clear_at(render3d_st: Render3dState, x: float, z: float) -> float { + var k = 1.0 + for c in 0 .. render3d_st.gd_clear_n { + let o = c * 5 + let cl = render3d_st.gd_clear + k = k * gf_clear_one(cl[o], cl[o + 1], cl[o + 2], cl[o + 3], cl[o + 4], x, z) + } + return k +} diff --git a/packages/ludic.render3d/ground_fill.ludic b/packages/ludic.render3d/ground_fill.ludic index feab1b2f..24821bc3 100644 --- a/packages/ludic.render3d/ground_fill.ludic +++ b/packages/ludic.render3d/ground_fill.ludic @@ -1,7 +1,6 @@ -# ground_fill.ludic — a ground layer's instances in one chunk of its stream, placed from its painted density -# and nothing else: a candidate in each cell of the band's step, moved by the jitter, kept when a hash of -# (layer, chunk, band, cell) falls under the density there (times the game's trample, where it asks). The -# same numbers on every machine and in the editor's preview; the camera decides only which chunks. +# ground_fill.ludic — a ground layer's instances in one chunk of its stream, from its painted density and +# nothing else: each cell's candidate (ground_cand.ludic), the same on every machine and in the editor. The +# camera picks only the chunks and their band; for a solid layer (ground_solid.ludic) not where things stand. # a ground layer's numbers, as the game's row (GroundLayers) gives them; `layer` is its index in the densities export property GroundFill { @@ -18,13 +17,14 @@ export property GroundFill { sink: float = 0.03, trample: bool = true, scale_var: float = 0.0, # each instance's own size: x (1 +- var), from its hash (a flower is not its neighbour) - band_grow: float = 0.0 # a far band's instances larger by this a band, to keep the cover as the step widens + band_grow: float = 0.0, # a far band's instances larger by this a band, to keep the cover as the step widens + solid: bool = false # things to touch: one step (step0) at every band, a stable id each, distance only thins } # how much of a candidate at (x, z) the game lets stand (the camp, the town): 0..1, asked only when the row says export function r3d_on_ground_trample(render3d_st: mut Render3dState, f: fn(float, float) -> float) -> void { render3d_st.gd_trample_cb = f } -function gf_step(g: GroundFill, band: int) -> float { +export function gf_step(g: GroundFill, band: int) -> float { if band == 0 { return g.step0 } if band == 1 { return g.step1 } if band == 2 { return g.step2 } @@ -32,7 +32,7 @@ function gf_step(g: GroundFill, band: int) -> float { } # a hash of the candidate and one of its draws, 0..1 (a 32-bit mix: the same on every machine) -function gf_hash(a: int, b: int, c: int, d: int, e: int, k: int) -> float { +export function gf_hash(a: int, b: int, c: int, d: int, e: int, k: int) -> float { var h = (a * 73856093) ^ (b * 19349663) ^ (c * 83492791) ^ (d * 2654435761) ^ (e * 1597334677) ^ (k * 3812015801) h = h & 0xFFFFFFFF h = ((h ^ (h >> 16)) * 0x7feb352d) & 0xFFFFFFFF @@ -43,25 +43,21 @@ function gf_hash(a: int, b: int, c: int, d: int, e: int, k: int) -> float { # chunk (cx, cz) of stream s at `band`, from layer g (call it inside the game's r3d_stream_fill) export function ground_fill(render3d_st: mut Render3dState, s: Stream, cx: int, cz: int, band: int, g: GroundFill) -> void { + if g.solid { + ground_fill_solid(render3d_st, s, cx, cz, band, g) + return + } let step = gf_step(g, band) if step <= 0.0 or render3d_st.gd_file == null { return } let x0 = s.ox + float(cx) * s.size let z0 = s.oz + float(cz) * s.size let cells = int(Math.ceil(s.size / step)) + let c = render3d_st.gd_cand for j in 0 .. cells { for i in 0 .. cells { - let px = x0 + (float(i) + 0.5 + (gf_hash(g.layer, cx, cz, band, i * 65536 + j, 0) - 0.5) * g.jitter) * step - let pz = z0 + (float(j) + 0.5 + (gf_hash(g.layer, cx, cz, band, i * 65536 + j, 1) - 0.5) * g.jitter) * step - if px < x0 or px >= x0 + s.size or pz < z0 or pz >= z0 + s.size { continue } - var keep = ground_density_at(render3d_st, g.layer, px, pz, 0) - if keep <= 0.0 { continue } - if g.trample and render3d_st.gd_trample_cb != null { keep = keep * render3d_st.gd_trample_cb(px, pz) } - let e = i * 65536 + j - if gf_hash(g.layer, cx, cz, band, e, 2) >= keep { continue } - var sc = Math.lerp(g.scale_lo, g.scale_hi, ground_density_at(render3d_st, g.layer, px, pz, 1)) - sc = sc * (1.0 + (gf_hash(g.layer, cx, cz, band, e, 6) * 2.0 - 1.0) * g.scale_var) * (1.0 + g.band_grow * float(band)) - let h = terrain_height(render3d_st, px, pz) - g.sink - stream_emit(render3d_st, s, px, h, pz, sc, gf_hash(g.layer, cx, cz, band, e, 3) * 2.0 * PI, gf_hash(g.layer, cx, cz, band, e, 4), Math.lerp(g.wind_lo, g.wind_hi, gf_hash(g.layer, cx, cz, band, e, 5))) + if not ground_candidate(render3d_st, g, cx, cz, band, i, j, x0, z0, s.size, c) { continue } + let h = terrain_height(render3d_st, c.x, c.z) - g.sink + stream_emit(render3d_st, s, c.x, h, c.z, c.scale, c.yaw, c.seed, c.wind) } } } diff --git a/packages/ludic.render3d/ground_solid.ludic b/packages/ludic.render3d/ground_solid.ludic new file mode 100644 index 00000000..e52021f6 --- /dev/null +++ b/packages/ludic.render3d/ground_solid.ludic @@ -0,0 +1,65 @@ +# ground_solid.ludic — a solid layer (rocks, trees): its candidates at step0 and band 0 whatever the camera, so +# a thing stands in one place for physics, the nav bake, a save and the draw alike. Its id is (layer, chunk, +# cell), stable while the painting under it is; a far band draws a subset of the same things, never others. + +const GS_SPAN: int = 256 # chunks a side an id holds: -128 .. 127 about the stream's corner +const GS_CELLS: int = 512 # cells a chunk an id holds: a solid layer's step is at least the chunk's side / 22 + +# the id of cell (i, j) of chunk (cx, cz) in `layer`, where a chunk is cells x cells; -1 past what an id holds +export function ground_solid_id(layer: int, cx: int, cz: int, i: int, j: int, cells: int) -> int { + if layer < 0 or layer >= GD_LAYERS or cells * cells > GS_CELLS { return -1 } + if cx < -128 or cx > 127 or cz < -128 or cz > 127 { return -1 } + return ((layer * GS_SPAN + (cz + 128)) * GS_SPAN + (cx + 128)) * GS_CELLS + i * cells + j +} + +export function ground_solid_layer(id: int) -> int { return id / (GS_CELLS * GS_SPAN * GS_SPAN) } +export function ground_solid_cx(id: int) -> int { return (id / GS_CELLS) % GS_SPAN - 128 } +export function ground_solid_cz(id: int) -> int { return (id / (GS_CELLS * GS_SPAN)) % GS_SPAN - 128 } + +# a solid layer's cells a chunk side +export function gs_cells(g: GroundFill, size: float) -> int { return int(Math.ceil(size / g.step0)) } + +# the share of a solid layer drawn at `band`: as much fewer as a cover layer's wider step would make it +export function gs_thin(g: GroundFill, band: int) -> float { + if band <= 0 { return 1.0 } + let r = g.step0 / gf_step(g, band) + return Math.min(r * r, 1.0) +} + +# cell (i, j) of chunk (cx, cz) of solid layer g into `out`, its id with it: true when one stands there +export function ground_solid_candidate(render3d_st: mut Render3dState, g: GroundFill, ox: float, oz: float, size: float, cx: int, cz: int, i: int, j: int, out: GroundCand) -> bool { + if g.step0 <= 0.0 { return false } + let id = ground_solid_id(g.layer, cx, cz, i, j, gs_cells(g, size)) + if id < 0 { return false } + let x0 = ox + float(cx) * size + let z0 = oz + float(cz) * size + if not ground_candidate(render3d_st, g, cx, cz, 0, i, j, x0, z0, size, out) { return false } + out.id = id + return true +} + +# the draw: every candidate at band 0, a stable subset of them farther out (draw 7 under gs_thin) +function ground_fill_solid(render3d_st: mut Render3dState, s: Stream, cx: int, cz: int, band: int, g: GroundFill) -> void { + if g.step0 <= 0.0 or render3d_st.gd_file == null { return } + let cells = gs_cells(g, s.size) + let thin = gs_thin(g, band) + let c = render3d_st.gd_cand + for j in 0 .. cells { + for i in 0 .. cells { + if not ground_solid_candidate(render3d_st, g, s.ox, s.oz, s.size, cx, cz, i, j, c) { continue } + if band > 0 and not (gf_hash(g.layer, cx, cz, 0, c.cell, 7) < thin) { continue } + let h = terrain_height(render3d_st, c.x, c.z) - g.sink + stream_emit(render3d_st, s, c.x, h, c.z, c.scale, c.yaw, c.seed, c.wind) + } + } +} + +# the candidate `id` names in solid layer g (the stream's corner and chunk side): false when the painting +# under it keeps nothing there now, or the id is not this layer's +export function ground_solid_at(render3d_st: mut Render3dState, g: GroundFill, ox: float, oz: float, size: float, id: int, out: GroundCand) -> bool { + if id < 0 or g.step0 <= 0.0 or ground_solid_layer(id) != g.layer { return false } + let cells = gs_cells(g, size) + let cell = id % GS_CELLS + if cells <= 0 or cell >= cells * cells { return false } + return ground_solid_candidate(render3d_st, g, ox, oz, size, ground_solid_cx(id), ground_solid_cz(id), cell / cells, cell % cells, out) +} diff --git a/packages/ludic.render3d/ground_solid_list.ludic b/packages/ludic.render3d/ground_solid_list.ludic new file mode 100644 index 00000000..a04319c0 --- /dev/null +++ b/packages/ludic.render3d/ground_solid_list.ludic @@ -0,0 +1,60 @@ +# ground_solid_list.ludic — a solid layer's things in one chunk, into a list the caller made once: what physics, +# the nav bake and a save ask, the same candidates the draw grows at band 0 (ground_solid.ludic). + +export property GroundSolids { + cap: int = 0, + n: int = 0, # how many were written, never over cap + found: int = 0, # how many stand in the chunk: over cap, the list was too short for it + ids: words = null, + xs: floats = null, + zs: floats = null, + scales: floats = null, + yaws: floats = null, + seeds: floats = null, + winds: floats = null, + cand: GroundCand = null +} + +@alloc_ok("a caller's list of solid things, made once and reused") +export function ground_solids_new(cap: int) -> GroundSolids { + let l = new GroundSolids + l.cap = max(cap, 1) + l.ids = words(l.cap) + l.xs = floats(l.cap) + l.zs = floats(l.cap) + l.scales = floats(l.cap) + l.yaws = floats(l.cap) + l.seeds = floats(l.cap) + l.winds = floats(l.cap) + l.cand = new GroundCand + return l +} + +function gs_put(l: GroundSolids, c: GroundCand) -> void { + l.found += 1 + if l.n >= l.cap { return } + let k = l.n + l.ids[k] = c.id + l.xs[k] = c.x + l.zs[k] = c.z + l.scales[k] = c.scale + l.yaws[k] = c.yaw + l.seeds[k] = c.seed + l.winds[k] = c.wind + l.n += 1 +} + +# chunk (cx, cz) of solid layer g - the stream's corner (ox, oz) and chunk side `size` - into `out`, emptied +# first, in cell order (j, then i); answers how many stand there +export function ground_solid_list(render3d_st: mut Render3dState, g: GroundFill, ox: float, oz: float, size: float, cx: int, cz: int, out: GroundSolids) -> int { + out.n = 0 + out.found = 0 + if g.step0 <= 0.0 or render3d_st.gd_file == null { return 0 } + let cells = gs_cells(g, size) + for j in 0 .. cells { + for i in 0 .. cells { + if ground_solid_candidate(render3d_st, g, ox, oz, size, cx, cz, i, j, out.cand) { gs_put(out, out.cand) } + } + } + return out.found +} diff --git a/packages/ludic.render3d/r3d.ludic b/packages/ludic.render3d/r3d.ludic index c3936e6b..fff3f334 100644 --- a/packages/ludic.render3d/r3d.ludic +++ b/packages/ludic.render3d/r3d.ludic @@ -60,6 +60,10 @@ import "stream_baked.ludic" import "ground_density_write.ludic" import "ground_density.ludic" import "ground_fill.ludic" +import "ground_cand.ludic" +import "ground_clear.ludic" +import "ground_solid.ludic" +import "ground_solid_list.ludic" import "fog_streams.ludic" import "grass_kind.ludic" import "grass_bind.ludic" From 54e267dfa6cbcbe28f42072e9fd1130b86ad5b19 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Wed, 30 Sep 2026 15:24:23 +0300 Subject: [PATCH 2/4] render3d: ground_fill's conformance test and the generator of its golden tests/ground_fill_test.ludic compares gg_json (gf_hash over fixed inputs, Math.lerp cases whose order matters, the yaw as model.vert turns by it, a hand-written density, one cover chunk with a clearing and one solid chunk) line for line with tests/ground_fill_golden.json, and checks ground_fill draws exactly the candidates and a solid layer's far bands a subset of band 0. The golden is written by tests/gen/ground_fill_golden.ludic, run from the repository root; it is not committed here. Co-Authored-By: Claude Opus 5.5 --- .../tests/fakes/ground_golden.ludic | 96 +++++++++++++++++++ .../tests/fakes/ground_golden_check.ludic | 21 ++++ .../tests/fakes/ground_golden_json.ludic | 66 +++++++++++++ .../tests/fakes/ground_golden_things.ludic | 65 +++++++++++++ .../tests/gen/ground_fill_golden.ludic | 21 ++++ .../tests/ground_fill_test.ludic | 93 ++++++++++++++++++ 6 files changed, 362 insertions(+) create mode 100644 packages/ludic.render3d/tests/fakes/ground_golden.ludic create mode 100644 packages/ludic.render3d/tests/fakes/ground_golden_check.ludic create mode 100644 packages/ludic.render3d/tests/fakes/ground_golden_json.ludic create mode 100644 packages/ludic.render3d/tests/fakes/ground_golden_things.ludic create mode 100644 packages/ludic.render3d/tests/gen/ground_fill_golden.ludic create mode 100644 packages/ludic.render3d/tests/ground_fill_test.ludic diff --git a/packages/ludic.render3d/tests/fakes/ground_golden.ludic b/packages/ludic.render3d/tests/fakes/ground_golden.ludic new file mode 100644 index 00000000..87d76cde --- /dev/null +++ b/packages/ludic.render3d/tests/fakes/ground_golden.ludic @@ -0,0 +1,96 @@ +# fakes/ground_golden.ludic - ground_fill's conformance fixture: one density layer written by hand (R and G, a +# block of raw tiles round the chunk, every other tile empty), a cover layer with a clearing and a solid layer +# grown over it, and chunk (3, -2) of a 32 m grid from world zero - across four tiles, at negative z. +const GG_CX: int = 3 +const GG_CZ: int = -2 +const GG_BAND: int = 1 # the cover layer's band: a step that does not divide the chunk, and band_grow +const GG_T0: int = 64 # the raw tiles: x 64..67, z 61..64 (the chunk reads x 65..66, z 62..63) +const GG_Z0: int = 61 +const GG_TN: int = 4 + +# the density's texels as written, 0..255: R ramps through empty and full, G is a weave +function gg_r(tx: int, tz: int) -> int { return min(max((tx * 53 + tz * 97) % 320 - 32, 0), 255) } +function gg_g(tx: int, tz: int) -> int { return (tx * 29 + tz * 13) & 255 } + +function gg_put(b: []byte, at: int, v: int) -> void { + for k in 0 .. 4 { b[at + k] = (v >> (k * 8)) & 255 } +} + +# an LGD2 payload: one layer of two channels, the raw tiles after the index +function gg_payload() -> []byte { + let n = TERRAIN_RES / TT_TEX + let tb = GD_SIDE * GD_SIDE * 2 + let at0 = GD_HEADW * 4 + n * n * 8 + let size = at0 + GG_TN * GG_TN * tb + let b = buffer(size) + for k in 0 .. size { b[k] = 0 } + gg_put(b, 0, GD_MAGIC) + gg_put(b, 4, GD_VERSION) + gg_put(b, 8, n) + gg_put(b, 12, GD_SIDE) + gg_put(b, 16, 1) + gg_put(b, 32, 2) + gg_put(b, 36, GD_HEADW * 4) + for q in 0 .. GG_TN * GG_TN { + let tx = GG_T0 + q % GG_TN + let tz = GG_Z0 + q / GG_TN + let at = at0 + q * tb + gg_put(b, GD_HEADW * 4 + (tz * n + tx) * 8, at) + gg_put(b, GD_HEADW * 4 + (tz * n + tx) * 8 + 4, tb) + for p in 0 .. GD_SIDE * GD_SIDE { + let x = tx * GD_SIDE + p % GD_SIDE + let z = tz * GD_SIDE + p / GD_SIDE + b[at + p * 2] = gg_r(x, z) + b[at + p * 2 + 1] = gg_g(x, z) + } + } + return b +} + +# an 8 km map at world zero with no terrain (heights 0), the densities above, and the one clearing +function gg_open(render3d_st: mut Render3dState, tag: string) -> bool { + render3d_st.TERRAIN_HALF = 4096 + render3d_st.ter_ox = 0.0 + render3d_st.ter_oz = 0.0 + render3d_st.gd_trample_cb = null + r3d_ground_clearings_reset(render3d_st) + r3d_ground_clearing(render3d_st, 112.0, -48.0, 3.0, 7.5, 0.25) + let path = Os.temp_dir() + "/r3d_ground_golden_" + tag + ".lgd2" + let b = gg_payload() + return Fs.write_bytes(path, b, len(b)) and gd_open_at(render3d_st, path, 0) +} + +function gg_cover() -> GroundFill { + let g = new GroundFill + g.step0 = 2.0 + g.step1 = 2.5 + g.scale_lo = 0.6 + g.scale_hi = 1.4 + g.wind_lo = 0.5 + g.scale_var = 0.2 + g.band_grow = 0.15 + return g +} + +function gg_solid() -> GroundFill { + let g = gg_cover() + g.step0 = 4.0 + g.step1 = 5.0 + g.step2 = 8.0 + g.step3 = 12.0 + g.jitter = 0.8 + g.scale_lo = 0.8 + g.scale_hi = 1.6 + g.band_grow = 0.3 + g.trample = false + g.solid = true + return g +} + +function gg_stream(render3d_st: mut Render3dState) -> Stream { + if render3d_st.stream_scratch == null { render3d_st.stream_scratch = floats(4096 * INST_FLOATS) } + let s = new Stream + s.size = 32.0 + s.cur = new Chunk + return s +} diff --git a/packages/ludic.render3d/tests/fakes/ground_golden_check.ludic b/packages/ludic.render3d/tests/fakes/ground_golden_check.ludic new file mode 100644 index 00000000..289e95bb --- /dev/null +++ b/packages/ludic.render3d/tests/fakes/ground_golden_check.ludic @@ -0,0 +1,21 @@ +# fakes/ground_golden_check.ludic - what ground_fill_test compares drawn instances by + +# how far drawn instance k is from candidate c: 0 when every number is the same bits +function gft_off(render3d_st: Render3dState, k: int, c: GroundCand) -> int { + let o = k * INST_FLOATS + let st = render3d_st.stream_scratch + if st[o] != c.x or st[o + 2] != c.z or st[o + 3] != c.scale or st[o + 6] != c.seed or st[o + 7] != c.wind { return 1 } + if st[o + 4] != Math.sin(c.yaw) or st[o + 5] != Math.cos(c.yaw) { return 1 } + return 0 +} + +# drawn instances not at a listed thing's place +function gft_subset(render3d_st: Render3dState, n: int, l: GroundSolids) -> int { + var bad = 0 + for k in 0 .. n { + var hit = false + for q in 0 .. l.n { hit = hit or (render3d_st.stream_scratch[k * INST_FLOATS] == l.xs[q] and render3d_st.stream_scratch[k * INST_FLOATS + 2] == l.zs[q]) } + if not hit { bad += 1 } + } + return bad +} diff --git a/packages/ludic.render3d/tests/fakes/ground_golden_json.ludic b/packages/ludic.render3d/tests/fakes/ground_golden_json.ludic new file mode 100644 index 00000000..229846e5 --- /dev/null +++ b/packages/ludic.render3d/tests/fakes/ground_golden_json.ludic @@ -0,0 +1,66 @@ +# fakes/ground_golden_json.ludic - the golden's first half: every float is its IEEE single bits as an int, so +# the file round-trips exactly; Math.lerp's order, the yaw the model shader turns by, gf_hash and the density. + +function gg_b(x: float) -> string { return `{float_bits(x)}` } + +# Math.lerp is a + (b - a) * t; each case here is one a * (1 - t) + b * t would round differently +function gg_lerps() -> string { + let a = [1.0, 2.0, 6.04, -2.19] + let b = [1.7, 7.0, 1.72, 3.62] + let t = [0.9, 0.71, 0.639, 0.868] + var s = " \"lerp_order\": \"a + (b - a) * t\",\n \"lerp\": [\n" + for k in 0 .. 4 { + let other = a[k] * (1.0 - t[k]) + b[k] * t[k] + s = s + ` [{gg_b(a[k])}, {gg_b(b[k])}, {gg_b(t[k])}, {gg_b(Math.lerp(a[k], b[k], t[k]))}, {gg_b(other)}]` + if k < 3 { s = s + "," } + s = s + "\n" + } + return s + " ],\n" +} + +# model.vert: p' = (c x + s z, y, -s x + c z) with (s, c) = (sin yaw, cos yaw) as stream_emit stores them +function gg_yaw() -> string { + let y = 0.5 + let sn = Math.sin(y) + let cs = Math.cos(y) + var s = ` "yaw": {{"yaw": {gg_b(y)}, "sin": {gg_b(sn)}, "cos": {gg_b(cs)}, "rule": "p' = (c*x + s*z, y, -s*x + c*z)",\n` + s = s + ` "x_axis": {gg_turn(sn, cs, 1.0, 0.0)}, "z_axis": {gg_turn(sn, cs, 0.0, 1.0)}}},\n` + return s +} + +# where local (x, 0, z) lands, as the shader turns it +function gg_turn(sn: float, cs: float, x: float, z: float) -> string { return `[{gg_b(cs * x + sn * z)}, {gg_b(0.0)}, {gg_b(-sn * x + cs * z)}]` } + +function gg_hashes() -> string { + let v = [0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 0, 3, -2, 1, 7 * 65536 + 9, 2, 13, -128, 127, 3, 32767, 5, + 63, -1, -1, 0, 21 * 65536 + 21, 7, -5, 100000, -100000, 2, 123456789, 1, 2147483647, -2147483647, 65536, 3, -1, 4] + var s = " \"hash\": [\n" + let n = len(v) / 6 + for r in 0 .. n { + let o = r * 6 + s = s + ` [{v[o]}, {v[o + 1]}, {v[o + 2]}, {v[o + 3]}, {v[o + 4]}, {v[o + 5]}, {gg_b(gf_hash(v[o], v[o + 1], v[o + 2], v[o + 3], v[o + 4], v[o + 5]))}]` + if r < n - 1 { s = s + "," } + s = s + "\n" + } + return s + " ],\n" +} + +# the texels the chunk's bilinear reads (and a border), R and G row by row from (tx0, tz0); none outside is read +function gg_density() -> string { + let tx0 = 2094 + let tz0 = 2014 + let w = 20 + var r = "" + var g = "" + for k in 0 .. w * w { + if k > 0 { + r = r + "," + g = g + "," + } + r = r + `{gg_r(tx0 + k % w, tz0 + k / w)}` + g = g + `{gg_g(tx0 + k % w, tz0 + k / w)}` + } + var s = ` "density": {{"layer": 0, "half": 4096, "ter_ox": 0, "ter_oz": 0, "texel_m": 2, "rule": "fx = (x - ter_ox + half) / texel - 0.5, bilinear, / 255",\n` + s = s + ` "tx0": {tx0}, "tz0": {tz0}, "w": {w},\n "r": [{r}],\n "g": [{g}]}},\n` + return s +} diff --git a/packages/ludic.render3d/tests/fakes/ground_golden_things.ludic b/packages/ludic.render3d/tests/fakes/ground_golden_things.ludic new file mode 100644 index 00000000..6e31d0a3 --- /dev/null +++ b/packages/ludic.render3d/tests/fakes/ground_golden_things.ludic @@ -0,0 +1,65 @@ +# fakes/ground_golden_things.ludic - the golden's second half: the clearing, the layers' numbers and what each +# grows in the chunk (a cover layer at GG_BAND, a solid layer at band 0 with what band 2 still draws) + +function gg_fill(g: GroundFill) -> string { + var s = `{{"layer": {g.layer}, "steps": [{gg_b(g.step0)}, {gg_b(g.step1)}, {gg_b(g.step2)}, {gg_b(g.step3)}], "jitter": {gg_b(g.jitter)}, ` + s = s + `"scale_lo": {gg_b(g.scale_lo)}, "scale_hi": {gg_b(g.scale_hi)}, "wind_lo": {gg_b(g.wind_lo)}, "wind_hi": {gg_b(g.wind_hi)}, ` + s = s + `"scale_var": {gg_b(g.scale_var)}, "band_grow": {gg_b(g.band_grow)}, "sink": {gg_b(g.sink)}, "trample": {g.trample}, "solid": {g.solid}}}` + return s +} + +function gg_clearings(render3d_st: Render3dState) -> string { + var s = " \"clearings_rule\": \"floor + (1 - floor) * smoothstep((d - r_in) / (r_out - r_in)), 1 past r_out\",\n \"clearings\": [" + for c in 0 .. render3d_st.gd_clear_n { + let o = c * 5 + let cl = render3d_st.gd_clear + if c > 0 { s = s + ", " } + s = s + `[{gg_b(cl[o])}, {gg_b(cl[o + 1])}, {gg_b(cl[o + 2])}, {gg_b(cl[o + 3])}, {gg_b(cl[o + 4])}]` + } + return s + "],\n" +} + +function gg_thing(c: GroundCand) -> string { return `{gg_b(c.x)}, {gg_b(c.z)}, {gg_b(c.scale)}, {gg_b(c.yaw)}, {gg_b(c.seed)}, {gg_b(c.wind)}` } + +# the cover layer's things in cell order: [cell, x, z, scale, yaw, seed, wind] +function gg_cover_json(render3d_st: mut Render3dState) -> string { + let g = gg_cover() + let size = 32.0 + let cells = int(Math.ceil(size / gf_step(g, GG_BAND))) + let c = new GroundCand + var s = ` "cover": {{"fill": {gg_fill(g)}, "ox": 0, "oz": 0, "size": {gg_b(size)}, "cx": {GG_CX}, "cz": {GG_CZ}, "band": {GG_BAND}, "things": [\n` + var first = true + for j in 0 .. cells { + for i in 0 .. cells { + if not ground_candidate(render3d_st, g, GG_CX, GG_CZ, GG_BAND, i, j, float(GG_CX) * size, float(GG_CZ) * size, size, c) { continue } + if not first { s = s + ",\n" } + first = false + s = s + ` [{c.cell}, {gg_thing(c)}]` + } + } + return s + "\n ]}},\n" +} + +# the solid layer's things: [id, cell, x, z, scale, yaw, seed, wind, drawn at band 2] +function gg_solid_json(render3d_st: mut Render3dState) -> string { + let g = gg_solid() + let l = ground_solids_new(256) + ground_solid_list(render3d_st, g, 0.0, 0.0, 32.0, GG_CX, GG_CZ, l) + let c = new GroundCand + var s = ` "solid": {{"fill": {gg_fill(g)}, "ox": 0, "oz": 0, "size": {gg_b(32.0)}, "cx": {GG_CX}, "cz": {GG_CZ}, "far_band": 2, "things": [\n` + for k in 0 .. l.n { + ground_solid_at(render3d_st, g, 0.0, 0.0, 32.0, l.ids[k], c) + let far = gf_hash(g.layer, GG_CX, GG_CZ, 0, c.cell, 7) < gs_thin(g, 2) + if k > 0 { s = s + ",\n" } + s = s + ` [{c.id}, {c.cell}, {gg_thing(c)}, {far}]` + } + return s + "\n ]}}\n" +} + +# the whole golden, as ground_fill_golden.json holds it +function gg_json(render3d_st: mut Render3dState) -> string { + var s = "{\n \"about\": \"ludic.render3d ground_fill conformance (tests/ground_fill_test.ludic; regenerate with tests/gen/ground_fill_golden.ludic)\",\n" + s = s + " \"numbers\": \"every float is its IEEE-754 single-precision bits as a signed 32-bit int\",\n" + s = s + gg_lerps() + gg_yaw() + gg_hashes() + gg_density() + gg_clearings(render3d_st) + return s + gg_cover_json(render3d_st) + gg_solid_json(render3d_st) + "}\n" +} diff --git a/packages/ludic.render3d/tests/gen/ground_fill_golden.ludic b/packages/ludic.render3d/tests/gen/ground_fill_golden.ludic new file mode 100644 index 00000000..4a6941f0 --- /dev/null +++ b/packages/ludic.render3d/tests/gen/ground_fill_golden.ludic @@ -0,0 +1,21 @@ +# gen/ground_fill_golden.ludic - writes tests/ground_fill_golden.json from what ground_fill grows today. Run it +# from the repository root after a deliberate change to the fill, and commit the file with the change. +import "ludic.render3d/r3d.ludic" +program GroundFillGolden { + numbers float + import "../fakes/ground_golden.ludic" + import "../fakes/ground_golden_json.ludic" + import "../fakes/ground_golden_things.ludic" + # never called: naming Input links the engine's runtime, which render3d's textures stand on + function links_runtime() -> bool { return Input.key_pressed(0) } + + entry (render3d_st: mut Render3dState) { + if not gg_open(render3d_st, "write") { + print("ground_fill_golden: the density could not be written") + return + } + let path = "packages/ludic.render3d/tests/ground_fill_golden.json" + let text = gg_json(render3d_st) + if Fs.write_text(path, text) { print(`ground_fill_golden: wrote {path}`) } else { print(`ground_fill_golden: could not write {path}`) } + } +} diff --git a/packages/ludic.render3d/tests/ground_fill_test.ludic b/packages/ludic.render3d/tests/ground_fill_test.ludic new file mode 100644 index 00000000..ee749368 --- /dev/null +++ b/packages/ludic.render3d/tests/ground_fill_test.ludic @@ -0,0 +1,93 @@ +# ground_fill_test.ludic - ground_fill against its golden (ground_fill_golden.json, which the studio's own test +# reads too): the hash, Math.lerp's order, the yaw, one chunk of a cover layer and of a solid layer over a +# hand-written density - and ground_fill drawing exactly the candidates, a solid layer's far band a subset +import "ludic.render3d/r3d.ludic" +program GroundFillTest { + numbers float + import "fakes/ground_golden.ludic" + import "fakes/ground_golden_json.ludic" + import "fakes/ground_golden_things.ludic" + import "fakes/ground_golden_check.ludic" + # never called: naming Input links the engine's runtime, which render3d's textures stand on + function links_runtime() -> bool { return Input.key_pressed(0) } + + test "the golden is what the generator writes today, number for number" (render3d_st: mut Render3dState) { + expect(gg_open(render3d_st, "golden")) + let want = Fs.read_text("packages/ludic.render3d/tests/ground_fill_golden.json") + expect(want != null) + if want == null { return } + let got = Text.split(gg_json(render3d_st), "\n") + let had = Text.split(want, "\n") + expect_eq(len(got), len(had)) + for k in 0 .. min(len(got), len(had)) { + if got[k] != had[k] { print(`line {k + 1}: got {got[k]}`) } + expect(got[k] == had[k]) + } + } + + test "Math.lerp is a + (b - a) * t" { + let a = [1.0, 2.0, 6.04, -2.19] + let b = [1.7, 7.0, 1.72, 3.62] + let t = [0.9, 0.71, 0.639, 0.868] + for k in 0 .. 4 { expect_eq(float_bits(Math.lerp(a[k], b[k], t[k])), float_bits(a[k] + (b[k] - a[k]) * t[k])) } + } + + test "ground_fill draws exactly the cover layer's candidates, in cell order" (render3d_st: mut Render3dState) { + expect(gg_open(render3d_st, "cover")) + let g = gg_cover() + let s = gg_stream(render3d_st) + ground_fill(render3d_st, s, GG_CX, GG_CZ, GG_BAND, g) + let cells = int(Math.ceil(32.0 / gf_step(g, GG_BAND))) + let c = new GroundCand + var n = 0 + var off = 0 + for j in 0 .. cells { + for i in 0 .. cells { + if not ground_candidate(render3d_st, g, GG_CX, GG_CZ, GG_BAND, i, j, 96.0, -64.0, 32.0, c) { continue } + off += gft_off(render3d_st, n, c) + n += 1 + } + } + expect(n > 20) + expect_eq(s.cur.count, n) + expect_eq(off, 0) + } + + test "a solid layer stands still: every band draws a subset of band 0, listed and answered by id" (render3d_st: mut Render3dState) { + expect(gg_open(render3d_st, "solid")) + let g = gg_solid() + let l = ground_solids_new(256) + let n = ground_solid_list(render3d_st, g, 0.0, 0.0, 32.0, GG_CX, GG_CZ, l) + expect(n > 8) + expect_eq(l.n, n) + let c = new GroundCand + var off = 0 + var far = 0 + for k in 0 .. l.n { + expect(ground_solid_at(render3d_st, g, 0.0, 0.0, 32.0, l.ids[k], c)) + if c.x != l.xs[k] or c.z != l.zs[k] or c.scale != l.scales[k] or c.yaw != l.yaws[k] { off += 1 } + if gf_hash(g.layer, GG_CX, GG_CZ, 0, c.cell, 7) < gs_thin(g, 2) { far += 1 } + } + expect_eq(off, 0) + for band in 0 .. 4 { + let s = gg_stream(render3d_st) + ground_fill(render3d_st, s, GG_CX, GG_CZ, band, g) + expect_eq(gft_subset(render3d_st, s.cur.count, l), 0) + if band == 0 { expect_eq(s.cur.count, n) } + if band == 2 { expect_eq(s.cur.count, far) } + } + expect(far < n) + expect(not ground_solid_at(render3d_st, g, 0.0, 0.0, 32.0, ground_solid_id(g.layer + 1, GG_CX, GG_CZ, 0, 0, 8), c)) + } + + test "a solid id holds its layer and chunk, and refuses what it cannot hold" { + let id = ground_solid_id(63, -128, 127, 21, 21, 22) + expect(id > 0) + expect_eq(ground_solid_layer(id), 63) + expect_eq(ground_solid_cx(id), -128) + expect_eq(ground_solid_cz(id), 127) + expect_eq(ground_solid_id(0, 128, 0, 0, 0, 8), -1) + expect_eq(ground_solid_id(0, 0, 0, 0, 0, 23), -1) + expect_eq(ground_solid_id(64, 0, 0, 0, 0, 8), -1) + } +} From 530956889b7421e98968a4733c222730f2b4c0df Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Wed, 30 Sep 2026 15:24:23 +0300 Subject: [PATCH 3/4] render3d: a README note on painted ground layers, and the changeset Co-Authored-By: Claude Opus 5.5 --- changes/ground-painted-zones.md | 13 ++++++++++++ packages/ludic.render3d/README.md | 35 +++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 changes/ground-painted-zones.md create mode 100644 packages/ludic.render3d/README.md diff --git a/changes/ground-painted-zones.md b/changes/ground-painted-zones.md new file mode 100644 index 00000000..90e2ec0a --- /dev/null +++ b/changes/ground-painted-zones.md @@ -0,0 +1,13 @@ +bump: minor +type: feat +**`ludic.render3d`: painted ground layers grow solid things, with ids, and the trample is data.** +`ground_fill`'s candidate is its own function, `ground_candidate` (pure `gf_*` steps with the density read +between them, into a caller-held `GroundCand`), and `ground_fill` draws exactly its answers - the same +operations in the same order as before, so every cover layer grows bit for bit what it did. A layer with +`solid: true` is filled at `step0` with band 0's hashes whatever the camera, a far band drawing a stable +subset; each thing has an int id from (layer, chunk, cell) (`ground_solid_id`), and `ground_solid_list` / +`ground_solid_at` answer a chunk's things or one by id for physics, the nav bake and saves. +`r3d_ground_clearing(x, z, r_in, r_out, floor)` hands the trample over as discs the editor can see, beside +the `r3d_on_ground_trample` callback, which still works. `tests/ground_fill_test.ludic` holds all of it to +`tests/ground_fill_golden.json` (written by `tests/gen/ground_fill_golden.ludic`), the file the studio's +TypeScript generator is tested against. diff --git a/packages/ludic.render3d/README.md b/packages/ludic.render3d/README.md new file mode 100644 index 00000000..9c0f10a8 --- /dev/null +++ b/packages/ludic.render3d/README.md @@ -0,0 +1,35 @@ +# ludic.render3d + +A physically based 3D renderer over Vulkan. `FEATURES.md` lists what is implemented and where. + +## Painted ground layers + +A ground layer is a density painted over the map (R the chance a cell keeps one, G its scale, 2 m texels +baked into an LGD2 file, `ground_density.ludic`) and a `GroundFill` row of numbers. Everything it grows is +a pure function of those two - the same on every machine and in the editor's preview. + +- **The candidate** (`ground_cand.ludic`): for (layer, chunk, band, cell) `ground_candidate` answers + whether one stands there and its x, z, scale, yaw, seed and wind, into a `GroundCand` the caller holds. + Its steps are pure: `gf_along` (the jitter, draws 0 and 1), `gf_keeps` (draw 2 under the chance), + `gf_scale` (G and draw 6), `gf_yaw` (3), `gf_seed` (4), `gf_wind` (5). `ground_fill` draws exactly + these. Numbers are IEEE single precision, `Math.lerp` is `a + (b - a) * t`, `gf_hash` is 32-bit + integer arithmetic with a logical `>>`, and the model shader turns local (x, z) to + `(c x + s z, -s x + c z)` - three.js's rotation about +Y. +- **Cover and solid.** A cover layer (grass, flowers, pebbles) is filled at its band's step, so a far + band is a different, sparser sample. A layer with `solid: true` (rocks, trees) is filled at `step0` + with band 0's hashes whatever the band, and a far band draws the subset whose draw 7 falls under + `(step0 / step_band)^2`; its scale ignores `band_grow`. Nothing solid moves with the camera. +- **Solid ids** (`ground_solid.ludic`): `ground_solid_id(layer, cx, cz, i, j, cells)` = + `((layer * 256 + cz + 128) * 256 + cx + 128) * 512 + i * cells + j`, a positive 31-bit int, stable + while the painting under it is. It holds chunks -128 .. 127 about the stream's corner and at most 22 + cells a chunk side (a step of at least 32 / 22 m on 32 m chunks); a cell past that has no id and is + not grown. `ground_solid_list` fills a caller's `GroundSolids` (made once, `ground_solids_new`) with a + chunk's things for physics, the nav bake or a save; `ground_solid_at` answers one by id. +- **The trample as data** (`ground_clear.ludic`): `r3d_ground_clearing(x, z, r_in, r_out, floor)` hands + over a clearing - `floor` inside `r_in`, back to full at `r_out` on a smoothstep, the shape of the + camp's ring and the town's yard. A layer with `trample` multiplies its chance by the game's callback + (`r3d_on_ground_trample`, kept for now) and then by every clearing. A game should use one or the other. +- **Conformance**: `tests/ground_fill_test.ludic` holds the package to `tests/ground_fill_golden.json` + (gf_hash, lerp, yaw, one cover chunk and one solid chunk over a hand-written density), which the + studio's own test reads. After a deliberate change, rebuild it from the repository root: + `bin/ludic run packages/ludic.render3d/tests/gen/ground_fill_golden.ludic --headless`, and commit it. From 87b95625ffd3c96761446e9fc94a2303e0c476c3 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Wed, 30 Sep 2026 15:25:43 +0300 Subject: [PATCH 4/4] render3d: ground_fill's golden conformance data, generated by tests/gen/ground_fill_golden.ludic (two runs byte-identical) Co-Authored-By: Claude Opus 5.5 --- .../tests/ground_fill_golden.json | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 packages/ludic.render3d/tests/ground_fill_golden.json diff --git a/packages/ludic.render3d/tests/ground_fill_golden.json b/packages/ludic.render3d/tests/ground_fill_golden.json new file mode 100644 index 00000000..daf37581 --- /dev/null +++ b/packages/ludic.render3d/tests/ground_fill_golden.json @@ -0,0 +1,135 @@ +{ + "about": "ludic.render3d ground_fill conformance (tests/ground_fill_test.ludic; regenerate with tests/gen/ground_fill_golden.ludic)", + "numbers": "every float is its IEEE-754 single-precision bits as a signed 32-bit int", + "lerp_order": "a + (b - a) * t", + "lerp": [ + [1065353216, 1071225242, 1063675494, 1070638040, 1070638039], + [1073741824, 1088421888, 1060487823, 1085381018, 1085381017], + [1086408622, 1071393014, 1059296641, 1079108521, 1079108520], + [-1072944906, 1080536596, 1063138623, 1077319900, 1077319899] + ], + "yaw": {"yaw": 1056964608, "sin": 1056274244, "cos": 1063299392, "rule": "p' = (c*x + s*z, y, -s*x + c*z)", + "x_axis": [1063299392, 0, -1091209404], "z_axis": [1056274244, 0, 1063299392]}, + "hash": [ + [0, 0, 0, 0, 0, 0, 0], + [1, 2, 3, 4, 5, 6, 1059717290], + [0, 3, -2, 1, 458761, 2, 1057636647], + [13, -128, 127, 3, 32767, 5, 1049010834], + [63, -1, -1, 0, 1376277, 7, 1057639637], + [-5, 100000, -100000, 2, 123456789, 1, 1063465007], + [2147483647, -2147483647, 65536, 3, -1, 4, 1040271092] + ], + "density": {"layer": 0, "half": 4096, "ter_ox": 0, "ter_oz": 0, "texel_m": 2, "rule": "fx = (x - ter_ox + half) / texel - 0.5, bilinear, / 255", + "tx0": 2094, "tz0": 2014, "w": 20, + "r": [68,121,174,227,255,13,66,119,172,225,255,11,64,117,170,223,255,9,62,115,165,218,255,4,57,110,163,216,255,2,55,108,161,214,255,0,53,106,159,212,255,0,48,101,154,207,255,0,46,99,152,205,255,0,44,97,150,203,255,0,39,92,145,198,251,0,37,90,143,196,249,0,35,88,141,194,247,0,33,86,136,189,242,0,28,81,134,187,240,0,26,79,132,185,238,0,24,77,130,183,233,255,19,72,125,178,231,255,17,70,123,176,229,255,15,68,121,174,227,255,10,63,116,169,222,255,8,61,114,167,220,255,6,59,112,165,218,255,4,57,107,160,213,255,0,52,105,158,211,255,0,50,103,156,209,255,0,48,101,154,204,255,0,43,96,149,202,255,0,41,94,147,200,253,0,39,92,145,198,251,0,34,87,140,193,246,0,32,85,138,191,244,0,30,83,136,189,242,0,28,78,131,184,237,0,23,76,129,182,235,0,21,74,127,180,233,255,19,72,125,175,228,255,14,67,120,173,226,255,12,65,118,171,224,255,10,63,116,169,222,255,5,58,111,164,217,255,3,56,109,162,215,255,1,54,107,160,213,255,0,49,102,155,208,255,0,47,100,153,206,255,0,45,98,151,204,255,0,43,96,146,199,252,0,38,91,144,197,250,0,36,89,142,195,248,0,34,87,140,193,243,0,29,82,135,188,241,0,27,80,133,186,239,0,25,78,131,184,237,0,20,73,126,179,232,255,18,71,124,177,230,255,16,69,122,175,228,255,14,67,117,170,223,255,9,62,115,168,221,255,7,60,113,166,219,255,5,58,111,164,214,255,0,53,106,159,212,255,0,51,104,157,210,255,0,49,102,155,208,255,0,44,97,150,203,255,0,42,95,148,201,254,0,40,93,146,199,252,0,38], + "g": [124,153,182,211,240,13,42,71,100,129,158,187,216,245,18,47,76,105,134,163,137,166,195,224,253,26,55,84,113,142,171,200,229,2,31,60,89,118,147,176,150,179,208,237,10,39,68,97,126,155,184,213,242,15,44,73,102,131,160,189,163,192,221,250,23,52,81,110,139,168,197,226,255,28,57,86,115,144,173,202,176,205,234,7,36,65,94,123,152,181,210,239,12,41,70,99,128,157,186,215,189,218,247,20,49,78,107,136,165,194,223,252,25,54,83,112,141,170,199,228,202,231,4,33,62,91,120,149,178,207,236,9,38,67,96,125,154,183,212,241,215,244,17,46,75,104,133,162,191,220,249,22,51,80,109,138,167,196,225,254,228,1,30,59,88,117,146,175,204,233,6,35,64,93,122,151,180,209,238,11,241,14,43,72,101,130,159,188,217,246,19,48,77,106,135,164,193,222,251,24,254,27,56,85,114,143,172,201,230,3,32,61,90,119,148,177,206,235,8,37,11,40,69,98,127,156,185,214,243,16,45,74,103,132,161,190,219,248,21,50,24,53,82,111,140,169,198,227,0,29,58,87,116,145,174,203,232,5,34,63,37,66,95,124,153,182,211,240,13,42,71,100,129,158,187,216,245,18,47,76,50,79,108,137,166,195,224,253,26,55,84,113,142,171,200,229,2,31,60,89,63,92,121,150,179,208,237,10,39,68,97,126,155,184,213,242,15,44,73,102,76,105,134,163,192,221,250,23,52,81,110,139,168,197,226,255,28,57,86,115,89,118,147,176,205,234,7,36,65,94,123,152,181,210,239,12,41,70,99,128,102,131,160,189,218,247,20,49,78,107,136,165,194,223,252,25,54,83,112,141,115,144,173,202,231,4,33,62,91,120,149,178,207,236,9,38,67,96,125,154]}, + "clearings_rule": "floor + (1 - floor) * smoothstep((d - r_in) / (r_out - r_in)), 1 past r_out", + "clearings": [[1121976320, -1035993088, 1077936128, 1089470464, 1048576000]], + "cover": {"fill": {"layer": 0, "steps": [1073741824, 1075838976, 1068708659, 1068708659], "jitter": 1065353216, "scale_lo": 1058642330, "scale_hi": 1068708659, "wind_lo": 1056964608, "wind_hi": 1065353216, "scale_var": 1045220557, "band_grow": 1041865114, "sink": 1022739087, "trample": 1, "solid": 0}, "ox": 0, "oz": 0, "size": 1107296256, "cx": 3, "cz": -2, "band": 1, "things": [ + [131072, 1120722131, -1031988814, 1063839030, 1075514781, 1061975106, 1058404922], + [327680, 1121801699, -1032364098, 1068919519, 1063792719, 1049525222, 1060683454], + [458752, 1122465832, -1031889000, 1069448012, 1086016211, 1049231304, 1060531988], + [524288, 1122652719, -1032091122, 1069044046, 1084335045, 1042065604, 1057936960], + [786432, 1124005574, -1031944401, 1066394760, 1066870402, 1064697147, 1060653764], + [1, 1120018591, -1032685244, 1067346335, 1085558634, 1054041374, 1062133124], + [65537, 1120371200, -1032954977, 1065975606, 1083518089, 1065129413, 1057612078], + [262145, 1121356718, -1032479330, 1066928675, 1024682493, 1064430405, 1058254252], + [327681, 1121606079, -1033046405, 1068851011, 1077737535, 1064480426, 1057363182], + [458753, 1122328230, -1032827722, 1070792961, 1076663355, 1056375478, 1064587742], + [65538, 1120463992, -1033613436, 1060457593, 1074573362, 1057445941, 1062637456], + [131074, 1120572733, -1033661622, 1062624664, 1082113703, 1062061190, 1057664958], + [262146, 1121314977, -1033482165, 1067453131, 1084681630, 1049680874, 1064772764], + [327682, 1121561232, -1033237059, 1067452030, 1077105671, 1053690052, 1057286626], + [458754, 1122374807, -1033751933, 1068681354, 1072698788, 1052240590, 1060365712], + [655362, 1123332418, -1033652843, 1066408536, 1054991633, 1062544760, 1059281512], + [720898, 1123715890, -1033428095, 1067696300, 1076179498, 1043138132, 1064618752], + [3, 1120166220, -1033968424, 1062267741, 1085755275, 1058969154, 1060467825], + [131075, 1120811203, -1033876423, 1062497849, 1079576187, 1052427156, 1058219821], + [196611, 1120960981, -1033939129, 1067260015, 1061916453, 1050262064, 1062985215], + [327683, 1121744884, -1034111018, 1068186170, 1086195927, 1063684314, 1064611040], + [393219, 1121944133, -1034121688, 1067081453, 1084125171, 1046733352, 1061951717], + [458755, 1122336852, -1033860481, 1066352794, 1055127513, 1061107994, 1063479044], + [655363, 1123201280, -1034160112, 1065573331, 1086839112, 1061752269, 1064321610], + [786435, 1123827217, -1034205505, 1068871523, 1079170585, 1056558946, 1061642159], + [4, 1120099678, -1034750794, 1061339931, 1080569423, 1057505192, 1057146090], + [131076, 1120789658, -1034810383, 1063347522, 1086251169, 1034781696, 1064206448], + [262148, 1121453169, -1034634739, 1068136211, 1076486477, 1055007100, 1060210905], + [589828, 1123009927, -1034824436, 1062703839, 1085088285, 1064575722, 1057569190], + [720900, 1123581713, -1034680000, 1066931396, 1074502627, 1050478582, 1058532338], + [131077, 1120685847, -1035628450, 1067060154, 1066287690, 1060023183, 1058700876], + [262149, 1121200649, -1035086950, 1067262377, 1074667727, 1061676180, 1060092720], + [524293, 1122785508, -1035296987, 1066758411, 1068971106, 1063885378, 1061440870], + [589829, 1122835146, -1035370184, 1064284565, 1085271522, 1058223749, 1062305456], + [6, 1120071083, -1035796082, 1064759508, 1051495341, 1063759425, 1063330882], + [65542, 1120429090, -1035765120, 1062635826, 1072326525, 1052336760, 1061079065], + [196614, 1121004046, -1036017291, 1068151216, 1083200182, 1046648512, 1065104064], + [327686, 1121734054, -1035736794, 1068830219, 1083091576, 1060148856, 1058380053], + [589830, 1123049340, -1035750522, 1068315059, 1078742942, 1053434130, 1057814420], + [655366, 1123252776, -1036107092, 1069084182, 1049407803, 1059915340, 1059287390], + [720902, 1123688935, -1036027332, 1067353178, 1075493805, 1052157150, 1057779508], + [7, 1119972395, -1036776087, 1065928939, 1084187056, 1058669209, 1058172888], + [131079, 1120695165, -1036807948, 1065803870, 1085651203, 1064428109, 1063126413], + [262151, 1121305432, -1036505111, 1069874039, 1054543943, 1063591813, 1063478878], + [589831, 1122949063, -1036739210, 1066469305, 1077561851, 1046913520, 1057635168], + [655367, 1123267161, -1036567612, 1065991224, 1075220436, 1058549287, 1059914820], + [720903, 1123799626, -1036435331, 1069770113, 1072588927, 1053806348, 1057430074], + [8, 1120102246, -1037422342, 1064335519, 1066268080, 1031625520, 1060565898], + [196616, 1121145408, -1037253740, 1066916402, 1060346876, 1057616950, 1061816063], + [262152, 1121486820, -1037400879, 1063269927, 1083056872, 1040859600, 1063478826], + [655368, 1123239728, -1037186555, 1066870852, 1084093218, 1027108672, 1063293641], + [720904, 1123691123, -1037347934, 1069066122, 1085326914, 1017624512, 1059328364], + [9, 1120054424, -1038292433, 1067066116, 1070302768, 1052711020, 1062115508], + [131081, 1120732507, -1037834558, 1067627931, 1013278155, 1061370947, 1059034438], + [262153, 1121369458, -1038212635, 1066866956, 1014830353, 1060618345, 1063656644], + [327689, 1121588751, -1038330345, 1063079095, 1078405272, 1064082613, 1063230570], + [720905, 1123722622, -1037841175, 1068806914, 1084268125, 1060276795, 1063221992], + [65546, 1120304158, -1038861964, 1066184590, 1076017353, 1040823344, 1060286255], + [196618, 1120885739, -1038486810, 1070801036, 1086041023, 1057627422, 1062033764], + [393226, 1121977389, -1038821564, 1066120053, 1061555987, 1049470312, 1061120434], + [524298, 1122776602, -1038830134, 1066173247, 1080610903, 1056751238, 1063824694], + [589834, 1123096416, -1038584012, 1070866520, 1085814179, 1062620434, 1058791728], + [65547, 1120429794, -1039083608, 1068590716, 1072090856, 1061374698, 1059210362], + [131083, 1120553328, -1039112057, 1066175623, 1072596269, 1054427332, 1062420510], + [458763, 1122234379, -1039228072, 1066196971, 1078080975, 1058563850, 1065351816], + [655371, 1123225640, -1039075734, 1071814252, 1065450289, 1041046272, 1058612354], + [786443, 1124045186, -1039406495, 1065956757, 1086915761, 1047327912, 1059610038], + [12, 1120172331, -1040047278, 1069387899, 1079288505, 1024762976, 1060390814], + [65548, 1120247151, -1039775524, 1068907881, 1084308911, 1065073267, 1060439949], + [131084, 1120615769, -1039878030, 1068947240, 1083502790, 1056931844, 1057488786], + [196620, 1121118525, -1040010303, 1061322280, 1077929596, 1052826214, 1064042924], + [589836, 1123092738, -1039727871, 1068558543, 1084332302, 1049315840, 1057920874], + [655372, 1123240528, -1039705474, 1068208532, 1086445325, 1062070265, 1061650604] + ]}}, + "solid": {"fill": {"layer": 0, "steps": [1082130432, 1084227584, 1090519040, 1094713344], "jitter": 1061997773, "scale_lo": 1061997773, "scale_hi": 1070386381, "wind_lo": 1056964608, "wind_hi": 1065353216, "scale_var": 1045220557, "band_grow": 1050253722, "sink": 1022739087, "trample": 0, "solid": 1}, "ox": 0, "oz": 0, "size": 1107296256, "cx": 3, "cz": -2, "far_band": 2, "things": [ + [16582144, 0, 1120293028, -1032252411, 1068709886, 1085262221, 1065248692, 1058250104, 0], + [16582176, 262144, 1122044225, -1032619009, 1070248319, 1073719313, 1064173498, 1063466348, 1], + [16582200, 458752, 1123660781, -1032485573, 1066304277, 1069870346, 1064911989, 1057976674, 0], + [16582161, 131073, 1121004655, -1033560481, 1065826519, 1069762310, 1048734550, 1062419942, 0], + [16582185, 327681, 1122891219, -1033334636, 1064534541, 1049917068, 1060399914, 1057515134, 0], + [16582193, 393217, 1123227094, -1033072107, 1063157924, 1084632888, 1049823870, 1061233682, 1], + [16582146, 2, 1120098383, -1034220530, 1064891188, 1071495521, 1049736648, 1058135006, 0], + [16582162, 131074, 1121330806, -1034400123, 1066240901, 1081202597, 1064297738, 1061589532, 1], + [16582170, 196610, 1121648030, -1034385209, 1067471220, 1067006313, 1063288699, 1064312224, 0], + [16582186, 327682, 1122859183, -1034545454, 1063397635, 1080375926, 1052510306, 1057817892, 0], + [16582194, 393218, 1123258040, -1034666854, 1065666160, 1076070243, 1059294668, 1063564212, 0], + [16582202, 458754, 1123898810, -1034464750, 1070302032, 1083310160, 1046855468, 1064929602, 0], + [16582179, 262147, 1122302705, -1035424756, 1065501747, 1082580068, 1048318644, 1063732166, 1], + [16582187, 327683, 1122641852, -1035108121, 1066046128, 1085315314, 1033693656, 1063163313, 0], + [16582195, 393219, 1123474480, -1035172904, 1068708433, 1085923941, 1013630976, 1058432680, 0], + [16582203, 458755, 1123901206, -1035453673, 1069480276, 1068449825, 1065184737, 1061276845, 0], + [16582148, 4, 1120270582, -1036144562, 1066589869, 1063486081, 1058251797, 1060618520, 1], + [16582172, 196612, 1121632793, -1036239652, 1068097589, 1069693465, 1064717160, 1057397812, 1], + [16582188, 327684, 1122789459, -1036866786, 1064673176, 1086879325, 1065325129, 1064975418, 0], + [16582196, 393220, 1123285593, -1036479532, 1067976026, 1082656672, 1014975296, 1059244680, 0], + [16582157, 65541, 1120532302, -1037364348, 1068574045, 1083796886, 1062530079, 1057480249, 0], + [16582165, 131077, 1121217218, -1037958810, 1071536019, 1083057254, 1029748784, 1059703596, 0], + [16582173, 196613, 1121828561, -1037307962, 1064590064, 1079332061, 1056723512, 1058688736, 1], + [16582166, 131078, 1121177097, -1038543456, 1070246213, 1052418781, 1058206170, 1064924534, 0], + [16582182, 262150, 1122224782, -1038960375, 1066984436, 1078036756, 1052132012, 1059085758, 0], + [16582206, 458758, 1123771428, -1038852112, 1063183247, 1081742483, 1044344244, 1058875344, 1], + [16582167, 131079, 1121082601, -1039914496, 1064736625, 1086794242, 1054124508, 1059956726, 0], + [16582175, 196615, 1121761085, -1039599090, 1067374368, 1067261885, 1058627350, 1062771573, 0], + [16582191, 327687, 1122751322, -1039341503, 1069678485, 1068499230, 1053671156, 1059624745, 0], + [16582199, 393223, 1123213029, -1039687681, 1071554254, 1053024656, 1029041920, 1063208826, 1] + ]}} +}