# ============================================================================ # 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. # ============================================================================ var STREAM_MAX_CHUNKS: int = 4096 property Chunk { key: int = 0, # packed (cx, cz, band) used: int = 0, # the walk that last wanted it (for eviction) data: words, # INST_FLOATS per instance 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) } var stream_all: []Stream = null var stream_cap_read: bool = false var stream_no_evict: bool = false # R3D_NOEVICT: the old behaviour, for comparison var stream_walk_no: int = 0 # counts ring walks; a chunk's age is measured in these var stream_evictions: int = 0 # microseconds spent per frame, split so the hitch can be attributed (R3D_PROF=1) var stream_us_gen: long = 0 # generating new chunks (stream_fill) var stream_us_gather: long = 0 # copying cached chunks into the layer buffer var stream_us_walk: long = 0 # the ring walk itself var stream_walks: int = 0 # streams that walked their whole ring this frame function stream_new(layer: Layer, size: float, reach: float, b0: float, b1: float, b2: float, b3: float) -> Stream { if not stream_cap_read { stream_cap_read = true if r3d_env_has("R3D_STREAM_CAP") { STREAM_MAX_CHUNKS = Text.to_int(r3d_env("R3D_STREAM_CAP")) } stream_no_evict = r3d_env_has("R3D_NOEVICT") } 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 s.chunks = new []Chunk s.keys = words(STREAM_MAX_CHUNKS) s.htab = words(STREAM_HASH) for i in 0 .. STREAM_HASH { s.htab[i] = 0 } if stream_all == null { stream_all = new []Stream } push(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 var stream_debug_n: int = 0 var stream_scratch: floats = null function stream_emit(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 stream_scratch == null { stream_scratch = floats(STREAM_CHUNK_MAX * INST_FLOATS) } 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 stream_scratch[o] = x; stream_scratch[o + 1] = y; stream_scratch[o + 2] = z; stream_scratch[o + 3] = scale stream_scratch[o + 4] = Math.sin(yaw); stream_scratch[o + 5] = Math.cos(yaw); stream_scratch[o + 6] = seed; 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. var STREAM_BUDGET_US: int = 2500 # microseconds of generation per frame; a setting may move it var stream_deadline: long = 0 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. var stream_budget_left: int = 0 # 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(s: Stream) -> void { # the age threshold that keeps about half, found by bisection on the count (no sort) var lo = 0 var hi = 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 let kept = new []Chunk var i = 0 while i < s.n { let c = s.chunks[i] if c.used >= t or c.used == stream_walk_no { push(kept, c) } else { if c.data != null { free(c.data) } } i += 1 } s.chunks = kept s.n = len(kept) 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 } stream_evictions += 1 } function stream_update(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 == sc_view_gen { return } let first = s.last_cx == 999999 s.view_gen = sc_view_gen s.last_cx = ccx; s.last_cz = ccz let l = s.layer l.count = 0 var missing = false stream_walks += 1 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 = 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 if c == null and (first or urgent or gl_now_us() < stream_deadline) { c = new Chunk c.key = key s.cur = c let t0 = gl_now_us() stream_fill(s, cx, cz, band) let dt = gl_now_us() - t0 stream_us_gen = stream_us_gen + dt prof_chunk(s.kind, band, c.count, dt) if r3d_debug and band == 0 and stream_debug_n < 40 { stream_debug_n += 1; print(`stream kind {s.kind} band {band} chunk {cx},{cz}: {c.count} instances`) } if c.count > 0 { c.data = words(c.count * INST_FLOATS); mem_copy(c.data, stream_scratch, c.count * INST_FLOATS * 4) } if s.n >= STREAM_MAX_CHUNKS and not stream_no_evict { stream_evict(s) } # If the walk in progress wants more chunks than the cache can hold, there # is nothing to evict and this one is used and dropped, as every chunk used # to be. The cap has to exceed one walk's ring for the cache to work at all. if s.n < STREAM_MAX_CHUNKS { push(s.chunks, c); s.keys[s.n] = key; stream_remember(s, key, s.n); s.n += 1 c.used = stream_walk_no } prof_gen_add(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 = stream_walk_no } } if c != null and c.count > 0 and l.count + c.count <= l.cap and stream_chunk_visible(s, cx, cz, c) { let tg = gl_now_us() mem_copy(mem_off(l.inst, l.count * INST_FLOATS * 4), c.data, c.count * INST_FLOATS * 4) l.count += c.count stream_us_gather = stream_us_gather + (gl_now_us() - tg) } } } cx += 1 } cz += 1 } ring += 1 } s.pending = missing stream_us_walk = 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(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(wx, cy, wz, r) } # what the caches hold, and whether they are being churned (R3D_PROF) function stream_census() -> void { if stream_all == null { return } print("") print(`ground-cover chunk caches (cap {string(STREAM_MAX_CHUNKS)} each, {string(stream_evictions)} evictions over the run):`) var inst = 0 for i in 0 .. len(stream_all) { let s = 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() -> void { if stream_all == null { return } stream_deadline = gl_now_us() + STREAM_BUDGET_US for i in 0 .. len(stream_all) { stream_update(stream_all[i], cam_pos[0], cam_pos[2]) } } # every stream and its cached chunks, for a world being replaced (scatter_clear_all) function stream_clear_all() -> void { if stream_all == null { return } for i in 0 .. len(stream_all) { let s = stream_all[i] if s.chunks != null { for c in 0 .. len(s.chunks) { if s.chunks[c].data != null { free(s.chunks[c].data) } } } if s.keys != null { free(s.keys) } if s.htab != null { free(s.htab) } if s.bands != null { free(s.bands) } } stream_all = null }