Skip to content

Commit a01bf5a

Browse files
committed
feat(entity): add BitSet class for efficient bit manipulation
1 parent 6a5ba09 commit a01bf5a

File tree

3 files changed

+362
-124
lines changed

3 files changed

+362
-124
lines changed

src/bit-set.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
export class BitSet {
2+
private data: Uint32Array;
3+
private _length: number;
4+
5+
constructor(length: number) {
6+
this._length = length;
7+
const numWords = Math.ceil(length / 32);
8+
this.data = new Uint32Array(numWords);
9+
}
10+
11+
get length(): number {
12+
return this._length;
13+
}
14+
15+
has(index: number): boolean {
16+
if (index < 0 || index >= this._length) return false;
17+
const word = index >>> 5; // divide by 32
18+
const bit = index & 31;
19+
return ((this.data[word]! >>> bit) & 1) !== 0;
20+
}
21+
22+
set(index: number): void {
23+
if (index < 0 || index >= this._length) return;
24+
const word = index >>> 5;
25+
const bit = index & 31;
26+
this.data[word]! |= 1 << bit;
27+
}
28+
29+
clear(index: number): void {
30+
if (index < 0 || index >= this._length) return;
31+
const word = index >>> 5;
32+
const bit = index & 31;
33+
this.data[word]! &= ~(1 << bit);
34+
}
35+
36+
// set a range [lo, hi] inclusive to 1
37+
setRange(lo: number, hi: number): void {
38+
if (lo > hi) return;
39+
if (lo < 0) lo = 0;
40+
if (hi >= this._length) hi = this._length - 1;
41+
42+
const firstWord = lo >>> 5;
43+
const lastWord = hi >>> 5;
44+
const loBit = lo & 31;
45+
const hiBit = hi & 31;
46+
47+
// helper to produce mask for [a..b] within a single 32-bit word
48+
const maskFor = (a: number, b: number) => {
49+
const width = b - a + 1;
50+
if (width <= 0) return 0 >>> 0;
51+
if (width >= 32) return 0xffffffff >>> 0;
52+
return (((1 << width) - 1) << a) >>> 0;
53+
};
54+
55+
if (firstWord === lastWord) {
56+
const mask = maskFor(loBit, hiBit);
57+
this.data[firstWord]! = (this.data[firstWord]! | mask) >>> 0;
58+
return;
59+
}
60+
61+
// first partial word
62+
const firstMask = maskFor(loBit, 31);
63+
this.data[firstWord]! = (this.data[firstWord]! | firstMask) >>> 0;
64+
65+
// middle full words
66+
for (let w = firstWord + 1; w <= lastWord - 1; w++) {
67+
this.data[w] = 0xffffffff >>> 0;
68+
}
69+
70+
// last partial word
71+
const lastMask = maskFor(0, hiBit);
72+
this.data[lastWord]! = (this.data[lastWord]! | lastMask) >>> 0;
73+
}
74+
75+
// check whether any bit in [lo, hi] is zero (i.e. not set)
76+
anyClearInRange(lo: number, hi: number): boolean {
77+
if (lo > hi) return false;
78+
if (lo < 0) lo = 0;
79+
if (hi >= this._length) hi = this._length - 1;
80+
81+
const firstWord = lo >>> 5;
82+
const lastWord = hi >>> 5;
83+
const loBit = lo & 31;
84+
const hiBit = hi & 31;
85+
86+
const maskFor = (a: number, b: number) => {
87+
const width = b - a + 1;
88+
if (width <= 0) return 0 >>> 0;
89+
if (width >= 32) return 0xffffffff >>> 0;
90+
return (((1 << width) - 1) << a) >>> 0;
91+
};
92+
93+
if (firstWord === lastWord) {
94+
const mask = maskFor(loBit, hiBit);
95+
const bits = (this.data[firstWord]! & mask) >>> 0;
96+
return bits !== mask >>> 0;
97+
}
98+
99+
// first partial word: if any bit in the mask is clear -> return true
100+
const firstMask = maskFor(loBit, 31);
101+
if ((this.data[firstWord]! & firstMask) >>> 0 !== firstMask >>> 0) return true;
102+
103+
// middle full words
104+
for (let w = firstWord + 1; w <= lastWord - 1; w++) {
105+
if (this.data[w] !== 0xffffffff >>> 0) return true;
106+
}
107+
108+
// last partial word
109+
const lastMask = maskFor(0, hiBit);
110+
if ((this.data[lastWord]! & lastMask) >>> 0 !== lastMask >>> 0) return true;
111+
112+
return false;
113+
}
114+
115+
// reset all bits to zero
116+
reset(): void {
117+
this.data.fill(0);
118+
}
119+
120+
*[Symbol.iterator](): IterableIterator<number> {
121+
for (let wordIndex = 0; wordIndex < this.data.length; wordIndex++) {
122+
let word = this.data[wordIndex]!;
123+
if (word === 0) continue;
124+
const baseIndex = wordIndex * 32;
125+
for (let bit = 0; bit < 32 && baseIndex + bit < this._length; bit++) {
126+
if (word & 1) {
127+
yield baseIndex + bit;
128+
}
129+
word >>>= 1;
130+
}
131+
}
132+
}
133+
}

src/entity.ts

Lines changed: 152 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { BitSet } from "./bit-set";
2+
13
/**
24
* Unique symbol brand for associating component type information with EntityId
35
*/
@@ -460,6 +462,11 @@ export interface ComponentOptions {
460462

461463
const ComponentOptions: Map<ComponentId<any>, ComponentOptions> = new Map();
462464

465+
// BitSets for fast component option checks (Component ID range: 1-1023)
466+
const exclusiveFlags = new BitSet(COMPONENT_ID_MAX + 1);
467+
const cascadeDeleteFlags = new BitSet(COMPONENT_ID_MAX + 1);
468+
const dontFragmentFlags = new BitSet(COMPONENT_ID_MAX + 1);
469+
463470
/**
464471
* Allocate a new component ID from the global allocator.
465472
* @param nameOrOptions Optional name for the component (for serialization/debugging) or options object
@@ -501,6 +508,10 @@ export function component<T = void>(nameOrOptions?: string | ComponentOptions):
501508
// Register options if provided
502509
if (options) {
503510
ComponentOptions.set(id, options);
511+
// Set bitset flags for fast lookup
512+
if (options.exclusive) exclusiveFlags.set(id);
513+
if (options.cascadeDelete) cascadeDeleteFlags.set(id);
514+
if (options.dontFragment) dontFragmentFlags.set(id);
504515
}
505516

506517
return id;
@@ -538,7 +549,7 @@ export function getComponentOptions(id: ComponentId<any>): ComponentOptions | un
538549
* @returns true if the component is exclusive, false otherwise
539550
*/
540551
export function isExclusiveComponent(id: ComponentId<any>): boolean {
541-
return ComponentOptions.get(id)?.exclusive ?? false;
552+
return exclusiveFlags.has(id);
542553
}
543554

544555
/**
@@ -547,7 +558,7 @@ export function isExclusiveComponent(id: ComponentId<any>): boolean {
547558
* @returns true if the component is cascade delete, false otherwise
548559
*/
549560
export function isCascadeDeleteComponent(id: ComponentId<any>): boolean {
550-
return ComponentOptions.get(id)?.cascadeDelete ?? false;
561+
return cascadeDeleteFlags.has(id);
551562
}
552563

553564
/**
@@ -556,5 +567,143 @@ export function isCascadeDeleteComponent(id: ComponentId<any>): boolean {
556567
* @returns true if the component is dontFragment, false otherwise
557568
*/
558569
export function isDontFragmentComponent(id: ComponentId<any>): boolean {
559-
return ComponentOptions.get(id)?.dontFragment ?? false;
570+
return dontFragmentFlags.has(id);
571+
}
572+
573+
/**
574+
* Check if a relation ID is a dontFragment relation (entity-relation or component-relation with dontFragment component)
575+
* This is an optimized function that avoids the overhead of getDetailedIdType
576+
* @param id The entity/relation ID to check
577+
* @returns true if this is a dontFragment relation, false otherwise
578+
*/
579+
export function isDontFragmentRelation(id: EntityId<any>): boolean {
580+
// Only relation IDs are negative; non-relations return false immediately
581+
if (id >= 0) return false;
582+
583+
// Extract componentId directly from the relation encoding: -(componentId * RELATION_SHIFT + targetId)
584+
// componentId = floor(absId / RELATION_SHIFT)
585+
const absId = -id;
586+
const componentId = Math.floor(absId / RELATION_SHIFT);
587+
588+
// Wildcard relations (targetId === 0) are not considered dontFragment relations for this check
589+
const targetId = absId % RELATION_SHIFT;
590+
if (targetId === WILDCARD_TARGET_ID) return false;
591+
592+
// Check if componentId is valid and has dontFragment flag
593+
return componentId >= 1 && componentId <= COMPONENT_ID_MAX && dontFragmentFlags.has(componentId);
594+
}
595+
596+
/**
597+
* Check if an ID is a wildcard relation with dontFragment component
598+
* This is an optimized function for filtering archetype component types
599+
* @param id The entity/relation ID to check
600+
* @returns true if this is a wildcard relation with dontFragment component, false otherwise
601+
*/
602+
export function isDontFragmentWildcard(id: EntityId<any>): boolean {
603+
// Only relation IDs are negative
604+
if (id >= 0) return false;
605+
606+
const absId = -id;
607+
const targetId = absId % RELATION_SHIFT;
608+
609+
// Must be a wildcard relation (targetId === 0)
610+
if (targetId !== WILDCARD_TARGET_ID) return false;
611+
612+
const componentId = Math.floor(absId / RELATION_SHIFT);
613+
return componentId >= 1 && componentId <= COMPONENT_ID_MAX && dontFragmentFlags.has(componentId);
614+
}
615+
616+
/**
617+
* Check if a relation ID is an exclusive relation (entity-relation or component-relation with exclusive component)
618+
* This avoids the full getDetailedIdType overhead for hot paths
619+
* @param id The entity/relation ID to check
620+
* @returns true if this is an exclusive relation, false otherwise
621+
*/
622+
export function isExclusiveRelation(id: EntityId<any>): boolean {
623+
if (id >= 0) return false;
624+
const absId = -id;
625+
const targetId = absId % RELATION_SHIFT;
626+
if (targetId === WILDCARD_TARGET_ID) return false; // not an exclusive-specific relation
627+
const componentId = Math.floor(absId / RELATION_SHIFT);
628+
return componentId >= 1 && componentId <= COMPONENT_ID_MAX && exclusiveFlags.has(componentId);
629+
}
630+
631+
/**
632+
* Check if a relation ID is a wildcard relation with exclusive component
633+
*/
634+
export function isExclusiveWildcard(id: EntityId<any>): boolean {
635+
if (id >= 0) return false;
636+
const absId = -id;
637+
const targetId = absId % RELATION_SHIFT;
638+
if (targetId !== WILDCARD_TARGET_ID) return false;
639+
const componentId = Math.floor(absId / RELATION_SHIFT);
640+
return componentId >= 1 && componentId <= COMPONENT_ID_MAX && exclusiveFlags.has(componentId);
641+
}
642+
643+
/**
644+
* Check if a relation ID is a cascade delete entity-relation
645+
* This is an optimized function that avoids the overhead of getDetailedIdType
646+
* Note: Cascade delete only applies to entity-relations (not component-relations or wildcards)
647+
* @param id The entity/relation ID to check
648+
* @returns true if this is an entity-relation with cascade delete, false otherwise
649+
*/
650+
export function isCascadeDeleteRelation(id: EntityId<any>): boolean {
651+
if (id >= 0) return false;
652+
const absId = -id;
653+
const targetId = absId % RELATION_SHIFT;
654+
// Wildcard relations (targetId === 0) don't cascade
655+
if (targetId === WILDCARD_TARGET_ID) return false;
656+
// Only entity-relations cascade (targetId >= ENTITY_ID_START)
657+
if (targetId < ENTITY_ID_START) return false;
658+
const componentId = Math.floor(absId / RELATION_SHIFT);
659+
return componentId >= 1 && componentId <= COMPONENT_ID_MAX && cascadeDeleteFlags.has(componentId);
660+
}
661+
662+
/**
663+
* Get the componentId from a relation ID without fully decoding the relation.
664+
* Returns undefined for non-relation IDs or invalid component IDs.
665+
*/
666+
export function getComponentIdFromRelationId<T>(id: EntityId<T>): ComponentId<T> | undefined {
667+
if (id >= 0) return undefined;
668+
const absId = -id;
669+
const componentId = Math.floor(absId / RELATION_SHIFT);
670+
if (componentId < 1 || componentId > COMPONENT_ID_MAX) return undefined;
671+
return componentId as ComponentId<T>;
672+
}
673+
674+
/**
675+
* Get the targetId from a relation ID without fully decoding the relation.
676+
* Returns undefined for non-relation IDs.
677+
*/
678+
export function getTargetIdFromRelationId(id: EntityId<any>): EntityId<any> | undefined {
679+
if (id >= 0) return undefined;
680+
const absId = -id;
681+
return (absId % RELATION_SHIFT) as EntityId<any>;
682+
}
683+
684+
/**
685+
* Check if an ID is an entity-relation (relation targeting an entity, not a component or wildcard)
686+
*/
687+
export function isEntityRelation(id: EntityId<any>): boolean {
688+
if (id >= 0) return false;
689+
const absId = -id;
690+
const targetId = absId % RELATION_SHIFT;
691+
return targetId >= ENTITY_ID_START;
692+
}
693+
694+
/**
695+
* Check if an ID is a component-relation (relation targeting a component)
696+
*/
697+
export function isComponentRelation(id: EntityId<any>): boolean {
698+
if (id >= 0) return false;
699+
const absId = -id;
700+
const targetId = absId % RELATION_SHIFT;
701+
return targetId >= 1 && targetId <= COMPONENT_ID_MAX;
702+
}
703+
704+
/**
705+
* Check if an ID is any type of relation (entity, component, or wildcard)
706+
*/
707+
export function isAnyRelation(id: EntityId<any>): boolean {
708+
return id < 0;
560709
}

0 commit comments

Comments
 (0)