archetype-ecs 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +32 -0
- package/README.md +359 -0
- package/bench/allocations-1m.js +161 -0
- package/bench/multi-ecs-bench.js +393 -0
- package/bench/typed-array-vs-objects.js +268 -0
- package/bench/typed-vs-bitecs-1m.js +86 -0
- package/bench/typed-vs-untyped.js +166 -0
- package/bench/vs-bitecs.js +267 -0
- package/package.json +23 -5
- package/src/ComponentRegistry.js +21 -0
- package/src/EntityManager.js +194 -94
- package/src/index.d.ts +111 -0
- package/src/index.js +35 -3
- package/tests/EntityManager.test.js +288 -24
- package/tests/types.ts +101 -0
- package/tsconfig.json +10 -0
- package/src/Container.js +0 -23
- package/src/EventBus.js +0 -29
- package/src/GameLoop.js +0 -110
- package/tests/Container.test.js +0 -41
- package/tests/EventBus.test.js +0 -42
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Focused benchmark: archetype+TypedArrays vs bitECS at 1M entities
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createWorld, addEntity, addComponent, query
|
|
5
|
+
} from 'bitecs';
|
|
6
|
+
import { createEntityManager, component } from '../src/index.js';
|
|
7
|
+
|
|
8
|
+
const COUNT = 1_000_000;
|
|
9
|
+
const FRAMES = 500;
|
|
10
|
+
|
|
11
|
+
// archetype-ecs: component() with schema + forEach() hot path
|
|
12
|
+
function benchArchetypeTyped() {
|
|
13
|
+
const Position = component('BenchPos', { x: 'f32', y: 'f32' });
|
|
14
|
+
const Velocity = component('BenchVel', { vx: 'f32', vy: 'f32' });
|
|
15
|
+
const em = createEntityManager();
|
|
16
|
+
|
|
17
|
+
for (let i = 0; i < COUNT; i++) {
|
|
18
|
+
const id = em.createEntity();
|
|
19
|
+
em.addComponent(id, Position, { x: Math.random() * 100, y: Math.random() * 100 });
|
|
20
|
+
em.addComponent(id, Velocity, { vx: Math.random() * 10, vy: Math.random() * 10 });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const t0 = performance.now();
|
|
24
|
+
for (let f = 0; f < FRAMES; f++) {
|
|
25
|
+
em.forEach([Position, Velocity], (arch) => {
|
|
26
|
+
const px = arch.field(Position.x);
|
|
27
|
+
const py = arch.field(Position.y);
|
|
28
|
+
const vx = arch.field(Velocity.vx);
|
|
29
|
+
const vy = arch.field(Velocity.vy);
|
|
30
|
+
for (let i = 0; i < arch.count; i++) {
|
|
31
|
+
px[i] += vx[i];
|
|
32
|
+
py[i] += vy[i];
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return (performance.now() - t0) / FRAMES;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// bitECS: sparse TypedArrays, query returns entity ID list
|
|
40
|
+
function benchBitECS() {
|
|
41
|
+
const world = createWorld();
|
|
42
|
+
const Position = {
|
|
43
|
+
x: new Float32Array(COUNT + 10),
|
|
44
|
+
y: new Float32Array(COUNT + 10)
|
|
45
|
+
};
|
|
46
|
+
const Velocity = {
|
|
47
|
+
vx: new Float32Array(COUNT + 10),
|
|
48
|
+
vy: new Float32Array(COUNT + 10)
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
for (let i = 0; i < COUNT; i++) {
|
|
52
|
+
const eid = addEntity(world);
|
|
53
|
+
addComponent(world, eid, Position);
|
|
54
|
+
addComponent(world, eid, Velocity);
|
|
55
|
+
Position.x[eid] = Math.random() * 100;
|
|
56
|
+
Position.y[eid] = Math.random() * 100;
|
|
57
|
+
Velocity.vx[eid] = Math.random() * 10;
|
|
58
|
+
Velocity.vy[eid] = Math.random() * 10;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const t0 = performance.now();
|
|
62
|
+
for (let f = 0; f < FRAMES; f++) {
|
|
63
|
+
const entities = query(world, [Position, Velocity]);
|
|
64
|
+
for (let i = 0; i < entities.length; i++) {
|
|
65
|
+
const eid = entities[i];
|
|
66
|
+
Position.x[eid] += Velocity.vx[eid];
|
|
67
|
+
Position.y[eid] += Velocity.vy[eid];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return (performance.now() - t0) / FRAMES;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Warmup
|
|
74
|
+
benchArchetypeTyped();
|
|
75
|
+
|
|
76
|
+
console.log(`\n=== 1M entities, ${FRAMES} frames: Position += Velocity ===\n`);
|
|
77
|
+
|
|
78
|
+
const archTyped = benchArchetypeTyped();
|
|
79
|
+
console.log(` arch+typed: ${archTyped.toFixed(3)} ms/frame`);
|
|
80
|
+
|
|
81
|
+
const bit = benchBitECS();
|
|
82
|
+
console.log(` bitECS: ${bit.toFixed(3)} ms/frame`);
|
|
83
|
+
|
|
84
|
+
const ratio = bit / archTyped;
|
|
85
|
+
console.log(`\n → arch+typed is ${ratio.toFixed(1)}x sneller`);
|
|
86
|
+
console.log();
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Benchmark: typed (SoA TypedArrays) vs untyped (object arrays) components
|
|
2
|
+
|
|
3
|
+
import { createEntityManager, component } from '../src/index.js';
|
|
4
|
+
|
|
5
|
+
const COUNT = 1_000_000;
|
|
6
|
+
const FRAMES = 200;
|
|
7
|
+
|
|
8
|
+
// --- Typed: creation ---
|
|
9
|
+
function benchTypedCreate() {
|
|
10
|
+
const Pos = component('TP', 'f32', ['x', 'y']);
|
|
11
|
+
const Vel = component('TV', 'f32', ['vx', 'vy']);
|
|
12
|
+
const em = createEntityManager();
|
|
13
|
+
|
|
14
|
+
const t0 = performance.now();
|
|
15
|
+
for (let i = 0; i < COUNT; i++) {
|
|
16
|
+
em.createEntityWith(Pos, { x: i, y: i }, Vel, { vx: 1, vy: 1 });
|
|
17
|
+
}
|
|
18
|
+
return { em, Pos, Vel, time: performance.now() - t0 };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// --- Untyped: creation ---
|
|
22
|
+
function benchUntypedCreate() {
|
|
23
|
+
const Pos = component('UP');
|
|
24
|
+
const Vel = component('UV');
|
|
25
|
+
const em = createEntityManager();
|
|
26
|
+
|
|
27
|
+
const t0 = performance.now();
|
|
28
|
+
for (let i = 0; i < COUNT; i++) {
|
|
29
|
+
em.createEntityWith(Pos, { x: i, y: i }, Vel, { vx: 1, vy: 1 });
|
|
30
|
+
}
|
|
31
|
+
return { em, Pos, Vel, time: performance.now() - t0 };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --- Typed: iteration with forEach + field() ---
|
|
35
|
+
function benchTypedIterate(em, Pos, Vel) {
|
|
36
|
+
const t0 = performance.now();
|
|
37
|
+
for (let f = 0; f < FRAMES; f++) {
|
|
38
|
+
em.forEach([Pos, Vel], (arch) => {
|
|
39
|
+
const px = arch.field(Pos.x);
|
|
40
|
+
const py = arch.field(Pos.y);
|
|
41
|
+
const vx = arch.field(Vel.vx);
|
|
42
|
+
const vy = arch.field(Vel.vy);
|
|
43
|
+
for (let i = 0; i < arch.count; i++) {
|
|
44
|
+
px[i] += vx[i];
|
|
45
|
+
py[i] += vy[i];
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return (performance.now() - t0) / FRAMES;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// --- Untyped: iteration with forEach (object access) ---
|
|
53
|
+
function benchUntypedIterate(em, Pos, Vel) {
|
|
54
|
+
const t0 = performance.now();
|
|
55
|
+
for (let f = 0; f < FRAMES; f++) {
|
|
56
|
+
const ids = em.query([Pos, Vel]);
|
|
57
|
+
for (let i = 0; i < ids.length; i++) {
|
|
58
|
+
const pos = em.getComponent(ids[i], Pos);
|
|
59
|
+
const vel = em.getComponent(ids[i], Vel);
|
|
60
|
+
pos.x += vel.vx;
|
|
61
|
+
pos.y += vel.vy;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return (performance.now() - t0) / FRAMES;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// --- String SoA: creation + access ---
|
|
68
|
+
function benchStringSoA() {
|
|
69
|
+
const Name = component('SSoA', { name: 'string', tag: 'string' });
|
|
70
|
+
const em = createEntityManager();
|
|
71
|
+
|
|
72
|
+
const t0 = performance.now();
|
|
73
|
+
for (let i = 0; i < COUNT; i++) {
|
|
74
|
+
em.createEntityWith(Name, { name: `entity_${i}`, tag: 'npc' });
|
|
75
|
+
}
|
|
76
|
+
const createTime = performance.now() - t0;
|
|
77
|
+
|
|
78
|
+
// forEach field access
|
|
79
|
+
const t1 = performance.now();
|
|
80
|
+
let count = 0;
|
|
81
|
+
for (let f = 0; f < 50; f++) {
|
|
82
|
+
em.forEach([Name], (arch) => {
|
|
83
|
+
const names = arch.field(Name.name);
|
|
84
|
+
for (let i = 0; i < arch.count; i++) {
|
|
85
|
+
if (names[i].length > 5) count++;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
const forEachTime = (performance.now() - t1) / 50;
|
|
90
|
+
|
|
91
|
+
// get() field access
|
|
92
|
+
const ids = em.query([Name]);
|
|
93
|
+
const t2 = performance.now();
|
|
94
|
+
let count2 = 0;
|
|
95
|
+
for (let f = 0; f < 50; f++) {
|
|
96
|
+
for (let i = 0; i < ids.length; i++) {
|
|
97
|
+
if (em.get(ids[i], Name.name).length > 5) count2++;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const getTime = (performance.now() - t2) / 50;
|
|
101
|
+
|
|
102
|
+
return { createTime, forEachTime, getTime, count, count2 };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- String untyped: creation + access ---
|
|
106
|
+
function benchStringUntyped() {
|
|
107
|
+
const Name = component('SUn');
|
|
108
|
+
const em = createEntityManager();
|
|
109
|
+
|
|
110
|
+
const t0 = performance.now();
|
|
111
|
+
for (let i = 0; i < COUNT; i++) {
|
|
112
|
+
em.createEntityWith(Name, { name: `entity_${i}`, tag: 'npc' });
|
|
113
|
+
}
|
|
114
|
+
const createTime = performance.now() - t0;
|
|
115
|
+
|
|
116
|
+
// getComponent access
|
|
117
|
+
const ids = em.query([Name]);
|
|
118
|
+
const t1 = performance.now();
|
|
119
|
+
let count = 0;
|
|
120
|
+
for (let f = 0; f < 50; f++) {
|
|
121
|
+
for (let i = 0; i < ids.length; i++) {
|
|
122
|
+
const n = em.getComponent(ids[i], Name);
|
|
123
|
+
if (n.name.length > 5) count++;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const accessTime = (performance.now() - t1) / 50;
|
|
127
|
+
|
|
128
|
+
return { createTime, accessTime, count };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- Run ---
|
|
132
|
+
console.log(`\n=== Typed vs Untyped: ${(COUNT / 1e6).toFixed(0)}M entities ===\n`);
|
|
133
|
+
|
|
134
|
+
// Warmup
|
|
135
|
+
benchTypedCreate();
|
|
136
|
+
benchUntypedCreate();
|
|
137
|
+
|
|
138
|
+
// Creation
|
|
139
|
+
const typed = benchTypedCreate();
|
|
140
|
+
const untyped = benchUntypedCreate();
|
|
141
|
+
|
|
142
|
+
console.log(`Creation (${(COUNT / 1e6).toFixed(0)}M entities, createEntityWith):`);
|
|
143
|
+
console.log(` typed: ${typed.time.toFixed(0)} ms`);
|
|
144
|
+
console.log(` untyped: ${untyped.time.toFixed(0)} ms`);
|
|
145
|
+
console.log(` ratio: ${(untyped.time / typed.time).toFixed(2)}x`);
|
|
146
|
+
|
|
147
|
+
// Iteration
|
|
148
|
+
const typedIter = benchTypedIterate(typed.em, typed.Pos, typed.Vel);
|
|
149
|
+
const untypedIter = benchUntypedIterate(untyped.em, untyped.Pos, untyped.Vel);
|
|
150
|
+
|
|
151
|
+
console.log(`\nIteration (${FRAMES} frames, Position += Velocity):`);
|
|
152
|
+
console.log(` typed (forEach+field): ${typedIter.toFixed(2)} ms/frame`);
|
|
153
|
+
console.log(` untyped (query+getComponent): ${untypedIter.toFixed(2)} ms/frame`);
|
|
154
|
+
console.log(` ratio: ${(untypedIter / typedIter).toFixed(1)}x slower`);
|
|
155
|
+
|
|
156
|
+
// String components
|
|
157
|
+
console.log(`\nString component (${(COUNT / 1e6).toFixed(0)}M entities, { name, tag }):`);
|
|
158
|
+
const strSoA = benchStringSoA();
|
|
159
|
+
const strUn = benchStringUntyped();
|
|
160
|
+
console.log(` SoA create: ${strSoA.createTime.toFixed(0)} ms`);
|
|
161
|
+
console.log(` untyped create: ${strUn.createTime.toFixed(0)} ms`);
|
|
162
|
+
console.log(` SoA forEach(field): ${strSoA.forEachTime.toFixed(1)} ms/frame`);
|
|
163
|
+
console.log(` SoA get(): ${strSoA.getTime.toFixed(1)} ms/frame`);
|
|
164
|
+
console.log(` untyped getComponent(): ${strUn.accessTime.toFixed(1)} ms/frame`);
|
|
165
|
+
console.log(` forEach vs getComponent: ${(strUn.accessTime / strSoA.forEachTime).toFixed(1)}x faster`);
|
|
166
|
+
console.log();
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// Benchmark: archetype-ecs (current) vs archetype+TypedArrays (hypothetical) vs bitECS
|
|
2
|
+
// Tests: system loop, entity creation, component add/remove churn
|
|
3
|
+
|
|
4
|
+
import { createEntityManager } from '../src/EntityManager.js';
|
|
5
|
+
import {
|
|
6
|
+
createWorld, addEntity,
|
|
7
|
+
addComponent, removeComponent,
|
|
8
|
+
query
|
|
9
|
+
} from 'bitecs';
|
|
10
|
+
|
|
11
|
+
const ENTITY_COUNTS = [100, 1_000, 10_000, 100_000];
|
|
12
|
+
const FRAMES = 500;
|
|
13
|
+
const MAX_ENTITIES = 110_000;
|
|
14
|
+
|
|
15
|
+
const pad = (s, n) => String(s).padStart(n);
|
|
16
|
+
|
|
17
|
+
// =========================================================================
|
|
18
|
+
// System loop: Position += Velocity
|
|
19
|
+
// =========================================================================
|
|
20
|
+
|
|
21
|
+
function benchArchetypeLoop(entityCount) {
|
|
22
|
+
const em = createEntityManager();
|
|
23
|
+
const Position = Symbol('Position');
|
|
24
|
+
const Velocity = Symbol('Velocity');
|
|
25
|
+
|
|
26
|
+
for (let i = 0; i < entityCount; i++) {
|
|
27
|
+
const id = em.createEntity();
|
|
28
|
+
em.addComponent(id, Position, { x: Math.random() * 100, y: Math.random() * 100 });
|
|
29
|
+
em.addComponent(id, Velocity, { vx: Math.random() * 10, vy: Math.random() * 10 });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const t0 = performance.now();
|
|
33
|
+
for (let f = 0; f < FRAMES; f++) {
|
|
34
|
+
const entities = em.query([Position, Velocity]);
|
|
35
|
+
for (let i = 0; i < entities.length; i++) {
|
|
36
|
+
const pos = em.getComponent(entities[i], Position);
|
|
37
|
+
const vel = em.getComponent(entities[i], Velocity);
|
|
38
|
+
pos.x += vel.vx;
|
|
39
|
+
pos.y += vel.vy;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return (performance.now() - t0) / FRAMES;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Simulates archetype-ecs with TypedArray backing:
|
|
46
|
+
// Dense arrays per archetype, but Float32Arrays instead of object arrays
|
|
47
|
+
function benchArchetypeTypedLoop(entityCount) {
|
|
48
|
+
// One archetype with all entities (they all have Position+Velocity)
|
|
49
|
+
const count = entityCount;
|
|
50
|
+
const px = new Float32Array(count);
|
|
51
|
+
const py = new Float32Array(count);
|
|
52
|
+
const vx = new Float32Array(count);
|
|
53
|
+
const vy = new Float32Array(count);
|
|
54
|
+
for (let i = 0; i < count; i++) {
|
|
55
|
+
px[i] = Math.random() * 100;
|
|
56
|
+
py[i] = Math.random() * 100;
|
|
57
|
+
vx[i] = Math.random() * 10;
|
|
58
|
+
vy[i] = Math.random() * 10;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const t0 = performance.now();
|
|
62
|
+
for (let f = 0; f < FRAMES; f++) {
|
|
63
|
+
// Dense iteration — no entity ID lookup, no getComponent, no sparse gaps
|
|
64
|
+
for (let i = 0; i < count; i++) {
|
|
65
|
+
px[i] += vx[i];
|
|
66
|
+
py[i] += vy[i];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return (performance.now() - t0) / FRAMES;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function benchBitECSLoop(entityCount) {
|
|
73
|
+
const world = createWorld();
|
|
74
|
+
const Position = {
|
|
75
|
+
x: new Float32Array(MAX_ENTITIES),
|
|
76
|
+
y: new Float32Array(MAX_ENTITIES)
|
|
77
|
+
};
|
|
78
|
+
const Velocity = {
|
|
79
|
+
vx: new Float32Array(MAX_ENTITIES),
|
|
80
|
+
vy: new Float32Array(MAX_ENTITIES)
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
for (let i = 0; i < entityCount; i++) {
|
|
84
|
+
const eid = addEntity(world);
|
|
85
|
+
addComponent(world, eid, Position);
|
|
86
|
+
addComponent(world, eid, Velocity);
|
|
87
|
+
Position.x[eid] = Math.random() * 100;
|
|
88
|
+
Position.y[eid] = Math.random() * 100;
|
|
89
|
+
Velocity.vx[eid] = Math.random() * 10;
|
|
90
|
+
Velocity.vy[eid] = Math.random() * 10;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const t0 = performance.now();
|
|
94
|
+
for (let f = 0; f < FRAMES; f++) {
|
|
95
|
+
const entities = query(world, [Position, Velocity]);
|
|
96
|
+
for (let i = 0; i < entities.length; i++) {
|
|
97
|
+
const eid = entities[i];
|
|
98
|
+
Position.x[eid] += Velocity.vx[eid];
|
|
99
|
+
Position.y[eid] += Velocity.vy[eid];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return (performance.now() - t0) / FRAMES;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// =========================================================================
|
|
106
|
+
// Entity creation with 2 components
|
|
107
|
+
// =========================================================================
|
|
108
|
+
|
|
109
|
+
function benchArchetypeCreate(entityCount) {
|
|
110
|
+
const em = createEntityManager();
|
|
111
|
+
const Position = Symbol('Position');
|
|
112
|
+
const Velocity = Symbol('Velocity');
|
|
113
|
+
|
|
114
|
+
const t0 = performance.now();
|
|
115
|
+
for (let i = 0; i < entityCount; i++) {
|
|
116
|
+
const id = em.createEntity();
|
|
117
|
+
em.addComponent(id, Position, { x: i, y: i });
|
|
118
|
+
em.addComponent(id, Velocity, { vx: 1, vy: 1 });
|
|
119
|
+
}
|
|
120
|
+
return performance.now() - t0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function benchBitECSCreate(entityCount) {
|
|
124
|
+
const world = createWorld();
|
|
125
|
+
const Position = {
|
|
126
|
+
x: new Float32Array(entityCount + 10),
|
|
127
|
+
y: new Float32Array(entityCount + 10)
|
|
128
|
+
};
|
|
129
|
+
const Velocity = {
|
|
130
|
+
vx: new Float32Array(entityCount + 10),
|
|
131
|
+
vy: new Float32Array(entityCount + 10)
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const t0 = performance.now();
|
|
135
|
+
for (let i = 0; i < entityCount; i++) {
|
|
136
|
+
const eid = addEntity(world);
|
|
137
|
+
addComponent(world, eid, Position);
|
|
138
|
+
addComponent(world, eid, Velocity);
|
|
139
|
+
Position.x[eid] = i;
|
|
140
|
+
Position.y[eid] = i;
|
|
141
|
+
Velocity.vx[eid] = 1;
|
|
142
|
+
Velocity.vy[eid] = 1;
|
|
143
|
+
}
|
|
144
|
+
return performance.now() - t0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// =========================================================================
|
|
148
|
+
// Component add/remove churn
|
|
149
|
+
// =========================================================================
|
|
150
|
+
|
|
151
|
+
function benchArchetypeChurn(entityCount) {
|
|
152
|
+
const em = createEntityManager();
|
|
153
|
+
const Position = Symbol('Position');
|
|
154
|
+
const Health = Symbol('Health');
|
|
155
|
+
|
|
156
|
+
const ids = [];
|
|
157
|
+
for (let i = 0; i < entityCount; i++) {
|
|
158
|
+
const id = em.createEntity();
|
|
159
|
+
em.addComponent(id, Position, { x: 0, y: 0 });
|
|
160
|
+
ids.push(id);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const ops = Math.min(entityCount, 10_000);
|
|
164
|
+
const t0 = performance.now();
|
|
165
|
+
for (let i = 0; i < ops; i++) {
|
|
166
|
+
em.addComponent(ids[i], Health, { hp: 100 });
|
|
167
|
+
}
|
|
168
|
+
for (let i = 0; i < ops; i++) {
|
|
169
|
+
em.removeComponent(ids[i], Health);
|
|
170
|
+
}
|
|
171
|
+
return { ms: performance.now() - t0, ops: ops * 2 };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function benchBitECSChurn(entityCount) {
|
|
175
|
+
const world = createWorld();
|
|
176
|
+
const Position = {
|
|
177
|
+
x: new Float32Array(entityCount + 10),
|
|
178
|
+
y: new Float32Array(entityCount + 10)
|
|
179
|
+
};
|
|
180
|
+
const Health = {
|
|
181
|
+
hp: new Float32Array(entityCount + 10)
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const eids = [];
|
|
185
|
+
for (let i = 0; i < entityCount; i++) {
|
|
186
|
+
const eid = addEntity(world);
|
|
187
|
+
addComponent(world, eid, Position);
|
|
188
|
+
Position.x[eid] = 0;
|
|
189
|
+
Position.y[eid] = 0;
|
|
190
|
+
eids.push(eid);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const ops = Math.min(entityCount, 10_000);
|
|
194
|
+
const t0 = performance.now();
|
|
195
|
+
for (let i = 0; i < ops; i++) {
|
|
196
|
+
addComponent(world, eids[i], Health);
|
|
197
|
+
Health.hp[eids[i]] = 100;
|
|
198
|
+
}
|
|
199
|
+
for (let i = 0; i < ops; i++) {
|
|
200
|
+
removeComponent(world, eids[i], Health);
|
|
201
|
+
}
|
|
202
|
+
return { ms: performance.now() - t0, ops: ops * 2 };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// =========================================================================
|
|
206
|
+
// RUN
|
|
207
|
+
// =========================================================================
|
|
208
|
+
|
|
209
|
+
// Warmup
|
|
210
|
+
benchArchetypeLoop(100);
|
|
211
|
+
benchArchetypeTypedLoop(100);
|
|
212
|
+
benchBitECSLoop(100);
|
|
213
|
+
|
|
214
|
+
console.log(`\n=== System loop: Position += Velocity (${FRAMES} frames, per-frame time) ===\n`);
|
|
215
|
+
console.log('Entities | arch (now) | arch+typed | bitECS | arch+typed vs bitECS');
|
|
216
|
+
console.log('------------|---------------|--------------|--------------|---------------------');
|
|
217
|
+
|
|
218
|
+
for (const count of ENTITY_COUNTS) {
|
|
219
|
+
const arch = benchArchetypeLoop(count);
|
|
220
|
+
const archTyped = benchArchetypeTypedLoop(count);
|
|
221
|
+
const bit = benchBitECSLoop(count);
|
|
222
|
+
const vsNow = arch / archTyped;
|
|
223
|
+
const vsBit = bit / archTyped;
|
|
224
|
+
console.log(
|
|
225
|
+
`${pad(count.toLocaleString(), 11)} | ` +
|
|
226
|
+
`${pad(arch.toFixed(3), 10)} ms | ` +
|
|
227
|
+
`${pad(archTyped.toFixed(3), 9)} ms | ` +
|
|
228
|
+
`${pad(bit.toFixed(3), 9)} ms | ` +
|
|
229
|
+
`${vsBit > 1.1 ? `arch+typed ${vsBit.toFixed(1)}x sneller` : vsBit < 0.9 ? `bitECS ${(1/vsBit).toFixed(1)}x sneller` : '~gelijk'}`
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
console.log(`\n=== Entity creation (with 2 components) ===\n`);
|
|
234
|
+
console.log('Entities | archetype-ecs | bitECS | Δ');
|
|
235
|
+
console.log('------------|---------------|--------------|-------');
|
|
236
|
+
|
|
237
|
+
for (const count of ENTITY_COUNTS) {
|
|
238
|
+
const arch = benchArchetypeCreate(count);
|
|
239
|
+
const bit = benchBitECSCreate(count);
|
|
240
|
+
const ratio = arch / bit;
|
|
241
|
+
const winner = ratio > 1.1 ? `bitECS ${ratio.toFixed(1)}x` : ratio < 0.9 ? `arch ${(1/ratio).toFixed(1)}x` : '~gelijk';
|
|
242
|
+
console.log(
|
|
243
|
+
`${pad(count.toLocaleString(), 11)} | ` +
|
|
244
|
+
`${pad(arch.toFixed(1), 10)} ms | ` +
|
|
245
|
+
`${pad(bit.toFixed(1), 9)} ms | ` +
|
|
246
|
+
`${winner}`
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
console.log(`\n=== Component add/remove churn (10k ops) ===\n`);
|
|
251
|
+
console.log('Entities | archetype-ecs | bitECS | Δ');
|
|
252
|
+
console.log('------------|---------------|--------------|-------');
|
|
253
|
+
|
|
254
|
+
for (const count of [1_000, 10_000, 100_000]) {
|
|
255
|
+
const arch = benchArchetypeChurn(count);
|
|
256
|
+
const bit = benchBitECSChurn(count);
|
|
257
|
+
const ratio = arch.ms / bit.ms;
|
|
258
|
+
const winner = ratio > 1.1 ? `bitECS ${ratio.toFixed(1)}x` : ratio < 0.9 ? `arch ${(1/ratio).toFixed(1)}x` : '~gelijk';
|
|
259
|
+
console.log(
|
|
260
|
+
`${pad(count.toLocaleString(), 11)} | ` +
|
|
261
|
+
`${pad(arch.ms.toFixed(1), 10)} ms | ` +
|
|
262
|
+
`${pad(bit.ms.toFixed(1), 9)} ms | ` +
|
|
263
|
+
`${winner}`
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
console.log();
|
package/package.json
CHANGED
|
@@ -1,16 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "archetype-ecs",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Lightweight archetype-based Entity Component System",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"module": "src/index.js",
|
|
8
|
+
"types": "src/index.d.ts",
|
|
8
9
|
"exports": {
|
|
9
|
-
".":
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./src/index.d.ts",
|
|
12
|
+
"default": "./src/index.js"
|
|
13
|
+
}
|
|
10
14
|
},
|
|
11
15
|
"scripts": {
|
|
12
|
-
"test": "node --test tests/"
|
|
16
|
+
"test": "node --test tests/ && npx tsc --noEmit",
|
|
17
|
+
"bench": "node --expose-gc bench/multi-ecs-bench.js"
|
|
13
18
|
},
|
|
14
|
-
"keywords": [
|
|
15
|
-
|
|
19
|
+
"keywords": [
|
|
20
|
+
"ecs",
|
|
21
|
+
"entity",
|
|
22
|
+
"component",
|
|
23
|
+
"system",
|
|
24
|
+
"archetype",
|
|
25
|
+
"gamedev"
|
|
26
|
+
],
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"bitecs": "^0.4.0",
|
|
30
|
+
"harmony-ecs": "^0.0.12",
|
|
31
|
+
"miniplex": "^2.0.0",
|
|
32
|
+
"wolf-ecs": "^2.0.0"
|
|
33
|
+
}
|
|
16
34
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export const TYPED = Symbol('typed');
|
|
2
|
+
|
|
3
|
+
export const TYPE_MAP = {
|
|
4
|
+
'f32': Float32Array,
|
|
5
|
+
'f64': Float64Array,
|
|
6
|
+
'i8': Int8Array,
|
|
7
|
+
'i16': Int16Array,
|
|
8
|
+
'i32': Int32Array,
|
|
9
|
+
'u8': Uint8Array,
|
|
10
|
+
'u16': Uint16Array,
|
|
11
|
+
'u32': Uint32Array,
|
|
12
|
+
'string': Array,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** @type {Map<symbol, Record<string, typeof Float32Array>>} */
|
|
16
|
+
export const componentSchemas = new Map();
|
|
17
|
+
|
|
18
|
+
/** Extract the underlying symbol from a component object or pass through a plain symbol */
|
|
19
|
+
export function toSym(type) {
|
|
20
|
+
return type._sym || type;
|
|
21
|
+
}
|