UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

257 lines (216 loc) 8.49 kB
import { assert } from "../../../core/assert.js"; import { ceilPowerOfTwo } from "../../../core/binary/operations/ceilPowerOfTwo.js"; import { generate_next_linear_congruential_index } from "../../../core/collection/map/generate_next_linear_congruential_index.js"; import { isPowerOfTwo } from "../../../core/math/isPowerOfTwo.js"; /** * Reserved sentinel for "empty slot." Network IDs are unsigned 32-bit; we * forbid `0xFFFFFFFF` as a usable ID (the slot table never allocates that * many slots, so this is safe in practice). * @type {number} */ const EMPTY = 0xFFFFFFFF; /** * Maximum load factor before {@link ChangedEntitySet#add} grows the backing * array. 0.5 keeps probe chains short (mean probe distance ≈ 1.5 with random * keys) at the cost of using twice the slots; the right tradeoff for a hot-path * set where O(1)-expected `add` matters more than memory. * @type {number} */ const LOAD_FACTOR = 0.5; /** * Open-addressed hash set keyed by `uint32` network IDs. Backed by a single * `Uint32Array`; empty slots hold `0xFFFFFFFF`. Probing uses * {@link generate_next_linear_congruential_index} (the same scheme as * `core/collection/map/HashMap.js`). * * Designed for the per-tick "which entities changed this tick?" use case: * - `clear()` is one `fill(0xFFFFFFFF)` call (vectorisable, JIT-friendly). * - `add(network_id)` is O(1) expected with a load factor < 0.5. * - `compact_into(buffer)` writes each non-empty slot as a `varint` and * returns the count, suitable for appending to a `MutationLedger` slice. * * Capacity grows automatically when load factor exceeds 0.5 (doubles + rehashes, * O(N) amortised). Construction-time `capacity` is the *initial* size — * pick at least 2× your typical peak per-tick mutation count to avoid the * grow path on the hot loop. * * @author Alex Goldring * @copyright Company Named Limited (c) 2025 */ export class ChangedEntitySet { #mask; #slots; #size; /** * @param {{ capacity?: number }} [options] capacity is rounded up to a power of two; default 256 */ constructor({ capacity = 256 } = {}) { assert.isNonNegativeInteger(capacity, 'capacity'); // Round up to power of two so we can use a bitmask instead of modulo. const cap = ceilPowerOfTwo(capacity); /** @type {number} */ this.capacity = cap; /** @private @type {number} */ this.#mask = cap - 1; /** * @private * @type {Uint32Array} */ this.#slots = new Uint32Array(cap).fill(EMPTY); /** @private @type {number} */ this.#size = 0; } /** * Reallocate the backing array at `new_capacity` and re-insert every * present ID. Items are placed by their hash against the new mask, so * resize is the only correct way to change capacity (a flat copy would * leave entries at indices that no longer match `id & mask`). * * @param {number} new_capacity power of two; must be at least 2× current size */ resize(new_capacity) { assert.isNonNegativeInteger(new_capacity, 'new_capacity'); assert.ok(isPowerOfTwo(new_capacity), 'new_capacity must be a power of two'); assert.ok(new_capacity >= this.#size * 2, 'new_capacity too small to host current size at load factor 0.5'); // Snapshot the old slots, swap in the new ones, then re-insert. Doing // it via add() handles the new hash positions for free; the trick is // resetting __size first so add()'s "already present" path doesn't // miscount and so the load-factor guard inside add() doesn't recurse. const old_slots = this.#slots; this.#slots = new Uint32Array(new_capacity).fill(EMPTY); this.#mask = new_capacity - 1; this.capacity = new_capacity; this.#size = 0; for (let i = 0; i < old_slots.length; i++) { const v = old_slots[i]; if (v !== EMPTY) this.add(v); } } /** * Double the capacity. Called automatically by {@link add} when the load * factor would exceed `LOAD_FACTOR` after the next insert. */ grow() { this.resize(this.capacity * 2); } /** * Add a network ID. No-op if already present. Grows the backing array * (and re-hashes) if adding this ID would exceed the load factor. * * @param {number} network_id non-negative integer < 0xFFFFFFFF */ add(network_id) { // The EMPTY sentinel collides with itself: the dedup check would match // an empty slot and silently no-op while `has(EMPTY)` would later report // true — `size`/`for_each`/`has` would disagree. assert.notEqual(network_id, EMPTY, 'ChangedEntitySet.add: 0xFFFFFFFF is reserved as the EMPTY sentinel'); // Pre-grow check: if this insert would push us over the load factor, // double capacity *first* so the insert lands in the bigger table. // Equivalent to (size+1)/capacity > LOAD_FACTOR; written without the // division to keep the hot path branch-light. if ((this.#size + 1) > this.capacity * LOAD_FACTOR) { this.grow(); } // No assert in the hot path — this is called per-mutation. Bad input // produces wrong results (collisions with the EMPTY sentinel) which // higher layers will notice; the cost of asserting per call isn't worth it. const slots = this.#slots; const mask = this.#mask; let slot_index = network_id & mask; const capacity = this.capacity; for (let probe = 0; probe < capacity; probe++) { const v = slots[slot_index]; if (v === network_id) { // already present return; } if (v === EMPTY) { slots[slot_index] = network_id; this.#size++; return; } slot_index = generate_next_linear_congruential_index(slot_index, mask); } // Unreachable: the load-factor guard above keeps occupancy under 0.5, // so the probe loop is guaranteed to find an EMPTY slot. throw new Error(`ChangedEntitySet.add: probe loop exhausted at capacity ${this.capacity}, size ${this.#size} — this is a bug`); } /** * @param {number} network_id * @returns {boolean} */ has(network_id) { const slots = this.#slots; const mask = this.#mask; let slot_index = network_id & mask; for (let probe = 0; probe < this.capacity; probe++) { const v = slots[slot_index]; if (v === network_id) { return true; } if (v === EMPTY) { return false; } slot_index = generate_next_linear_congruential_index(slot_index, mask); } return false; } /** * Reset every slot to empty. */ clear() { this.#slots.fill(EMPTY); this.#size = 0; } /** * Number of unique network IDs currently in the set. * @returns {number} */ size() { return this.#size; } /** * Iterate every present network ID. Order is implementation-defined * (slot order, not insertion or sorted) — callers must not rely on it. * * @param {function(number): void} fn */ for_each(fn) { const slots = this.#slots; const cap = this.capacity; for (let i = 0; i < cap; i++) { const v = slots[i]; if (v !== EMPTY) fn(v); } } /** * Append every present network ID into `buffer` as varints. Returns the * number of IDs written. Caller is responsible for any framing (count * prefix, length prefix, etc.). * * @param {BinaryBuffer} buffer * @returns {number} count written */ compact_into(buffer) { let written = 0; const slots = this.#slots; const cap = this.capacity; for (let i = 0; i < cap; i++) { const v = slots[i]; if (v !== EMPTY) { buffer.writeUintVar(v); written++; } } return written; } } /** * Sentinel value reserved for empty slots — exposed so external callers can * recognise it if they peek at raw storage. * @readonly * @type {number} */ ChangedEntitySet.EMPTY = EMPTY;