ludic/packages/ludic.render3d/stream.ludic
Orkuncakilkaya f993c4a36a r3d/runtime: allocs, keeps and births at 0 on this side
render3d: shadow_fit, water_reflection_pass, layer_partition_lods and the
GPU cull's scratch are made with the state; v3_dist is scalar; the pushes
into lists sized at start-up, the caps probe, the table growth, the loads
and the constructors declared with their bounds (one statement a line);
the renderer's name made once with the device; the two error messages
given back; the dead lupine models removed.

runtime: a component's text is held interned in its value cell (one copy
per distinct text), so the getter's own text goes with its frame instead
of being kept by ludic.ui's model - 80 of the 83 keeps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:10:59 +03:00

363 lines
18 KiB
Text

# ============================================================================
# stream.ludic — ground cover that follows the camera anywhere on the map.
#
# The world is cut into square chunks. A chunk's instances are generated once
# per distance band (a deterministic function of the chunk and the band, so the
# same ground always grows the same grass) and cached; each frame the chunks
# within reach are gathered into the layer's instance list. Bands thin the cover
# with distance and grow the cards so the carpet stays continuous on screen;
# beyond the last band nothing is placed (the terrain material carries it).
#
# The scene supplies the generator: stream_fill(chunk_x, chunk_z, band) calls
# stream_emit(...) per instance. One Stream drives one Layer.
# ============================================================================
property Chunk {
key: int = 0, # packed (cx, cz, band)
used: int = 0, # the walk that last wanted it (for eviction)
off: int = -1, # where its instances start in the stream's arena (words); -1 not kept
count: int = 0,
ymin: float = 0.0, # height range of its instances (float bits), for the frustum test
ymax: float = 0.0
}
property Stream {
layer: Layer,
size: float = 0.0, # chunk size (metres, float bits)
reach: float = 0.0, # radius (metres, float bits)
bands: floats, # band outer radii (float bits), ascending; 4 of them
chunks: []Chunk,
keys: words, # parallel to chunks for lookup
n: int = 0,
last_cx: int = 999999,
last_cz: int = 999999,
pending: bool = false, # chunks still to generate after the camera crossed a cell
cur: Chunk, # the chunk being filled
kind: int = 0, # the scene's generator selector for this stream
min_band: int = 0, # bands below this belong to another (nearer) stream
view_gen: int = -1, # sc_view_gen the layer was last gathered for (the view turned -> regather)
htab: words, # open-addressed key -> chunk index + 1 (0 = empty)
arena: words, # every kept chunk's instances, in the order of `chunks`
top: int = 0, # words of the arena in use
spare: []Chunk # records not in use: all of them are made with the stream
}
# microseconds spent per frame, split so the hitch can be attributed (R3D_PROF=1)
@alloc_ok("a world being set up (a stream, a layer, water, post, a bake): the map loading or swapping, not a frame")
function stream_new(render3d_st: mut Render3dState, layer: Layer, size: float, reach: float, b0: float, b1: float, b2: float, b3: float) -> Stream {
if not render3d_st.stream_cap_read {
render3d_st.stream_cap_read = true
if r3d_env_has(render3d_st, "R3D_STREAM_CAP") { render3d_st.STREAM_MAX_CHUNKS = Text.to_int(r3d_env(render3d_st, "R3D_STREAM_CAP")) }
render3d_st.stream_no_evict = r3d_env_has(render3d_st, "R3D_NOEVICT")
}
# the fill's scratch and the record for a chunk the cache cannot keep, both once for every stream
if render3d_st.stream_scratch == null { render3d_st.stream_scratch = floats(STREAM_CHUNK_MAX * INST_FLOATS) }
if render3d_st.stream_loose == null { render3d_st.stream_loose = new Chunk }
let s = new Stream
s.layer = layer; s.size = size; s.reach = reach
layer.streamed = true
layer.grounded = true
s.bands = floats(4)
s.bands[0] = b0; s.bands[1] = b1; s.bands[2] = b2; s.bands[3] = b3
# Everything the cache will ever hold is made here: a record per chunk it can keep and an arena
# of twice what the layer draws. Each chunk once had its own record and copy, made as the camera
# found new ground, so the heap grew for as long as there was ground nobody had stood near.
s.chunks = new []Chunk
s.spare = new []Chunk
let live = s.chunks
for i in 0 .. render3d_st.STREAM_MAX_CHUNKS { push(live, new Chunk) }
while len(live) > 0 { push(s.spare, List.pop(live)) }
s.arena = words(Math.max(layer.cap, 4096) * 2 * INST_FLOATS)
s.keys = words(render3d_st.STREAM_MAX_CHUNKS)
s.htab = words(STREAM_HASH)
for i in 0 .. STREAM_HASH { s.htab[i] = 0 }
if render3d_st.stream_all == null { render3d_st.stream_all = new []Stream }
push(render3d_st.stream_all, s)
return s
}
function stream_key(cx: int, cz: int, band: int) -> int { return ((cx + 4096) * 8192 + (cz + 4096)) * 4 + band }
# Chunk lookup is an open-addressed hash, not a scan. A cell crossing tests every cell
# within reach — for the 800 m streams that is ~2000 cells each — and a scan over the
# cached chunks made that O(cells x chunks), tens of millions of comparisons in the one
# frame that crosses a 32 m boundary, growing as more ground is explored. That is the
# stutter you feel when walking, and it never shows in a stationary profile because
# stream_update returns immediately while the camera stays in its cell.
const STREAM_HASH: int = 8192 # power of two, >= 2 * STREAM_MAX_CHUNKS
function stream_slot(key: int) -> int {
var h = key * -1640531527 # Knuth's golden-ratio multiplier, as a signed i32
h = h ^ (h >> 15)
return h & (STREAM_HASH - 1)
}
function stream_find(s: Stream, key: int) -> Chunk {
var i = stream_slot(key)
while s.htab[i] != 0 {
let idx = s.htab[i] - 1
if s.keys[idx] == key { return s.chunks[idx] }
i = (i + 1) & (STREAM_HASH - 1)
}
return null
}
function stream_remember(s: Stream, key: int, idx: int) -> void {
var i = stream_slot(key)
while s.htab[i] != 0 { i = (i + 1) & (STREAM_HASH - 1) }
s.htab[i] = idx + 1
}
# the generator adds instances to the chunk being filled (into a shared scratch; the
# chunk gets an exactly-sized copy when the fill ends)
const STREAM_CHUNK_MAX: int = 262144
function stream_emit(render3d_st: mut Render3dState, s: Stream, x: float, y: float, z: float, scale: float, yaw: float, seed: float, wind: float) -> void {
let c = s.cur
if c.count >= STREAM_CHUNK_MAX { return }
if c.count == 0 { c.ymin = y; c.ymax = y } else { c.ymin = Math.min(c.ymin, y); c.ymax = Math.max(c.ymax, y) }
let o = c.count * INST_FLOATS
render3d_st.stream_scratch[o] = x; render3d_st.stream_scratch[o + 1] = y; render3d_st.stream_scratch[o + 2] = z; render3d_st.stream_scratch[o + 3] = scale
render3d_st.stream_scratch[o + 4] = Math.sin(yaw); render3d_st.stream_scratch[o + 5] = Math.cos(yaw); render3d_st.stream_scratch[o + 6] = seed; render3d_st.stream_scratch[o + 7] = wind
c.count += 1
}
function stream_band(s: Stream, d: float) -> int {
if d < s.bands[0] { return 0 }
if d < s.bands[1] { return 1 }
if d < s.bands[2] { return 2 }
if d < s.bands[3] { return 3 }
return 4
}
# gather the chunks around the camera into the layer, generating missing ones nearest
# first within a per-frame budget so a cell crossing spreads over a few frames instead of
# one hitch (the very first update, before anything is on screen, generates everything)
# The budget is global, not per stream. It used to be 60000 per stream, and with the
# thirteen streams a scene like the valley runs that let a single frame generate over
# 700k instances — so crossing a 32 m cell put one frame's worth of cover generation
# (height samples, ortho lookups, slope and path tests, per candidate) into one frame
# while its neighbours did none. That one frame is the stutter you feel while walking;
# spreading the same work over several frames costs nothing but a little pop-in at the
# far edge of the reach, where new chunks appear.
# A time budget, not an instance count. Instances are a poor proxy: a candidate that
# is rejected costs nearly as much as one that is kept, and cost per instance varies
# by band and kind. With a real microsecond clock the budget can just be the thing we
# actually care about — how long this frame is allowed to spend growing ground cover.
# Overshoot is bounded by one chunk, so keep chunks small on the dense near streams.
const STREAM_BUDGET: int = 8000 # kept for the work counter only
# The worst frame is now bounded by one chunk, not by the budget: stream_fill emits a
# whole chunk in one call, and the densest band-0 chunk is ~114k instances. Splitting a
# chunk's generation across frames would need a resumable generator contract; that is
# the next step if the residual hitch ever matters.
# Drop the half of the cache nobody has asked for in the longest time, and rebuild the
# index over what is left.
#
# Before this, a full cache simply stopped remembering: the chunk was generated, used for
# that frame and thrown away, so every walk regenerated it. That is not a slow degradation
# — it is a cliff. Past it every frame pays the whole generation budget and the ground
# visibly re-grows as you turn, and it arrives after enough of the map has been walked,
# which is exactly when a player is least likely to connect it to anything.
function stream_evict(render3d_st: mut Render3dState, s: Stream) -> void {
# the age threshold that keeps about half, found by bisection on the count (no sort)
var lo = 0
var hi = render3d_st.stream_walk_no
var keep = s.n / 2
var t = 0
var it = 0
while it < 24 and lo < hi {
t = (lo + hi + 1) / 2
var c = 0
var i = 0
while i < s.n { if s.chunks[i].used >= t { c += 1 }; i += 1 }
if c >= keep { lo = t } else { hi = t - 1 }
it += 1
}
t = lo
# everything wanted by the walk in progress stays whatever the threshold says
# compacted in place, and an evicted chunk goes whole: a new list per eviction and the chunks'
# own records were never given back
# the arena is compacted in the same pass: a kept chunk's instances only ever move down
var w = 0
var top = 0
var i = 0
while i < s.n {
let c = s.chunks[i]
if c.used >= t or c.used == render3d_st.stream_walk_no {
let n = c.count * INST_FLOATS
if c.off != top { for k in 0 .. n { s.arena[top + k] = s.arena[c.off + k] } }
c.off = top
top += n
s.chunks[w] = c
w += 1
} else {
c.off = -1
@alloc_ok("within the stream's record pool, all made with the stream (STREAM_MAX_CHUNKS)")
push(s.spare, c)
}
i += 1
}
s.top = top
let ch = s.chunks
while len(ch) > w { List.pop(ch) }
s.n = w
for h in 0 .. STREAM_HASH { s.htab[h] = 0 }
i = 0
while i < s.n { s.keys[i] = s.chunks[i].key; stream_remember(s, s.chunks[i].key, i); i += 1 }
render3d_st.stream_evictions += 1
}
function stream_update(render3d_st: mut Render3dState, s: Stream, cam_x: float, cam_z: float) -> void {
let ccx = int(Math.floor(cam_x / s.size))
let ccz = int(Math.floor(cam_z / s.size))
if ccx == s.last_cx and ccz == s.last_cz and not s.pending and s.view_gen == render3d_st.sc_view_gen { return }
let first = s.last_cx == 999999
s.view_gen = render3d_st.sc_view_gen
s.last_cx = ccx; s.last_cz = ccz
let l = s.layer
l.count = 0
var missing = false
render3d_st.stream_walks += 1
render3d_st.stream_walk_no += 1
let tw = gl_now_us()
let r = int(s.reach / s.size) + 1
# rings outward from the camera's cell: the nearest chunks are generated first
var ring = 0
while ring <= r {
var cz = ccz - ring
while cz <= ccz + ring {
var cx = ccx - ring
while cx <= ccx + ring {
let edge = (cz == ccz - ring) or (cz == ccz + ring) or (cx == ccx - ring) or (cx == ccx + ring)
if edge {
let wx = (float(cx) + 0.5) * s.size
let wz = (float(cz) + 0.5) * s.size
let dx = wx - cam_x; let dz = wz - cam_z
let d = Math.sqrt(dx * dx + dz * dz)
let band = stream_band(s, d)
if band < 4 and band >= s.min_band and d < s.reach + s.size {
let key = stream_key(cx, cz, band)
var c = stream_find(s, key)
if c != null { c.used = render3d_st.stream_walk_no }
# The cell underfoot and its neighbours are never deferred: they are what you
# are looking at, and a hole there is the grass vanishing as you walk into it.
let urgent = band == 0 and ring <= 1
var loose = false
if c == null and (first or urgent or gl_now_us() < render3d_st.stream_deadline) {
if len(s.spare) == 0 and not render3d_st.stream_no_evict { stream_evict(render3d_st, s) }
let sp = s.spare
if len(sp) > 0 { c = List.pop(sp) } else { c = render3d_st.stream_loose }
c.key = key; c.count = 0; c.off = -1; c.used = 0
s.cur = c
let t0 = gl_now_us()
r3d_stream_fill(render3d_st, s, cx, cz, band)
let dt = gl_now_us() - t0
render3d_st.stream_us_gen = render3d_st.stream_us_gen + dt
prof_chunk(render3d_st, s.kind, band, c.count, dt)
if render3d_st.r3d_debug and band == 0 and render3d_st.stream_debug_n < 40 { render3d_st.stream_debug_n += 1; stream_say_chunk(s.kind, band, cx, cz, c.count) }
let need = c.count * INST_FLOATS
if s.top + need > len(s.arena) and not render3d_st.stream_no_evict { stream_evict(render3d_st, s) }
# If the walk in progress wants more than the cache can hold, there is nothing to
# evict and this one is drawn from the scratch and dropped, as every chunk used to be.
if c != render3d_st.stream_loose and s.n < render3d_st.STREAM_MAX_CHUNKS and s.top + need <= len(s.arena) {
if need > 0 { mem_copy(mem_off(data_of(s.arena), s.top * 4), data_of(render3d_st.stream_scratch), need * 4) }
c.off = s.top
s.top += need
@alloc_ok("within the stream's record pool, all made with the stream (STREAM_MAX_CHUNKS)")
push(s.chunks, c); s.keys[s.n] = key; stream_remember(s, key, s.n); s.n += 1
c.used = render3d_st.stream_walk_no
} else { loose = true }
prof_gen_add(render3d_st, c.count + 512)
}
if c == null {
missing = true
# Until the finer band is generated, show the coarser one this ground had
# a moment ago (same cell, next band out): approaching grass thins for a
# few frames instead of disappearing.
var b2 = band + 1
while c == null and b2 < 4 { c = stream_find(s, stream_key(cx, cz, b2)); b2 += 1 }
if c != null { c.used = render3d_st.stream_walk_no }
}
if c != null and c.count > 0 and l.count + c.count <= l.cap and stream_chunk_visible(render3d_st, s, cx, cz, c) {
let tg = gl_now_us()
layer_room(l, l.count + c.count)
var src = data_of(render3d_st.stream_scratch)
if c.off >= 0 { src = mem_off(data_of(s.arena), c.off * 4) }
mem_copy(mem_off(l.inst, l.count * INST_FLOATS * 4), src, c.count * INST_FLOATS * 4)
l.count += c.count
render3d_st.stream_us_gather = render3d_st.stream_us_gather + (gl_now_us() - tg)
}
@alloc_ok("within the stream's record pool, all made with the stream (STREAM_MAX_CHUNKS)")
if loose and c != render3d_st.stream_loose { push(s.spare, c) }
}
}
cx += 1
}
cz += 1
}
ring += 1
}
s.pending = missing
render3d_st.stream_us_walk = render3d_st.stream_us_walk + (gl_now_us() - tw)
# force the layer to re-partition its (new) instances
l.view_gen = -1
}
# Only chunks that can be seen are gathered: a sphere around the chunk's footprint and
# height range, padded for the tallest cover and for casters just outside the frame
# whose short shadows still fall inside it.
function stream_chunk_visible(render3d_st: Render3dState, s: Stream, cx: int, cz: int, c: Chunk) -> bool {
let half = s.size * 0.5
let wx = float(cx) * s.size + half
let wz = float(cz) * s.size + half
let hy = (c.ymax - c.ymin) * 0.5
let cy = c.ymin + hy
let r = Math.sqrt(half * half * 2.0 + hy * hy) + 8.0
return cam_sphere_visible(render3d_st, wx, cy, wz, r)
}
# what the caches hold, and whether they are being churned (R3D_PROF)
@alloc_ok("profiling and statistics, only under R3D_PROF / R3D_DRAWSTATS")
function stream_census(render3d_st: Render3dState) -> void {
if render3d_st.stream_all == null { return }
print("")
print(`ground-cover chunk caches (cap {string(render3d_st.STREAM_MAX_CHUNKS)} each, {string(render3d_st.stream_evictions)} evictions over the run):`)
var inst = 0
for i in 0 .. len(render3d_st.stream_all) {
let s = render3d_st.stream_all[i]
var n = 0
for k in 0 .. s.n { n += s.chunks[k].count }
inst += n
print(` stream kind {string(s.kind)}: {string(s.n)} chunks, {string(n)} instances`)
}
print(` {string(inst)} instances held, {string(inst * INST_FLOATS * 4 / 1024)} KB`)
}
function stream_update_all(render3d_st: mut Render3dState) -> void {
if render3d_st.stream_all == null { return }
render3d_st.stream_deadline = gl_now_us() + render3d_st.STREAM_BUDGET_US
for i in 0 .. len(render3d_st.stream_all) { stream_update(render3d_st, render3d_st.stream_all[i], render3d_st.cam_pos[0], render3d_st.cam_pos[2]) }
}
# every stream and its cached chunks, for a world being replaced (scatter_clear_all)
function stream_clear_all(render3d_st: mut Render3dState) -> void {
if render3d_st.stream_all == null { return }
for i in 0 .. len(render3d_st.stream_all) {
let s = render3d_st.stream_all[i]
for c in 0 .. len(s.chunks) { free(s.chunks[c]) }
for c in 0 .. len(s.spare) { free(s.spare[c]) }
free(s.chunks); free(s.spare); free(s.arena)
if s.keys != null { free(s.keys) }
if s.htab != null { free(s.htab) }
if s.bands != null { free(s.bands) }
free(s)
}
let all = render3d_st.stream_all
List.clear(all)
}
# messages, each built in a function of its own so the path that says it holds no allocation
@alloc_ok("a message, built only when it is said: a failure, a warning or a debug switch")
function stream_say_chunk(kind: int, band: int, cx: int, cz: int, n: int) -> void { print(`stream kind {kind} band {band} chunk {cx},{cz}: {n} instances`) }