From e53f3197ac66cfb3c85b9be14c62982a45c48a00 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Sun, 27 Sep 2026 00:08:36 +0300 Subject: [PATCH] refactor(render3d): phase 16 - the collider store is gone; still things are bodies in ludic.physics collide.ludic (circles in a cell grid that could not be removed) and its Render3dState fields are deleted, and the reload example stops asserting colliders. A game still calling col_* must move to ludic.physics first (Maroon Lake's src/solid). Co-Authored-By: Claude Opus 5.5 --- examples/rendering/reload.ludic | 12 +- packages/ludic.render3d/FEATURES.md | 2 +- packages/ludic.render3d/collide.ludic | 158 -------------------------- packages/ludic.render3d/env.ludic | 11 -- packages/ludic.render3d/r3d.ludic | 1 - 5 files changed, 5 insertions(+), 179 deletions(-) delete mode 100644 packages/ludic.render3d/collide.ludic diff --git a/examples/rendering/reload.ludic b/examples/rendering/reload.ludic index b22eda08..1fed1fb8 100644 --- a/examples/rendering/reload.ludic +++ b/examples/rendering/reload.ludic @@ -32,9 +32,7 @@ program Reload { for i in 0 .. 400 { let x = fi(i * 2 - 400) layer_add(l, x, terrain_height(render3d_st, x, fi(30)), fi(30), F_ONE, F_ZERO, F_ZERO, F_ZERO) - col_add(render3d_st, x, fi(30), F_ONE) } - col_build(render3d_st) } handler Boot(render3d_st: mut Render3dState) phase Start { @@ -52,15 +50,14 @@ program Reload { water_init(render3d_st, fl(4.0), F_ZERO, F_ZERO, fi(200), fi(200)) populate(render3d_st) var ok = true - ok = check("the first map has layers and colliders", len(render3d_st.sc_layers) == 1 and render3d_st.col_n == 400) and ok + ok = check("the first map has layers", len(render3d_st.sc_layers) == 1) and ok # --- swap to a 3 km map with a sea and a lake above it scatter_clear_all(render3d_st) actor_clear_all(render3d_st) - col_reset(render3d_st) water_bodies_clear(render3d_st) terrain_lake(render3d_st, F_ZERO, F_ZERO, F_ZERO, F_ZERO, F_ZERO) terrain_reload(render3d_st, "", F_ZERO, F_ZERO, F_ZERO, F_ZERO, F_ZERO, "", 1500) - ok = check("everything of the first map released", len(render3d_st.sc_layers) == 0 and render3d_st.col_n == 0 and render3d_st.wb_n == 0 and not render3d_st.water_on) and ok + ok = check("everything of the first map released", len(render3d_st.sc_layers) == 0 and render3d_st.wb_n == 0 and not render3d_st.water_on) and ok ok = check("the new half", render3d_st.TERRAIN_HALF == 1500 and render3d_st.ter_heights != null and render3d_st.ter_height_tex != 0) and ok ok = check("patch bounds rebuilt", render3d_st.cd_min != null and len(render3d_st.cd_min) == CD_LEVELS) and ok ok = check("patches scale with the map", cd_size(render3d_st, 0) == f_mul(fi(32), fr(3000, 8192))) and ok @@ -70,18 +67,17 @@ program Reload { let lake = water_body_add(render3d_st, fl(12.0), fi(600), fi(-400), fi(150), fi(100), false) ok = check("two water bodies, the sea mirrored", sea == 0 and lake == 1 and render3d_st.wb_n == 2 and render3d_st.wb_primary == 0 and render3d_st.water_level == F_ZERO and render3d_st.water_on) and ok populate(render3d_st) - ok = check("the new map takes layers and colliders", len(render3d_st.sc_layers) == 1 and render3d_st.col_n == 400 and render3d_st.col_side == 3000 / COL_CELL) and ok + ok = check("the new map takes layers", len(render3d_st.sc_layers) == 1) and ok # a sea below the carved lake: set once, and not undone by a reload terrain_sea(render3d_st, fl(-2.0)) terrain_lake(render3d_st, fl(12.0), fi(600), fi(-400), fi(150), fi(100)) ok = check("the sea is its own level", render3d_st.ter_sea_set and ter_sea_gen(render3d_st) == fl(-2.0) and render3d_st.ter_lake_level == fl(12.0)) and ok # --- and back: the swap is repeatable scatter_clear_all(render3d_st) - col_reset(render3d_st) water_init(render3d_st, fl(4.0), F_ZERO, F_ZERO, fi(200), fi(200)) terrain_reload(render3d_st, "", F_ZERO, F_ZERO, F_ZERO, F_ZERO, F_ZERO, "", 1000) populate(render3d_st) - ok = check("back to 2 km", render3d_st.TERRAIN_HALF == 1000 and len(render3d_st.cd_min) == CD_LEVELS and render3d_st.wb_n == 1 and render3d_st.col_side == 2000 / COL_CELL) and ok + ok = check("back to 2 km", render3d_st.TERRAIN_HALF == 1000 and len(render3d_st.cd_min) == CD_LEVELS and render3d_st.wb_n == 1) and ok if ok { print("RELOAD OK") } else { print("RELOAD FAILED") } quit() } diff --git a/packages/ludic.render3d/FEATURES.md b/packages/ludic.render3d/FEATURES.md index 9f735eef..b0eaeddc 100644 --- a/packages/ludic.render3d/FEATURES.md +++ b/packages/ludic.render3d/FEATURES.md @@ -87,7 +87,7 @@ Characters and moving objects 58. A 2D overlay over the finished frame: batched rectangles, images and proportional text from a baked font atlas (`tools/blender/font_build.py`) — `overlay.ludic`, `shaders/overlay.*` 59. Time of day over the HDRI: a solar arc drives the sun's direction and colour, the sky's light scales toward a deep-blue starry night, a moon lights the night, the visible sky turns with the sun and its convolutions rebake when far off, the height-field shadow rebakes as the sun moves, auto-exposure is capped at night — `daylight.ludic`, `shaders/sky.frag`, `shaders/lighting.glsl` 60. One point light (the campfire) in the shading — `shaders/lighting.glsl` `fireLight` -61. Static collision: ground circles in a cell grid with push-out and a segment test — `collide.ludic` +61. ~~Static collision: ground circles in a cell grid~~ - removed; still things are bodies in `ludic.physics` (Jolt) 62. Cut-out and emissive actors (a flame's cards) — `actor.ludic`, `shaders/model.frag` `u_emissive` 63. Per-instance skin clones (a herd posed individually), per-part tints and hidden parts on actors, uniform-location caching with frustum and distance culling — `skin.ludic` `skin_clone`, `actor.ludic` 64. A second point / cone light (a torch or flashlight in the hand) with a reach in metres and a gentle falloff to it — `daylight.ludic` `daylight_hand`, `shaders/lighting.glsl` `handLight` diff --git a/packages/ludic.render3d/collide.ludic b/packages/ludic.render3d/collide.ludic deleted file mode 100644 index da28144e..00000000 --- a/packages/ludic.render3d/collide.ludic +++ /dev/null @@ -1,158 +0,0 @@ -# ============================================================================ -# collide.ludic — the static colliders of the world as circles on the ground -# plane (a trunk, a boulder, a tent), sorted once into 16 m cells over the whole -# terrain. A moving thing asks col_resolve for its position pushed out of every -# circle it overlaps: three by three cells, a few dozen tests, no broad phase -# needed. Float bits, metres. -# ============================================================================ - -const COL_CELL: int = 16 -const COL_CAP: int = 120000 - -# What a collider occupies VERTICALLY: y0 its base, y1 its top, metres, float bits. A circle used -# to be an infinite pillar - you could not climb a boulder, and a knee-high rock stopped you dead, -# because there was no height to compare against. col_add keeps that shape (a span from far below -# to far above) so every existing caller behaves exactly as it did; col_add_h gives a real one. - -const COL_LOW: float = -16777216.0 # -16777216.0: below any ground -const COL_HIGH: float = 16777216.0 # 16777216.0: above any sky -function col_add(render3d_st: mut Render3dState, x: float, z: float, r: float) -> void { col_add_h(render3d_st, x, z, r, COL_LOW, COL_HIGH) } -# a collider that occupies only y0 .. y1: a body above its top walks over it, a body below its base -# passes under, and col_top_at reports it as something to stand on -function col_add_h(render3d_st: mut Render3dState, x: float, z: float, r: float, y0: float, y1: float) -> void { - if render3d_st.col_x == null { - render3d_st.col_x = floats(COL_CAP); render3d_st.col_z = floats(COL_CAP); render3d_st.col_r = floats(COL_CAP) - render3d_st.col_y0 = floats(COL_CAP); render3d_st.col_y1 = floats(COL_CAP); render3d_st.col_out = floats(2) - } - if render3d_st.col_n >= COL_CAP { return } - render3d_st.col_x[render3d_st.col_n] = x; render3d_st.col_z[render3d_st.col_n] = z; render3d_st.col_r[render3d_st.col_n] = r - render3d_st.col_y0[render3d_st.col_n] = y0; render3d_st.col_y1[render3d_st.col_n] = y1 - render3d_st.col_n += 1 - render3d_st.col_built = false -} -function col_cell_of(render3d_st: Render3dState, v: float, origin: float) -> int { - var c = int(Math.floor((v - origin + float(render3d_st.TERRAIN_HALF)) / float(COL_CELL))) - if c < 0 { c = 0 } - if c > render3d_st.col_side - 1 { c = render3d_st.col_side - 1 } - return c -} -function col_build(render3d_st: mut Render3dState) -> void { - render3d_st.col_side = (render3d_st.TERRAIN_HALF * 2) / COL_CELL - let ncell = render3d_st.col_side * render3d_st.col_side - if render3d_st.col_start == null { render3d_st.col_start = words(ncell + 1); render3d_st.col_sorted = words(COL_CAP) } - for i in 0 .. ncell + 1 { render3d_st.col_start[i] = 0 } - for i in 0 .. render3d_st.col_n { render3d_st.col_start[col_cell_of(render3d_st, render3d_st.col_z[i], render3d_st.ter_oz) * render3d_st.col_side + col_cell_of(render3d_st, render3d_st.col_x[i], render3d_st.ter_ox) + 1] += 1 } - for c in 0 .. ncell { render3d_st.col_start[c + 1] += render3d_st.col_start[c] } - let fill = words(ncell) - for c in 0 .. ncell { fill[c] = render3d_st.col_start[c] } - for i in 0 .. render3d_st.col_n { - let c = col_cell_of(render3d_st, render3d_st.col_z[i], render3d_st.ter_oz) * render3d_st.col_side + col_cell_of(render3d_st, render3d_st.col_x[i], render3d_st.ter_ox) - render3d_st.col_sorted[fill[c]] = i - fill[c] += 1 - } - free(fill) - render3d_st.col_built = true - print(`colliders: {render3d_st.col_n}`) -} - -# push (px, pz) with radius pr out of every circle it overlaps; the result is in col_out -function col_resolve(render3d_st: mut Render3dState, px: float, pz: float, pr: float) -> bool { return col_resolve_at(render3d_st, px, pz, pr, COL_LOW, COL_HIGH) } -# the same, for a body that occupies feet .. head: a collider whose span misses that is not in the -# way at all. This is what lets a hiker stand on top of a boulder rather than inside it. -function col_resolve_at(render3d_st: mut Render3dState, px: float, pz: float, pr: float, feet: float, head: float) -> bool { - render3d_st.col_out[0] = px; render3d_st.col_out[1] = pz - if not render3d_st.col_built or render3d_st.col_n == 0 { return false } - var x = px; var z = pz - var moved = false - let cx = col_cell_of(render3d_st, px, render3d_st.ter_ox); let cz = col_cell_of(render3d_st, pz, render3d_st.ter_oz) - for pass in 0 .. 2 { - for dz in 0 .. 3 { - let zc = cz + dz - 1 - if zc < 0 or zc >= render3d_st.col_side { continue } - for dx in 0 .. 3 { - let xc = cx + dx - 1 - if xc < 0 or xc >= render3d_st.col_side { continue } - let c = zc * render3d_st.col_side + xc - for k in render3d_st.col_start[c] .. render3d_st.col_start[c + 1] { - let i = render3d_st.col_sorted[k] - let ex = x - render3d_st.col_x[i]; let ez = z - render3d_st.col_z[i] - let d2 = ex * ex + ez * ez - let rr = render3d_st.col_r[i] + pr - # nothing to push out of if the body is wholly above its top or below its base - if not (feet < render3d_st.col_y1[i]) { continue } - if not (head > render3d_st.col_y0[i]) { continue } - if d2 < rr * rr { - let d = Math.sqrt(d2) - # The UNIT normal out of this circle. (ex, ez) / d is always unit for d > 0, - # because d is its own length - there is nothing to clamp and nothing that can - # grow. Exactly at the centre there is no direction to be had, so any one will - # do and +x is as good as another. - # - # This used to clamp d to 0.001 and then divide by it, having ALREADY set the - # degenerate normal to (1, 0): a unit vector divided by a thousandth, so the - # push came out a thousand times too big. A body standing dead centre on a - # 0.5 m trunk was thrown roughly 800 m across the map instead of 0.85 m clear - # of it. Off-centre - which is how anything actually arrives at a trunk - the - # arithmetic was right, so it never showed up in play. - var ux = 1.0; var uz = 0.0 - if d > 0.0 { ux = ex / d; uz = ez / d } - let push = rr - d - x = x + ux * push - z = z + uz * push - moved = true - } - } - } - } - } - render3d_st.col_out[0] = x; render3d_st.col_out[1] = z - return moved -} -# The highest collider top under (px, pz) that a body at `feet` could be standing on or step up to: -# tops above `reach` are a wall, not a step. F_ZERO-safe: returns `floor` when there is nothing, so -# a caller can pass the terrain height and use the answer directly as the ground. -function col_top_at(render3d_st: Render3dState, px: float, pz: float, pr: float, feet: float, reach: float, floor: float) -> float { - var top = floor - if not render3d_st.col_built or render3d_st.col_n == 0 { return top } - let cx = col_cell_of(render3d_st, px, render3d_st.ter_ox); let cz = col_cell_of(render3d_st, pz, render3d_st.ter_oz) - let limit = feet + reach - for dz in 0 .. 3 { - let zc = cz + dz - 1 - if zc < 0 or zc >= render3d_st.col_side { continue } - for dx in 0 .. 3 { - let xc = cx + dx - 1 - if xc < 0 or xc >= render3d_st.col_side { continue } - let c = zc * render3d_st.col_side + xc - for k in render3d_st.col_start[c] .. render3d_st.col_start[c + 1] { - let i = render3d_st.col_sorted[k] - let ex = px - render3d_st.col_x[i]; let ez = pz - render3d_st.col_z[i] - let d2 = ex * ex + ez * ez - let rr = render3d_st.col_r[i] + pr - if not (d2 < rr * rr) { continue } - let t = render3d_st.col_y1[i] - if t > limit { continue } # too tall to step onto: it is a wall - if t > top { top = t } - } - } - } - return top -} -# is the segment from (x0,z0) to (x1,z1) clear of every circle (a camera line of sight)? -function col_clear(render3d_st: mut Render3dState, x0: float, z0: float, x1: float, z1: float, r: float) -> bool { - let steps = 6 - for s in 0 .. steps + 1 { - let t = float(s) / float(steps) - let x = Math.lerp(x0, x1, t); let z = Math.lerp(z0, z1, t) - if col_resolve(render3d_st, x, z, r) { return false } - } - return true -} - -# No colliders, ready for another map's. The cell index is sized from TERRAIN_HALF on its -# first build and kept, so it is released too: a larger map would overrun the old one. -function col_reset(render3d_st: mut Render3dState) -> void { - render3d_st.col_n = 0 - render3d_st.col_built = false - if render3d_st.col_start != null { free(render3d_st.col_start); render3d_st.col_start = null } - if render3d_st.col_sorted != null { free(render3d_st.col_sorted); render3d_st.col_sorted = null } -} diff --git a/packages/ludic.render3d/env.ludic b/packages/ludic.render3d/env.ludic index 047de807..980c6e30 100644 --- a/packages/ludic.render3d/env.ludic +++ b/packages/ludic.render3d/env.ludic @@ -43,17 +43,6 @@ export state Render3dState { cam_fwd: floats = null cam_right: floats = null cam_planes: floats = null - col_x: floats = null - col_z: floats = null - col_r: floats = null - col_y0: floats = null - col_y1: floats = null - col_n: int = 0 - col_side: int = 0 # cells per side - col_start: words = null # per cell: first index into col_sorted (side*side + 1) - col_sorted: words = null - col_built: bool = false - col_out: floats = null # the resolved position (x, z) day_on: bool = false day_hours: float = 0.0 # float bits, 0 .. 24 day_light: float = 0.0 # 0 night .. 1 full day (float bits) diff --git a/packages/ludic.render3d/r3d.ludic b/packages/ludic.render3d/r3d.ludic index 7574e4e2..135ce808 100644 --- a/packages/ludic.render3d/r3d.ludic +++ b/packages/ludic.render3d/r3d.ludic @@ -21,7 +21,6 @@ import "camera.ludic" import "sky.ludic" import "daylight.ludic" import "terrain.ludic" -import "collide.ludic" import "overlay.ludic" import "shadow.ludic" import "post.ludic"