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 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-30 15:23:53 +03:00
parent 740ac2a879
commit e535879262
7 changed files with 259 additions and 19 deletions

View file

@ -860,6 +860,9 @@ export state Render3dState {
gd_zbuf: []byte = null # one tile's deflated bytes as read gd_zbuf: []byte = null # one tile's deflated bytes as read
gd_hand: int = 0 gd_hand: int = 0
gd_trample_cb: fn(float, float) -> float = null 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_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_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 gb_win: words = null # ... and the window's bytes, four texels a word, made once

View file

@ -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
}

View file

@ -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
}

View file

@ -1,7 +1,6 @@
# ground_fill.ludic — a ground layer's instances in one chunk of its stream, placed from its painted density # ground_fill.ludic — a ground layer's instances in one chunk of its stream, from its painted density and
# and nothing else: a candidate in each cell of the band's step, moved by the jitter, kept when a hash of # nothing else: each cell's candidate (ground_cand.ludic), the same on every machine and in the editor. The
# (layer, chunk, band, cell) falls under the density there (times the game's trample, where it asks). The # camera picks only the chunks and their band; for a solid layer (ground_solid.ludic) not where things stand.
# same numbers on every machine and in the editor's preview; the camera decides only which chunks.
# a ground layer's numbers, as the game's row (GroundLayers) gives them; `layer` is its index in the densities # a ground layer's numbers, as the game's row (GroundLayers) gives them; `layer` is its index in the densities
export property GroundFill { export property GroundFill {
@ -18,13 +17,14 @@ export property GroundFill {
sink: float = 0.03, sink: float = 0.03,
trample: bool = true, 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) 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 # 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 } 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 == 0 { return g.step0 }
if band == 1 { return g.step1 } if band == 1 { return g.step1 }
if band == 2 { return g.step2 } 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) # 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) var h = (a * 73856093) ^ (b * 19349663) ^ (c * 83492791) ^ (d * 2654435761) ^ (e * 1597334677) ^ (k * 3812015801)
h = h & 0xFFFFFFFF h = h & 0xFFFFFFFF
h = ((h ^ (h >> 16)) * 0x7feb352d) & 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) # 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 { 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) let step = gf_step(g, band)
if step <= 0.0 or render3d_st.gd_file == null { return } if step <= 0.0 or render3d_st.gd_file == null { return }
let x0 = s.ox + float(cx) * s.size let x0 = s.ox + float(cx) * s.size
let z0 = s.oz + float(cz) * s.size let z0 = s.oz + float(cz) * s.size
let cells = int(Math.ceil(s.size / step)) let cells = int(Math.ceil(s.size / step))
let c = render3d_st.gd_cand
for j in 0 .. cells { for j in 0 .. cells {
for i 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 if not ground_candidate(render3d_st, g, cx, cz, band, i, j, x0, z0, s.size, c) { continue }
let pz = z0 + (float(j) + 0.5 + (gf_hash(g.layer, cx, cz, band, i * 65536 + j, 1) - 0.5) * g.jitter) * step let h = terrain_height(render3d_st, c.x, c.z) - g.sink
if px < x0 or px >= x0 + s.size or pz < z0 or pz >= z0 + s.size { continue } stream_emit(render3d_st, s, c.x, h, c.z, c.scale, c.yaw, c.seed, c.wind)
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)))
} }
} }
} }

View file

@ -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)
}

View file

@ -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
}

View file

@ -60,6 +60,10 @@ import "stream_baked.ludic"
import "ground_density_write.ludic" import "ground_density_write.ludic"
import "ground_density.ludic" import "ground_density.ludic"
import "ground_fill.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 "fog_streams.ludic"
import "grass_kind.ludic" import "grass_kind.ludic"
import "grass_bind.ludic" import "grass_bind.ludic"