UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

584 lines (517 loc) 22.9 kB
import { assert } from "../../../core/assert.js"; import { BinaryBuffer } from "../../../core/binary/BinaryBuffer.js"; /** Words per record: [network_id, type_id, byte_offset, byte_length]. 4 × u32 = 16 B. */ const RECORD_WORDS = 4; /** 256-bit bloom filter per page → 8 × u32. */ const BLOOM_WORDS = 8; /** * Records per page. Sized so that with k=3 hash functions on a 256-bit filter * the false-positive rate is just under 5% — `(1 - exp(-3·39/256))^3 ≈ 0.048`. */ const METADATA_PAGE_SIZE = 39; /** Total words per fully-occupied page. Underused pages are smaller. */ const MAX_PAGE_WORDS = BLOOM_WORDS + METADATA_PAGE_SIZE * RECORD_WORDS; // 164 /** * Reusable scratch row used by {@link InterpolationLog#interpolate} to hold * the (offset, length) pairs from two `locate` calls. Six slots × u32 — the * two pairs live at indices [4..5] and [6..7]; lower slots are spare for any * future scratch use. * @type {Uint32Array} */ const scratch_u32 = new Uint32Array(8); /** * Insert a u32 key into the 256-bit bloom filter at words `[base..base+7]` of `table`. * Reference: standard double-hashing trick, k=3 hashes derived from one murmur3 mix. * * @param {Uint32Array} table * @param {number} base word offset of the bloom filter's first word * @param {number} key u32 to hash */ function bloom_insert(table, base, key) { let h = key | 0; h ^= h >>> 16; h = Math.imul(h, 0x85ebca6b); h ^= h >>> 13; const h1 = Math.imul(h, 0xc2b2ae35); const h2 = h1 ^ (h1 >>> 16); for (let i = 0; i < 3; i++) { const bit_index = (h1 + Math.imul(i, h2)) & 255; const word_index = bit_index >>> 5; table[base + word_index] |= (1 << bit_index); } } /** * Bloom-filter membership test. Returns false ⇒ definitely not present; * true ⇒ likely present (false-positive rate per page ≈ 5%). * * @param {Uint32Array} table * @param {number} base * @param {number} key * @returns {boolean} */ function bloom_contains(table, base, key) { let h = key | 0; h ^= h >>> 16; h = Math.imul(h, 0x85ebca6b); h ^= h >>> 13; const h1 = Math.imul(h, 0xc2b2ae35); const h2 = h1 ^ (h1 >>> 16); for (let i = 0; i < 3; i++) { const bit_index = (h1 + Math.imul(i, h2)) & 255; const word_index = bit_index >>> 5; if ((table[base + word_index] & (1 << bit_index)) === 0) return false; } return true; } /** * Per-tick recording of replicated component state for snapshot interpolation. * * Two ring buffers — both wrap at tick boundaries only: * * - `__buffer` (BinaryBuffer): raw component bytes, written via `begin_record` * / `end_record`. Variable size per record. * * - `__records` (Uint32Array): the metadata table, holding one row per * `(network_id, type_id, byte_offset, byte_length)` plus a bloom-filter * header every {@link METADATA_PAGE_SIZE} records. The structure: * * page = [bloom_0..7] [rec_0_n, _t, _o, _l] ... [rec_38_n, _t, _o, _l] * * A new page header is written at `begin_tick` and then again every 39 * records within a tick. The bloom filter is keyed by `network_id`, so a * `locate(network_id, type_id)` can skip whole pages with a single 256-bit * check before falling back to a per-record scan inside the page. Pages * never straddle tick boundaries. * * Per-tick metadata is small: byte range + word range in `__records` + record * count. The oldest tick is tracked explicitly (`__oldest_tick`) so the squash * check at each {@link begin_tick} is amortized O(1) — squash the oldest while * it overlaps the new write region, advance the pointer, repeat. * * Pair with {@link BinaryInterpolationAdapter} concrete subclasses (e.g. * {@link Vector3InterpolationAdapter}) which know each component type's * encoding and blend math. * * Typical receive-side use: * ``` * onFrameApplied(_, frame_number) { * log.begin_tick(frame_number); * const buf = log.begin_record(network_id, type_id); * adapter.serialize(buf, value); * log.end_record(); * log.end_tick(); * } * ``` * * Typical render-time use: * ``` * const ok = log.interpolate(out_buffer, network_id, type_id, tick_a, tick_b, t, adapter); * if (ok) { /* deserialize from out_buffer into the live component } * ``` * * @author Alex Goldring * @copyright Company Named Limited (c) 2025 */ export class InterpolationLog { #buffer; #buffer_cursor; #records; #records_word_capacity; #records_cursor; #current_page_start; #records_in_current_page; #ticks; #oldest_tick; #max_tick_bytes; #max_tick_words; #current_tick; #pending_record_start; #pending_network_id; #pending_type_id; /** * @param {{ buffer_capacity_bytes?: number, records_capacity?: number }} [options] * `records_capacity` is in *records* (not bytes); the underlying word * array is sized large enough to fit that many records plus their pages' * bloom-filter headers. */ constructor({ buffer_capacity_bytes = 65536, records_capacity = 4096 } = {}) { assert.isNonNegativeInteger(buffer_capacity_bytes, 'buffer_capacity_bytes'); assert.ok(buffer_capacity_bytes >= 256, 'buffer_capacity_bytes must be >= 256'); assert.isNonNegativeInteger(records_capacity, 'records_capacity'); assert.ok(records_capacity >= 1, 'records_capacity must be >= 1'); /** * Component-bytes ring. Reads (during {@link interpolate}) move the * buffer's position cursor; we track the next write position * separately in `__buffer_cursor` so reads don't clobber it. * @private @type {BinaryBuffer} */ this.#buffer = new BinaryBuffer(); this.#buffer.fromArrayBuffer(new ArrayBuffer(buffer_capacity_bytes)); /** @readonly @type {number} */ this.buffer_capacity = buffer_capacity_bytes; /** @private @type {number} */ this.#buffer_cursor = 0; /** * Metadata table — flat Uint32Array of pages back-to-back. Sized for * the worst case of every page being full; if the workload doesn't * fill pages, the wasted slots just sit empty. * @private @type {Uint32Array} */ const num_pages = Math.ceil(records_capacity / METADATA_PAGE_SIZE); const total_words = num_pages * MAX_PAGE_WORDS; this.#records = new Uint32Array(total_words); /** @readonly @type {number} */ this.records_capacity = records_capacity; /** Total word capacity of the metadata table (used for wrap/overlap math). @readonly @type {number} */ this.#records_word_capacity = total_words; /** @private @type {number} Next word position to write. */ this.#records_cursor = 0; /** * Current page state. While a tick is open, `__current_page_start` is * the word offset of the bloom-filter header for the page being * filled, and `__records_in_current_page` counts how many of its * 39 record slots are already populated. * @private @type {number} */ this.#current_page_start = -1; /** @private @type {number} */ this.#records_in_current_page = 0; /** * Per-tick metadata, keyed by tick number. Map preserves insertion * order, which equals chronological order; the first key is therefore * always the oldest tick, mirrored by `__oldest_tick`. * * Entry shape: `{ byte_start, byte_end, record_start_word, * record_word_count, record_count }`. * * @private @type {Map<number, {byte_start: number, byte_end: number, record_start_word: number, record_word_count: number, record_count: number}>} */ this.#ticks = new Map(); /** * Tick number of the oldest live tick, or -1 if empty. * @private @type {number} */ this.#oldest_tick = -1; /** * Largest single-tick byte / word footprint observed. Used as the * estimated-tick-size at {@link begin_tick} to decide whether to wrap * pre-emptively. * @private @type {number} */ this.#max_tick_bytes = 0; /** @private @type {number} */ this.#max_tick_words = 0; /** @private @type {number} */ this.#current_tick = -1; /** * Open-record state. -1 when no record is currently being written; * otherwise the byte offset where the in-progress record's payload * began (so {@link end_record} can compute the length). * @private @type {number} */ this.#pending_record_start = -1; /** @private @type {number} */ this.#pending_network_id = 0; /** @private @type {number} */ this.#pending_type_id = 0; } /** * Open a new tick for recording. May wrap either ring at this point — but * never mid-tick, so a single tick's data is always contiguous in both * the byte buffer and the metadata table. After wrapping, any old tick * whose data overlaps the new write region is squashed (in oldest-first * order) and `__oldest_tick` advances. * * @param {number} tick non-negative integer */ begin_tick(tick) { assert.isNonNegativeInteger(tick, 'tick'); if (this.#current_tick !== -1) { throw new Error(`InterpolationLog.begin_tick: previous tick ${this.#current_tick} still open; call end_tick first`); } // Wrap the byte buffer if remaining < typical tick size. if (this.buffer_capacity - this.#buffer_cursor < this.#max_tick_bytes) { this.#buffer_cursor = 0; } // Wrap the metadata table similarly. if (this.#records_word_capacity - this.#records_cursor < this.#max_tick_words) { this.#records_cursor = 0; } // Estimate the new tick's byte and word ranges (worst-case = max-observed). const new_byte_start = this.#buffer_cursor; const new_byte_end_est = new_byte_start + this.#max_tick_bytes; const new_word_start = this.#records_cursor; const new_word_end_est = new_word_start + this.#max_tick_words; // Squash oldest ticks while they overlap. O(1) amortized. while (this.#oldest_tick !== -1) { const oldest_meta = this.#ticks.get(this.#oldest_tick); const byte_overlap = oldest_meta.byte_end > new_byte_start && oldest_meta.byte_start < new_byte_end_est; const word_overlap = (oldest_meta.record_start_word + oldest_meta.record_word_count) > new_word_start && oldest_meta.record_start_word < new_word_end_est; if (!byte_overlap && !word_overlap) break; this.#ticks.delete(this.#oldest_tick); const next = this.#ticks.keys().next(); this.#oldest_tick = next.done ? -1 : next.value; } // Open this tick's first page: write a fresh (zero) bloom filter header. this.#current_page_start = this.#records_cursor; const records = this.#records; records[this.#current_page_start + 0] = 0; records[this.#current_page_start + 1] = 0; records[this.#current_page_start + 2] = 0; records[this.#current_page_start + 3] = 0; records[this.#current_page_start + 4] = 0; records[this.#current_page_start + 5] = 0; records[this.#current_page_start + 6] = 0; records[this.#current_page_start + 7] = 0; this.#records_cursor += BLOOM_WORDS; this.#records_in_current_page = 0; const meta = { byte_start: new_byte_start, byte_end: new_byte_start, record_start_word: this.#current_page_start, record_word_count: BLOOM_WORDS, record_count: 0, }; this.#ticks.set(tick, meta); if (this.#oldest_tick === -1) { this.#oldest_tick = tick; } this.#current_tick = tick; } /** * Begin recording one component. Returns the underlying byte buffer * positioned at the next write slot — the caller writes the component * payload directly (e.g. via a `BinaryClassSerializationAdapter.serialize` * call), then calls {@link end_record} to finalize the (offset, length). * * Splitting begin/end (vs. taking a write callback) avoids the per-record * closure allocation and lets the caller pass the buffer straight to an * adapter without wrapping. * * @param {number} network_id * @param {number} type_id component type id (small integer; matches the registry) * @returns {BinaryBuffer} the log's byte buffer, positioned for writing the payload */ begin_record(network_id, type_id) { assert.isNonNegativeInteger(network_id, 'network_id'); assert.isNonNegativeInteger(type_id, 'type_id'); if (this.#current_tick === -1) { throw new Error('InterpolationLog.begin_record: no tick is open; call begin_tick first'); } if (this.#pending_record_start !== -1) { throw new Error('InterpolationLog.begin_record: previous record still open; call end_record first'); } // If the current page is full, open a new one before this record's // metadata gets written. if (this.#records_in_current_page >= METADATA_PAGE_SIZE) { const records = this.#records; this.#current_page_start = this.#records_cursor; records[this.#current_page_start + 0] = 0; records[this.#current_page_start + 1] = 0; records[this.#current_page_start + 2] = 0; records[this.#current_page_start + 3] = 0; records[this.#current_page_start + 4] = 0; records[this.#current_page_start + 5] = 0; records[this.#current_page_start + 6] = 0; records[this.#current_page_start + 7] = 0; this.#records_cursor += BLOOM_WORDS; this.#records_in_current_page = 0; } const buf = this.#buffer; buf.position = this.#buffer_cursor; this.#pending_record_start = buf.position; this.#pending_network_id = network_id; this.#pending_type_id = type_id; return buf; } /** * Finalize the in-progress record opened by {@link begin_record}. Reads * the buffer's current position to compute the payload length, inserts * the network_id into the current page's bloom filter, writes the row to * the metadata table, and advances the cursors. */ end_record() { if (this.#pending_record_start === -1) { throw new Error('InterpolationLog.end_record: no record is open; call begin_record first'); } const buf = this.#buffer; const start = this.#pending_record_start; const end = buf.position; const length = end - start; this.#buffer_cursor = end; const records = this.#records; const nid = this.#pending_network_id; // Update the current page's bloom filter for fast skip-by-network_id. bloom_insert(records, this.#current_page_start, nid); // Write the record at the cursor. const at = this.#records_cursor; records[at + 0] = nid; records[at + 1] = this.#pending_type_id; records[at + 2] = start; records[at + 3] = length; this.#records_cursor += RECORD_WORDS; this.#records_in_current_page++; const meta = this.#ticks.get(this.#current_tick); meta.byte_end = end; meta.record_count++; const grown_word_count = this.#records_cursor - meta.record_start_word; meta.record_word_count = grown_word_count; // Overrun guard: the begin_tick wrap heuristic sizes off the largest // *previously observed* tick. A tick whose footprint exceeds any prior // one can advance the cursors into the live range of an older tick. // Only check (and squash) when this tick is actively exceeding the // envelope — steady-state ticks skip both branches. const grown_byte_count = end - meta.byte_start; if (grown_byte_count > this.#max_tick_bytes || grown_word_count > this.#max_tick_words) { while (this.#oldest_tick !== -1 && this.#oldest_tick !== this.#current_tick) { const oldest_meta = this.#ticks.get(this.#oldest_tick); const byte_overlap = end > oldest_meta.byte_start && meta.byte_start < oldest_meta.byte_end; const word_overlap = this.#records_cursor > oldest_meta.record_start_word && meta.record_start_word < oldest_meta.record_start_word + oldest_meta.record_word_count; if (!byte_overlap && !word_overlap) break; this.#ticks.delete(this.#oldest_tick); const next = this.#ticks.keys().next(); this.#oldest_tick = next.done ? -1 : next.value; } } this.#pending_record_start = -1; } /** * Close the currently-open tick. Updates the max-observed-tick sizes so * the next {@link begin_tick} can size its wrap heuristic correctly. * * Throws if a record is still open — silently dropping the in-progress * record's metadata (offset/length) would leak buffer space and produce * stale bytes that no one can locate. */ end_tick() { if (this.#current_tick === -1) { throw new Error('InterpolationLog.end_tick: no tick is open; call begin_tick first'); } if (this.#pending_record_start !== -1) { throw new Error(`InterpolationLog.end_tick: record (network_id=${this.#pending_network_id}, type_id=${this.#pending_type_id}) still open; call end_record first`); } const meta = this.#ticks.get(this.#current_tick); const tick_bytes = meta.byte_end - meta.byte_start; if (tick_bytes > this.#max_tick_bytes) this.#max_tick_bytes = tick_bytes; if (meta.record_word_count > this.#max_tick_words) this.#max_tick_words = meta.record_word_count; this.#current_tick = -1; } /** * @param {number} tick * @returns {boolean} true if the tick has live (non-squashed) data */ has_tick(tick) { return this.#ticks.has(tick); } /** * Find a component's stored slice within a tick. Walks the tick's pages, * bloom-filter-skipping pages that don't contain `network_id`, and * scanning records inside any page where the bloom hits. * * On success, writes `out[out_offset+0] = byte_offset` and * `out[out_offset+1] = byte_length`. * * @param {number[]|Uint32Array} out * @param {number} out_offset * @param {number} tick * @param {number} network_id * @param {number} type_id * @returns {boolean} false if the tick is missing/squashed or doesn't carry this component */ locate(out, out_offset, tick, network_id, type_id) { const meta = this.#ticks.get(tick); if (meta === undefined) return false; const records = this.#records; let page_start = meta.record_start_word; let records_remaining = meta.record_count; while (records_remaining > 0) { const records_in_this_page = Math.min(records_remaining, METADATA_PAGE_SIZE); // Skip the whole page if the bloom filter says network_id isn't in it. if (bloom_contains(records, page_start, network_id)) { const records_base = page_start + BLOOM_WORDS; for (let i = 0; i < records_in_this_page; i++) { const r = records_base + i * RECORD_WORDS; if (records[r + 0] === network_id && records[r + 1] === type_id) { out[out_offset + 0] = records[r + 2]; out[out_offset + 1] = records[r + 3]; return true; } } } page_start += BLOOM_WORDS + records_in_this_page * RECORD_WORDS; records_remaining -= records_in_this_page; } return false; } /** * Interpolate one component between two ticks via `adapter` and write the * result to `out_buffer` at its current position. * * Outcome by available data: * - Both ticks carry the component → adapter blends them at `t`. * - Only one carries it → adapter is called with the same offset twice * (snap to the surviving snapshot; `t` is irrelevant). * - Neither carries it → returns false; `out_buffer` is unchanged. * * @param {BinaryBuffer} out_buffer * @param {number} network_id * @param {number} type_id * @param {number} tick_a * @param {number} tick_b * @param {number} t * @param {BinaryInterpolationAdapter} adapter * @returns {boolean} true if a payload was written; false if neither tick has the component */ interpolate(out_buffer, network_id, type_id, tick_a, tick_b, t, adapter) { const a = this.locate(scratch_u32, 4, tick_a, network_id, type_id); const b = this.locate(scratch_u32, 6, tick_b, network_id, type_id); if (!(a || b)) return false; const offset_a = scratch_u32[4]; const offset_b = scratch_u32[6]; const first = a ? offset_a : offset_b; const second = b ? offset_b : offset_a; // Adapter is free to mutate __buffer.position (it sets it to specific // offsets to read). Save & restore so subsequent reads/writes aren't // surprised by a moved cursor. const buffer = this.#buffer; const saved = buffer.position; adapter.interpolate(out_buffer, buffer, first, second, t); buffer.position = saved; return true; } /** * Number of ticks currently held. Squashed ticks are excluded. * @returns {number} */ size() { return this.#ticks.size; } /** * Tick number of the oldest live tick, or -1 if empty. * @returns {number} */ oldest_tick() { return this.#oldest_tick; } /** * Drop all recorded ticks. Resets both ring cursors to 0. Useful on * reconnect or level transition. */ clear() { this.#ticks.clear(); this.#buffer_cursor = 0; this.#records_cursor = 0; this.#oldest_tick = -1; this.#current_tick = -1; this.#pending_record_start = -1; this.#current_page_start = -1; this.#records_in_current_page = 0; } }