ludic/packages/ludic.render3d/stream.ludic
Orkuncakilkaya 2ce2d997be render3d: the fog wall culls on the CPU - nothing past it costs a draw, a vertex or a caster
cam_sphere_visible refuses a sphere wholly past the wall (terrain patches, scatter cells, stream
chunks, grass tiles, water), actors past it are skipped for draws, casters and outlines, the grass
reach and the streams' generate-and-gather reach end at it, and the card shadows cast only from
the wall's share of a layer (fog_casters.ludic, rebuilt every 4 m). r3d_beyond_fog is exported for
the game to skip animating what will not be drawn. Render-only: no query of the ground or the world
changes, and off is the old frame (0 pixels over 8 against 160ce96, twice per side).

Draws per frame at the overlook: 2757 off, 1104 at 175 m, 484 at 30 m.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:38:00 +03:00

365 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()
# past the fog wall nothing is generated, gathered or kept wanted
let reach = r3d_reach(render3d_st, s.reach)
let r = int(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 < 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`) }