@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
623 lines (485 loc) • 17.8 kB
JavaScript
import { assert } from "../assert.js";
import { max3 } from "../math/max3.js";
import { min2 } from "../math/min2.js";
import { align_32 } from "./align_32.js";
import { lsb_32 } from "./lsb_32.js";
/**
* Used for overallocating space when bit set needs to grow
* @constant
* @type {number}
*/
const GROW_FACTOR = 1.3;
/**
* Used to allow some un-assigned space to be retained when shrinking
* @constant
* @type {number}
*/
const DEFAULT_SHRINK_FACTOR = 0.5;
/**
* Minimum number of bits to increase the capacity by
* @type {number}
*/
const RESIZE_COUNT_THRESHOLD = 128;
/**
* @readonly
* @type {number}
*/
const DEFAULT_INITIAL_CAPACITY = 64;
/**
* A dynamically resizable bitset (bit array) implementation.
* The bitset automatically grows and shrinks as bits are set and cleared.
*
* @example
* const bits = new BitSet();
* bits.set(10);
*
* bits.get(11) // false
* bits.get(10) // true
* bits.get(9) // false
*
* @author Alex Goldring
* @copyright Company Named Limited (c) 2025
*/
export class BitSet {
/**
* @constructor
* @param {number} [initial_capacity] optional, can supply initial capacity in bits to avoid resizing.
*/
constructor(initial_capacity = DEFAULT_INITIAL_CAPACITY) {
assert.isNonNegativeInteger(initial_capacity, 'initial_capacity');
/**
* Number of bits currently in use (highest set bit + 1).
* @private
* @type {number}
*/
this.__length = 0;
/**
* Current capacity in bits, this is at least equal to length
* Always a multiple of 32.
* @private
* @type {number}
*/
this.__capacity = align_32(initial_capacity);
/**
* Uint32-backed storage
* @type {Uint32Array}
* @private
*/
this.__data_uint32 = new Uint32Array(this.__capacity >> 5);
/**
* The fraction of capacity at which to trigger a shrink operation.
* @type {number}
*/
this.__shrinkFactor = DEFAULT_SHRINK_FACTOR;
}
/**
* Prevents the BitSet from shrinking automatically.
* Useful for fixed-size bitsets.
* @returns {void}
*/
preventShrink() {
this.setShrinkFactor(0);
}
/**
*
* @param {number} x The new shrink factor (must be between 0 and 1, inclusive of 0, exclusive of 1).
*/
setShrinkFactor(x) {
assert.greaterThanOrEqual(x, 0, 'x >= 0');
assert.lessThan(x, 1, 'x < 1');
this.__shrinkFactor = x;
}
/**
* Sets the capacity of the bitset, ensuring enough space.
* Does NOT change set bits.
* @param {number} bit_count The new capacity, in bits.
* @throws {Error} If the requested capacity is smaller than the current {@link size}.
* @returns {void}
*/
setCapacity(bit_count) {
assert.isNonNegativeInteger(bit_count, "bit_count");
if (this.__length > bit_count) {
throw new Error(`Current length(=${this.__length}) is greater than requested size(=${bit_count})`);
}
this.__resize(bit_count);
}
/**
* Returns the number of bits currently "used" by the BitSet.
* This is equivalent to the index of the highest set bit plus one.
* @returns {number} The size of the BitSet (in bits).
*/
size() {
return this.__length;
}
/**
* Returns the current capacity (allocated bits) of the BitSet.
* This is always a multiple of 32.
* @returns {number} The capacity of the BitSet (in bits).
*/
capacity() {
return this.__capacity;
}
/**
* Resizes the internal Uint32Array to the specified capacity.
* @param {number} bitCapacity New capacity in bits.
* @private
*/
__resize(bitCapacity) {
assert.isNonNegativeInteger(bitCapacity, 'bitCapacity');
const uint32_capacity = Math.ceil(bitCapacity / 32);
const oldData = this.__data_uint32;
const newData = new Uint32Array(uint32_capacity);
//copy data from old container
const oldDataSize = oldData.length;
if (oldDataSize < uint32_capacity) {
newData.set(oldData);
} else {
//creating a sub-array is using heap memory, so we prefer to avoid it
newData.set(oldData.subarray(0, uint32_capacity));
}
this.__data_uint32 = newData;
this.__capacity = uint32_capacity * 32;
}
/**
* Updates the internal length of the bitset after clearing bits.
* @private
*/
__updateLength() {
const found_length = this.previousSetBit(this.__length) + 1;
if (found_length < this.__length) {
this.__setLength(found_length);
}
}
/**
* Sets the internal length of the BitSet and manages capacity.
* Grows or shrinks if necessary
* @param {number} new_length The new length of the BitSet.
* @private
*/
__setLength(new_length) {
this.__length = new_length;
const capacity = this.__capacity;
if (new_length > capacity) {
const growSize = Math.ceil(max3(
new_length,
capacity + RESIZE_COUNT_THRESHOLD,
capacity * GROW_FACTOR
));
this.__resize(growSize);
} else {
if (
new_length < capacity - RESIZE_COUNT_THRESHOLD
&& new_length < capacity * this.__shrinkFactor
) {
this.__resize(new_length);
}
}
}
/**
* Returns the index of the nearest bit that is set to true that occurs on or before the specified starting index
* @param {number} from_index The index to start searching from (inclusive).
* @returns {number} Index of previous set bit, or -1 if no set bit found
*/
previousSetBit(from_index) {
assert.isNonNegativeInteger(from_index, `fromIndex`);
const index = min2(from_index, this.__length - 1);
let word_index = index >> 5;
let bit_index = index & 31; // modulo operation is slow, bitwise and is fast, this is the same as %8
const data = this.__data_uint32;
let word = data[word_index];
//handle first byte separately due to potential partial traversal
for (; bit_index >= 0; bit_index--) {
if ((word & (1 << bit_index)) !== 0) {
return word_index * 32 + bit_index;
}
}
//unwind first byte
word_index--;
//scan the rest
for (; word_index >= 0; word_index--) {
word = data[word_index];
for (bit_index = 31; bit_index >= 0; bit_index--) {
if ((word & (1 << bit_index)) !== 0) {
return word_index * 32 + bit_index;
}
}
}
return -1;
}
/**
* Returns the index of the first bit that is set to false that occurs on or after the specified starting index.
* @param {number} from_index The index to start searching from (inclusive).
* @returns {number} index of the next set bit, or -1 if no bits are set beyond supplied index
*/
nextSetBit(from_index) {
// assert.ok(fromIndex >= 0, `fromIndex must be greater or equal to 0, instead was ${fromIndex}`);
// assert.ok(Number.isInteger(fromIndex), `fromIndex must be an integer, instead was ${fromIndex}`);
const bit_length = this.__length;
if (from_index >= bit_length) {
//index is out of bounds, return -1
return -1;
}
const data = this.__data_uint32;
let word_index = from_index >> 5;
let word;
let bit_address;
let bit_index = from_index & 31;
if (bit_index !== 0) {
// bit offset boundary in inside the word, we need to mask part of the word
const fill_mask = ~((1 << bit_index) - 1);
const masked_word = data[word_index] & fill_mask;
if (masked_word !== 0) {
bit_index = lsb_32(masked_word);
return (word_index << 5) + bit_index;
}
word_index++;
}
//scan the rest of the words
const word_count = (bit_length + 31) >> 5; // Math.ceil(x /32)
for (; word_index < word_count; word_index++) {
word = data[word_index];
if (word === 0) {
// all 0s
continue;
}
bit_index = lsb_32(word);
bit_address = (word_index << 5) + bit_index;
return bit_address;
}
return -1;
}
/**
* Returns the index of the first bit that is set to false that occurs on or after the specified starting index.
* @param {number} from_index The index to start searching from (inclusive).
* @returns {number} Index of the next clear bit (bit set to false), or the current length of BitSet if all the bits are set.
*/
nextClearBit(from_index) {
//assert.ok(fromIndex >= 0, `fromIndex must be greater or equal to 0, instead was ${fromIndex}`);
//assert.ok(Number.isInteger(fromIndex), `fromIndex must be an integer, instead was ${fromIndex}`);
let word_index = from_index >> 5;
let word;
// treat first word specially, as we may need to mask out portion of a word to skip certain number of bits
let bit_index = from_index & 31;
const data = this.__data_uint32;
if (bit_index !== 0) {
// bit offset boundary in inside the word, we need to mask part of the word
word = data[word_index];
const fill_mask = (1 << bit_index) - 1;
// apply mask and convert to uint32 via shift
const masked_word = (word | fill_mask) >>> 0;
if (masked_word !== 4294967295) {
bit_index = lsb_32(~masked_word);
return bit_index + word_index * 32;
}
word_index++;
}
const set_length = this.__length;
const word_count = (set_length + 31) >> 5; // Math.ceil(x /32)
//scan the rest
for (; word_index < word_count; word_index++) {
word = data[word_index];
if (word === 4294967295) {
// no 0s
continue;
}
bit_index = lsb_32(~word);
return bit_index + word_index * 32;
}
return set_length;
}
/**
* Sets the bit at the specified index to the specified value.
* @param {number} bit_index The index of the bit to set.
* @param {boolean} value The value to set the bit to (true for 1, false for 0).
* @returns {void}
*/
set(bit_index, value) {
assert.isNonNegativeInteger(bit_index, 'bit_index');
assert.isBoolean(value, 'value');
const word_offset = bit_index >> 5;
const bit_offset = bit_index & 31;
//const oldLength = this.__length;
const word_mask = 1 << bit_offset;
if (value) {
const bitIndexInc = bit_index + 1;
if (bitIndexInc > this.__length) {
// ensure capacity
this.__setLength(bitIndexInc);
}
//set
this.__data_uint32[word_offset] |= word_mask;
} else if (bit_index < this.__length) {
//clear
this.__data_uint32[word_offset] &= ~word_mask;
// trim down set size potentially
this.__updateLength();
}
//DEBUG validate firstClearBit value
//assert.ok(this.__firstClearBitIndex === -1 || this.__firstClearBitIndex === scanToFirstClearBit(this), `Invalid first clear bit index, oldLength=${oldLength}, bitIndex=${bitIndex}`);
}
/**
* Sets the bit specified by the index to false (0).
* @param {number} bit_index The index of the bit to clear.
*/
clear(bit_index) {
this.set(bit_index, false);
}
/**
* Set all bits in a given range
* @param {number} start_index first bit to be set (inclusive)
* @param {number} end_index last bit to be set (inclusive)
*/
setRange(start_index, end_index) {
assert.greaterThanOrEqual(start_index, 0, "invalid start index");
assert.greaterThanOrEqual(end_index, 0, "invalid end index");
for (let i = start_index; i <= end_index; i++) {
this.set(i, true);
}
}
/**
* Clears bit values in a given (inclusive) range
* @param {number} start_index first bit to be cleared (inclusive)
* @param {number} end_index clear up to here, excluding this position
*/
clearRange(start_index, end_index) {
assert.greaterThanOrEqual(start_index, 0, "invalid start index");
assert.greaterThanOrEqual(end_index, 0, "invalid end index");
for (let i = start_index; i < end_index; i++) {
this.set(i, false);
}
}
/**
* Returns the value of the bit with the specified index.
* @param {int} bit_index The index of the bit to get.
* @returns {boolean} The value of the bit (true for 1, false for 0). Returns `false` if `bitIndex` is out of bounds.
*/
get(bit_index) {
assert.isNonNegativeInteger(bit_index, "bitIndex");
if (bit_index >= this.__length) {
//bit is outside the recorded region
return false;
}
const byteOffset = bit_index >> 5;
const bitOffset = bit_index & 31;
const word = this.__data_uint32[byteOffset];
const masked_word = word & (1 << bitOffset);
return masked_word !== 0;
}
/**
* Set a bit at the specified index and return its previous value
* @param {number} index
* @returns {boolean}
*/
getAndSet(index) {
const v = this.get(index);
if (!v) {
this.set(index, true);
}
return v;
}
/**
* Clear a bit at the specified index and return its previous value
* @param {number} index
* @returns {boolean}
*/
getAndClear(index) {
const v = this.get(index);
if (v) {
this.set(index, false);
}
return v;
}
/**
*
* @param {number} distance
* @param {number} start_index
* @param {number} end_index
*/
shift_right(distance, start_index, end_index) {
assert.isNonNegativeInteger(distance, 'distance');
for (let i = end_index; i >= start_index; i--) {
const v = this.get(i);
this.set(i + distance, v);
}
}
/**
*
* @param {number} distance
* @param {number} start_index
* @param {number} end_index
*/
shift_left(distance, start_index, end_index) {
assert.isNonNegativeInteger(distance, 'distance');
for (let i = start_index; i <= end_index; i++) {
const v = this.get(i);
this.set(i - distance, v);
}
}
/**
*
* @param {number} distance
* @param {number} start_index
* @param {number} end_index
*/
shift(distance, start_index, end_index) {
if (distance > 0) {
this.shift_right(distance, start_index, end_index);
} else {
this.shift_right(-distance, start_index, end_index);
}
}
/**
* Sets all the bits in this BitSet to false.
*/
reset() {
const current_length = this.__length;
if (current_length <= 0) {
// no action required
return;
}
if (current_length <= 32) {
// one word, set first word to 0 manually
this.__data_uint32[0] = 0;
} else {
this.__data_uint32.fill(0, 0, Math.ceil(current_length / 32));
}
this.__length = 0;
}
/**
* Copy contents of another bit set into this one
* @param {BitSet} other
*/
copy(other) {
const length = other.__length;
const uint32_size = length >> 5;
const old_length = this.__length;
if (old_length !== length) {
if (old_length < length) {
this.__resize(length);
} else {
// clear everything past the target region
this.__data_uint32.fill(0, uint32_size);
}
this.__length = length;
}
for (let i = 0; i < uint32_size; i++) {
this.__data_uint32[i] = other.__data_uint32[i];
}
const byte_aligned_bit_size = uint32_size << 5;
const overflow_bits = length - byte_aligned_bit_size;
for (let i = 0; i < overflow_bits; i++) {
const bitIndex = byte_aligned_bit_size + i;
this.set(bitIndex, other.get(bitIndex));
}
}
/**
*
* @param {number} x
* @returns {BitSet}
*/
static fixedSize(x) {
const r = new BitSet(x);
r.preventShrink();
return r;
}
}