archetype-ecs 1.0.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 +20 -6
- 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 +342 -77
- 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 -38
- package/tests/EventBus.test.js +0 -41
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
// Multi-ECS Benchmark: archetype-ecs vs bitECS vs wolf-ecs vs harmony-ecs vs miniplex
|
|
2
|
+
// Tests: iteration (Position += Velocity), entity creation, memory usage
|
|
3
|
+
// Run with: node --expose-gc bench/multi-ecs-bench.js
|
|
4
|
+
|
|
5
|
+
import { createEntityManager, component } from '../src/index.js';
|
|
6
|
+
|
|
7
|
+
const COUNT = 1_000_000;
|
|
8
|
+
const FRAMES = 500;
|
|
9
|
+
const RUNS = 5;
|
|
10
|
+
|
|
11
|
+
// ── Utilities ────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
const median = (arr) => {
|
|
14
|
+
const s = [...arr].sort((a, b) => a - b);
|
|
15
|
+
const mid = s.length >> 1;
|
|
16
|
+
return s.length & 1 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const memMB = () => {
|
|
20
|
+
if (globalThis.gc) globalThis.gc();
|
|
21
|
+
return process.memoryUsage().heapUsed / 1024 / 1024;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const pad = (s, n) => String(s).padStart(n);
|
|
25
|
+
const padEnd = (s, n) => String(s).padEnd(n);
|
|
26
|
+
|
|
27
|
+
// ── Safe import ──────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
async function tryImport(specifier) {
|
|
30
|
+
try {
|
|
31
|
+
return await import(specifier);
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── Library adapters ─────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
function adapterArchetypeECS() {
|
|
40
|
+
return {
|
|
41
|
+
name: 'archetype-ecs',
|
|
42
|
+
setup() {
|
|
43
|
+
const Position = component('BPos', { x: 'f32', y: 'f32' });
|
|
44
|
+
const Velocity = component('BVel', { vx: 'f32', vy: 'f32' });
|
|
45
|
+
const em = createEntityManager();
|
|
46
|
+
return { em, Position, Velocity };
|
|
47
|
+
},
|
|
48
|
+
createEntities(ctx, count) {
|
|
49
|
+
const { em, Position, Velocity } = ctx;
|
|
50
|
+
for (let i = 0; i < count; i++) {
|
|
51
|
+
em.createEntityWith(
|
|
52
|
+
Position, { x: i * 0.1, y: i * 0.1 },
|
|
53
|
+
Velocity, { vx: 1, vy: 1 },
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
iterateFrame(ctx) {
|
|
58
|
+
const { em, Position, Velocity } = ctx;
|
|
59
|
+
em.forEach([Position, Velocity], (arch) => {
|
|
60
|
+
const px = arch.field(Position.x);
|
|
61
|
+
const py = arch.field(Position.y);
|
|
62
|
+
const vx = arch.field(Velocity.vx);
|
|
63
|
+
const vy = arch.field(Velocity.vy);
|
|
64
|
+
for (let i = 0; i < arch.count; i++) {
|
|
65
|
+
px[i] += vx[i];
|
|
66
|
+
py[i] += vy[i];
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function adapterBitECS() {
|
|
74
|
+
const mod = await tryImport('bitecs');
|
|
75
|
+
if (!mod) return null;
|
|
76
|
+
const { createWorld, addEntity, addComponent, query } = mod;
|
|
77
|
+
return {
|
|
78
|
+
name: 'bitecs',
|
|
79
|
+
setup() {
|
|
80
|
+
const world = createWorld();
|
|
81
|
+
const Position = {
|
|
82
|
+
x: new Float32Array(COUNT + 10),
|
|
83
|
+
y: new Float32Array(COUNT + 10),
|
|
84
|
+
};
|
|
85
|
+
const Velocity = {
|
|
86
|
+
vx: new Float32Array(COUNT + 10),
|
|
87
|
+
vy: new Float32Array(COUNT + 10),
|
|
88
|
+
};
|
|
89
|
+
return { world, Position, Velocity };
|
|
90
|
+
},
|
|
91
|
+
createEntities(ctx, count) {
|
|
92
|
+
const { world, Position, Velocity } = ctx;
|
|
93
|
+
for (let i = 0; i < count; i++) {
|
|
94
|
+
const eid = addEntity(world);
|
|
95
|
+
addComponent(world, eid, Position);
|
|
96
|
+
addComponent(world, eid, Velocity);
|
|
97
|
+
Position.x[eid] = i * 0.1;
|
|
98
|
+
Position.y[eid] = i * 0.1;
|
|
99
|
+
Velocity.vx[eid] = 1;
|
|
100
|
+
Velocity.vy[eid] = 1;
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
iterateFrame(ctx) {
|
|
104
|
+
const { world, Position, Velocity } = ctx;
|
|
105
|
+
const entities = query(world, [Position, Velocity]);
|
|
106
|
+
for (let i = 0; i < entities.length; i++) {
|
|
107
|
+
const eid = entities[i];
|
|
108
|
+
Position.x[eid] += Velocity.vx[eid];
|
|
109
|
+
Position.y[eid] += Velocity.vy[eid];
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function adapterWolfECS() {
|
|
116
|
+
const mod = await tryImport('wolf-ecs');
|
|
117
|
+
if (!mod) return null;
|
|
118
|
+
const { ECS, types } = mod;
|
|
119
|
+
return {
|
|
120
|
+
name: 'wolf-ecs',
|
|
121
|
+
setup() {
|
|
122
|
+
const ecs = new ECS(COUNT + 10);
|
|
123
|
+
const Position = ecs.defineComponent({ x: types.f32, y: types.f32 });
|
|
124
|
+
const Velocity = ecs.defineComponent({ x: types.f32, y: types.f32 });
|
|
125
|
+
const q = ecs.createQuery(Position, Velocity);
|
|
126
|
+
return { ecs, Position, Velocity, q };
|
|
127
|
+
},
|
|
128
|
+
createEntities(ctx, count) {
|
|
129
|
+
const { ecs, Position, Velocity } = ctx;
|
|
130
|
+
for (let i = 0; i < count; i++) {
|
|
131
|
+
const e = ecs.createEntity();
|
|
132
|
+
ecs.addComponent(e, Position);
|
|
133
|
+
ecs.addComponent(e, Velocity);
|
|
134
|
+
Position.x[e] = i * 0.1;
|
|
135
|
+
Position.y[e] = i * 0.1;
|
|
136
|
+
Velocity.x[e] = 1;
|
|
137
|
+
Velocity.y[e] = 1;
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
iterateFrame(ctx) {
|
|
141
|
+
const { Position, Velocity, q } = ctx;
|
|
142
|
+
for (let i = 0; i < q.a.length; i++) {
|
|
143
|
+
const arch = q.a[i].e;
|
|
144
|
+
for (let j = 0; j < arch.length; j++) {
|
|
145
|
+
const id = arch[j];
|
|
146
|
+
Position.x[id] += Velocity.x[id];
|
|
147
|
+
Position.y[id] += Velocity.y[id];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function adapterHarmonyECS() {
|
|
155
|
+
const mod = await tryImport('harmony-ecs');
|
|
156
|
+
if (!mod) return null;
|
|
157
|
+
const { World, Schema, Entity, Query, Format } = mod;
|
|
158
|
+
return {
|
|
159
|
+
name: 'harmony-ecs',
|
|
160
|
+
setup() {
|
|
161
|
+
const world = World.make(COUNT + 10);
|
|
162
|
+
const Position = Schema.makeBinary(world, { x: Format.float32, y: Format.float32 });
|
|
163
|
+
const Velocity = Schema.makeBinary(world, { x: Format.float32, y: Format.float32 });
|
|
164
|
+
const layout = [Position, Velocity];
|
|
165
|
+
const q = Query.make(world, layout);
|
|
166
|
+
return { world, Position, Velocity, layout, q };
|
|
167
|
+
},
|
|
168
|
+
createEntities(ctx, count) {
|
|
169
|
+
const { world, layout } = ctx;
|
|
170
|
+
for (let i = 0; i < count; i++) {
|
|
171
|
+
Entity.make(world, layout);
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
iterateFrame(ctx) {
|
|
175
|
+
const { q } = ctx;
|
|
176
|
+
for (const [entities, [p, v]] of q) {
|
|
177
|
+
for (let i = 0; i < entities.length; i++) {
|
|
178
|
+
p.x[i] += v.x[i];
|
|
179
|
+
p.y[i] += v.y[i];
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function adapterMiniplex() {
|
|
187
|
+
let mod = await tryImport('miniplex');
|
|
188
|
+
if (!mod) return null;
|
|
189
|
+
const World = mod.World || mod.default?.World;
|
|
190
|
+
if (!World) return null;
|
|
191
|
+
return {
|
|
192
|
+
name: 'miniplex',
|
|
193
|
+
setup() {
|
|
194
|
+
const world = new World();
|
|
195
|
+
const q = world.with('position', 'velocity');
|
|
196
|
+
return { world, q };
|
|
197
|
+
},
|
|
198
|
+
createEntities(ctx, count) {
|
|
199
|
+
const { world } = ctx;
|
|
200
|
+
for (let i = 0; i < count; i++) {
|
|
201
|
+
world.add({
|
|
202
|
+
position: { x: i * 0.1, y: i * 0.1 },
|
|
203
|
+
velocity: { x: 1, y: 1 },
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
iterateFrame(ctx) {
|
|
208
|
+
const { q } = ctx;
|
|
209
|
+
for (const { position, velocity } of q) {
|
|
210
|
+
position.x += velocity.x;
|
|
211
|
+
position.y += velocity.y;
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── Benchmark runner ─────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
function benchIteration(adapter) {
|
|
220
|
+
const ctx = adapter.setup();
|
|
221
|
+
adapter.createEntities(ctx, COUNT);
|
|
222
|
+
// Warmup
|
|
223
|
+
for (let f = 0; f < 5; f++) adapter.iterateFrame(ctx);
|
|
224
|
+
|
|
225
|
+
const t0 = performance.now();
|
|
226
|
+
for (let f = 0; f < FRAMES; f++) {
|
|
227
|
+
adapter.iterateFrame(ctx);
|
|
228
|
+
}
|
|
229
|
+
return (performance.now() - t0) / FRAMES;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function benchCreation(adapter) {
|
|
233
|
+
const ctx = adapter.setup();
|
|
234
|
+
const t0 = performance.now();
|
|
235
|
+
adapter.createEntities(ctx, COUNT);
|
|
236
|
+
return performance.now() - t0;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function benchMemory(adapter) {
|
|
240
|
+
try {
|
|
241
|
+
const before = memMB();
|
|
242
|
+
const ctx = adapter.setup();
|
|
243
|
+
adapter.createEntities(ctx, COUNT);
|
|
244
|
+
const after = memMB();
|
|
245
|
+
return after - before;
|
|
246
|
+
} catch {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function runMultiple(fn, adapter, runs) {
|
|
252
|
+
const results = [];
|
|
253
|
+
for (let r = 0; r < runs; r++) {
|
|
254
|
+
try {
|
|
255
|
+
results.push(fn(adapter));
|
|
256
|
+
} catch {
|
|
257
|
+
// Some libraries (e.g. harmony-ecs) have global state that breaks on re-creation
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return results.length > 0 ? median(results) : null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── Output formatting ────────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
function printTable(title, unit, results, baselineName, { moreLabel = 'slower', lessLabel = 'faster' } = {}) {
|
|
267
|
+
console.log(`\n--- ${title} ---\n`);
|
|
268
|
+
|
|
269
|
+
const baseline = results.find(r => r.name === baselineName)?.value;
|
|
270
|
+
const nameWidth = 20;
|
|
271
|
+
const valueWidth = 12;
|
|
272
|
+
|
|
273
|
+
console.log(` ${padEnd('Library', nameWidth)} ${pad(unit, valueWidth)} vs ${baselineName}`);
|
|
274
|
+
console.log(` ${'─'.repeat(nameWidth + valueWidth + 25)}`);
|
|
275
|
+
|
|
276
|
+
for (const { name, value } of results) {
|
|
277
|
+
const valueStr = pad(value.toFixed(1), valueWidth);
|
|
278
|
+
let comparison;
|
|
279
|
+
if (name === baselineName) {
|
|
280
|
+
comparison = 'baseline';
|
|
281
|
+
} else if (baseline != null && baseline > 0) {
|
|
282
|
+
const ratio = value / baseline;
|
|
283
|
+
if (ratio > 1.05) {
|
|
284
|
+
comparison = `${ratio.toFixed(1)}x ${moreLabel}`;
|
|
285
|
+
} else if (ratio < 0.95) {
|
|
286
|
+
comparison = `${(1 / ratio).toFixed(1)}x ${lessLabel}`;
|
|
287
|
+
} else {
|
|
288
|
+
comparison = '~same';
|
|
289
|
+
}
|
|
290
|
+
} else {
|
|
291
|
+
comparison = '';
|
|
292
|
+
}
|
|
293
|
+
console.log(` ${padEnd(name, nameWidth)} ${valueStr} ${comparison}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
async function main() {
|
|
300
|
+
if (!globalThis.gc) {
|
|
301
|
+
console.log('⚠ Run with --expose-gc for accurate memory measurements');
|
|
302
|
+
console.log(' node --expose-gc bench/multi-ecs-bench.js\n');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
console.log(`=== Multi-ECS Benchmark: ${(COUNT / 1e6).toFixed(0)}M entities ===`);
|
|
306
|
+
console.log(` ${RUNS} runs, median | node --expose-gc bench/multi-ecs-bench.js\n`);
|
|
307
|
+
|
|
308
|
+
// Resolve adapters
|
|
309
|
+
const adapterFactories = [
|
|
310
|
+
adapterArchetypeECS,
|
|
311
|
+
adapterBitECS,
|
|
312
|
+
adapterWolfECS,
|
|
313
|
+
adapterHarmonyECS,
|
|
314
|
+
adapterMiniplex,
|
|
315
|
+
];
|
|
316
|
+
|
|
317
|
+
const adapters = [];
|
|
318
|
+
for (const factory of adapterFactories) {
|
|
319
|
+
try {
|
|
320
|
+
const adapter = await (typeof factory === 'function' && factory.constructor.name === 'AsyncFunction'
|
|
321
|
+
? factory()
|
|
322
|
+
: factory());
|
|
323
|
+
if (adapter) {
|
|
324
|
+
adapters.push(adapter);
|
|
325
|
+
} else {
|
|
326
|
+
const name = factory.name.replace('adapter', '').replace(/([A-Z])/g, ' $1').trim();
|
|
327
|
+
console.log(` [skip] ${name} — not installed`);
|
|
328
|
+
}
|
|
329
|
+
} catch (e) {
|
|
330
|
+
const name = factory.name.replace('adapter', '').replace(/([A-Z])/g, ' $1').trim();
|
|
331
|
+
console.log(` [skip] ${name} — ${e.message}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (adapters.length === 0) {
|
|
336
|
+
console.log('\nNo libraries available. Install dependencies and try again.');
|
|
337
|
+
process.exit(1);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
console.log(`\n Running benchmarks for: ${adapters.map(a => a.name).join(', ')}\n`);
|
|
341
|
+
|
|
342
|
+
// ── Iteration benchmark ──────────────────────────────────────────────────
|
|
343
|
+
const iterResults = [];
|
|
344
|
+
for (const adapter of adapters) {
|
|
345
|
+
process.stdout.write(` Benchmarking iteration: ${adapter.name}...`);
|
|
346
|
+
const ms = runMultiple(benchIteration, adapter, RUNS);
|
|
347
|
+
if (ms != null) {
|
|
348
|
+
iterResults.push({ name: adapter.name, value: ms });
|
|
349
|
+
process.stdout.write(` ${ms.toFixed(2)} ms/frame\n`);
|
|
350
|
+
} else {
|
|
351
|
+
process.stdout.write(` failed\n`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
iterResults.sort((a, b) => a.value - b.value);
|
|
355
|
+
printTable(`Iteration (${FRAMES} frames, ${(COUNT / 1e6).toFixed(0)}M entities) — ms/frame`, 'ms/frame', iterResults, 'archetype-ecs');
|
|
356
|
+
|
|
357
|
+
// ── Creation benchmark ───────────────────────────────────────────────────
|
|
358
|
+
const createResults = [];
|
|
359
|
+
for (const adapter of adapters) {
|
|
360
|
+
process.stdout.write(` Benchmarking creation: ${adapter.name}...`);
|
|
361
|
+
const ms = runMultiple(benchCreation, adapter, RUNS);
|
|
362
|
+
if (ms != null) {
|
|
363
|
+
createResults.push({ name: adapter.name, value: ms });
|
|
364
|
+
process.stdout.write(` ${ms.toFixed(1)} ms\n`);
|
|
365
|
+
} else {
|
|
366
|
+
process.stdout.write(` failed\n`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
createResults.sort((a, b) => a.value - b.value);
|
|
370
|
+
printTable(`Entity creation (${(COUNT / 1e6).toFixed(0)}M with Position + Velocity) — ms total`, 'ms', createResults, 'archetype-ecs');
|
|
371
|
+
|
|
372
|
+
// ── Memory benchmark ─────────────────────────────────────────────────────
|
|
373
|
+
const memResults = [];
|
|
374
|
+
for (const adapter of adapters) {
|
|
375
|
+
process.stdout.write(` Benchmarking memory: ${adapter.name}...`);
|
|
376
|
+
const mb = benchMemory(adapter);
|
|
377
|
+
if (mb != null) {
|
|
378
|
+
memResults.push({ name: adapter.name, value: mb });
|
|
379
|
+
process.stdout.write(` ${mb.toFixed(1)} MB\n`);
|
|
380
|
+
} else {
|
|
381
|
+
process.stdout.write(` failed\n`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
memResults.sort((a, b) => a.value - b.value);
|
|
385
|
+
printTable(`Memory (heap delta, ${(COUNT / 1e6).toFixed(0)}M entities) — MB`, 'MB', memResults, 'archetype-ecs', { moreLabel: 'more', lessLabel: 'less' });
|
|
386
|
+
|
|
387
|
+
console.log();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
main().catch(e => {
|
|
391
|
+
console.error(e);
|
|
392
|
+
process.exit(1);
|
|
393
|
+
});
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// Benchmark: TypedArray SoA vs plain object arrays
|
|
2
|
+
// Tests performance + allocation / GC pressure
|
|
3
|
+
// Run with: node --expose-gc bench/typed-array-vs-objects.js
|
|
4
|
+
|
|
5
|
+
const hasGC = typeof globalThis.gc === 'function';
|
|
6
|
+
if (!hasGC) {
|
|
7
|
+
console.log('WARNING: Run with --expose-gc for accurate allocation measurements\n');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const ENTITY_COUNTS = [100, 1_000, 10_000, 100_000, 1_000_000];
|
|
11
|
+
const ITERATIONS = 1_000;
|
|
12
|
+
|
|
13
|
+
function getHeapUsed() {
|
|
14
|
+
if (hasGC) globalThis.gc();
|
|
15
|
+
return process.memoryUsage().heapUsed;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// =========================================================================
|
|
19
|
+
// PERFORMANCE BENCHMARKS
|
|
20
|
+
// =========================================================================
|
|
21
|
+
|
|
22
|
+
function getIters() {
|
|
23
|
+
return ITERATIONS;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function benchObjectArrays(entityCount) {
|
|
27
|
+
const iters = getIters(entityCount);
|
|
28
|
+
const positions = [];
|
|
29
|
+
const velocities = [];
|
|
30
|
+
for (let i = 0; i < entityCount; i++) {
|
|
31
|
+
positions.push({ x: Math.random() * 100, y: Math.random() * 100 });
|
|
32
|
+
velocities.push({ vx: Math.random() * 10, vy: Math.random() * 10 });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const t0 = performance.now();
|
|
36
|
+
for (let iter = 0; iter < iters; iter++) {
|
|
37
|
+
for (let i = 0; i < entityCount; i++) {
|
|
38
|
+
positions[i].x += velocities[i].vx;
|
|
39
|
+
positions[i].y += velocities[i].vy;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { ms: performance.now() - t0, iters };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function benchTypedArraysSoA(entityCount) {
|
|
46
|
+
const iters = getIters(entityCount);
|
|
47
|
+
const px = new Float32Array(entityCount);
|
|
48
|
+
const py = new Float32Array(entityCount);
|
|
49
|
+
const vx = new Float32Array(entityCount);
|
|
50
|
+
const vy = new Float32Array(entityCount);
|
|
51
|
+
for (let i = 0; i < entityCount; i++) {
|
|
52
|
+
px[i] = Math.random() * 100;
|
|
53
|
+
py[i] = Math.random() * 100;
|
|
54
|
+
vx[i] = Math.random() * 10;
|
|
55
|
+
vy[i] = Math.random() * 10;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const t0 = performance.now();
|
|
59
|
+
for (let iter = 0; iter < iters; iter++) {
|
|
60
|
+
for (let i = 0; i < entityCount; i++) {
|
|
61
|
+
px[i] += vx[i];
|
|
62
|
+
py[i] += vy[i];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { ms: performance.now() - t0, iters };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// =========================================================================
|
|
69
|
+
// ALLOCATION: Storage footprint
|
|
70
|
+
// =========================================================================
|
|
71
|
+
|
|
72
|
+
function benchStorageAlloc(entityCount) {
|
|
73
|
+
// Objects
|
|
74
|
+
const heapBefore1 = getHeapUsed();
|
|
75
|
+
const positions = [];
|
|
76
|
+
const velocities = [];
|
|
77
|
+
for (let i = 0; i < entityCount; i++) {
|
|
78
|
+
positions.push({ x: Math.random() * 100, y: Math.random() * 100 });
|
|
79
|
+
velocities.push({ vx: Math.random() * 10, vy: Math.random() * 10 });
|
|
80
|
+
}
|
|
81
|
+
const objectBytes = getHeapUsed() - heapBefore1;
|
|
82
|
+
|
|
83
|
+
// Clear
|
|
84
|
+
positions.length = 0;
|
|
85
|
+
velocities.length = 0;
|
|
86
|
+
|
|
87
|
+
// TypedArrays
|
|
88
|
+
const heapBefore2 = getHeapUsed();
|
|
89
|
+
const px = new Float32Array(entityCount);
|
|
90
|
+
const py = new Float32Array(entityCount);
|
|
91
|
+
const vx = new Float32Array(entityCount);
|
|
92
|
+
const vy = new Float32Array(entityCount);
|
|
93
|
+
for (let i = 0; i < entityCount; i++) {
|
|
94
|
+
px[i] = Math.random() * 100;
|
|
95
|
+
py[i] = Math.random() * 100;
|
|
96
|
+
vx[i] = Math.random() * 10;
|
|
97
|
+
vy[i] = Math.random() * 10;
|
|
98
|
+
}
|
|
99
|
+
const typedBytes = getHeapUsed() - heapBefore2;
|
|
100
|
+
|
|
101
|
+
return { objectBytes, typedBytes };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// =========================================================================
|
|
105
|
+
// ALLOCATION: getComponent reconstruction GC pressure
|
|
106
|
+
// =========================================================================
|
|
107
|
+
|
|
108
|
+
function benchGetComponentGC(entityCount) {
|
|
109
|
+
const px = new Float32Array(entityCount);
|
|
110
|
+
const py = new Float32Array(entityCount);
|
|
111
|
+
for (let i = 0; i < entityCount; i++) {
|
|
112
|
+
px[i] = Math.random() * 100;
|
|
113
|
+
py[i] = Math.random() * 100;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const positions = [];
|
|
117
|
+
for (let i = 0; i < entityCount; i++) {
|
|
118
|
+
positions.push({ x: px[i], y: py[i] });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const calls = 100_000;
|
|
122
|
+
|
|
123
|
+
// Object array — return existing ref, no allocation
|
|
124
|
+
if (hasGC) globalThis.gc();
|
|
125
|
+
const heapBefore1 = process.memoryUsage().heapUsed;
|
|
126
|
+
const t0 = performance.now();
|
|
127
|
+
let sink = 0;
|
|
128
|
+
for (let i = 0; i < calls; i++) {
|
|
129
|
+
const pos = positions[i % entityCount];
|
|
130
|
+
sink += pos.x + pos.y;
|
|
131
|
+
}
|
|
132
|
+
const objTime = performance.now() - t0;
|
|
133
|
+
const objHeapAfter = process.memoryUsage().heapUsed;
|
|
134
|
+
|
|
135
|
+
// TypedArray — reconstruct object each call
|
|
136
|
+
if (hasGC) globalThis.gc();
|
|
137
|
+
const heapBefore2 = process.memoryUsage().heapUsed;
|
|
138
|
+
const t1 = performance.now();
|
|
139
|
+
for (let i = 0; i < calls; i++) {
|
|
140
|
+
const idx = i % entityCount;
|
|
141
|
+
const pos = { x: px[idx], y: py[idx] };
|
|
142
|
+
sink += pos.x + pos.y;
|
|
143
|
+
}
|
|
144
|
+
const typedTime = performance.now() - t1;
|
|
145
|
+
const typedHeapAfter = process.memoryUsage().heapUsed;
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
calls,
|
|
149
|
+
objTime,
|
|
150
|
+
objHeapDelta: objHeapAfter - heapBefore1,
|
|
151
|
+
typedTime,
|
|
152
|
+
typedHeapDelta: typedHeapAfter - heapBefore2,
|
|
153
|
+
sink
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// =========================================================================
|
|
158
|
+
// ALLOCATION: addComponent — object creation vs TypedArray write
|
|
159
|
+
// =========================================================================
|
|
160
|
+
|
|
161
|
+
function benchAddComponentAlloc(entityCount) {
|
|
162
|
+
const calls = 100_000;
|
|
163
|
+
|
|
164
|
+
// Objects: creating new objects (simulates current addComponent)
|
|
165
|
+
const objectStore = new Array(entityCount);
|
|
166
|
+
if (hasGC) globalThis.gc();
|
|
167
|
+
const heapBefore1 = process.memoryUsage().heapUsed;
|
|
168
|
+
const t0 = performance.now();
|
|
169
|
+
for (let i = 0; i < calls; i++) {
|
|
170
|
+
const idx = i % entityCount;
|
|
171
|
+
objectStore[idx] = { x: i, y: i * 2 };
|
|
172
|
+
}
|
|
173
|
+
const objTime = performance.now() - t0;
|
|
174
|
+
const objHeapAfter = process.memoryUsage().heapUsed;
|
|
175
|
+
|
|
176
|
+
// TypedArrays: write to existing arrays (zero allocation)
|
|
177
|
+
const px = new Float32Array(entityCount);
|
|
178
|
+
const py = new Float32Array(entityCount);
|
|
179
|
+
if (hasGC) globalThis.gc();
|
|
180
|
+
const heapBefore2 = process.memoryUsage().heapUsed;
|
|
181
|
+
const t1 = performance.now();
|
|
182
|
+
for (let i = 0; i < calls; i++) {
|
|
183
|
+
const idx = i % entityCount;
|
|
184
|
+
px[idx] = i;
|
|
185
|
+
py[idx] = i * 2;
|
|
186
|
+
}
|
|
187
|
+
const typedTime = performance.now() - t1;
|
|
188
|
+
const typedHeapAfter = process.memoryUsage().heapUsed;
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
calls,
|
|
192
|
+
objTime,
|
|
193
|
+
objHeapDelta: objHeapAfter - heapBefore1,
|
|
194
|
+
typedTime,
|
|
195
|
+
typedHeapDelta: typedHeapAfter - heapBefore2,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// =========================================================================
|
|
200
|
+
// RUN
|
|
201
|
+
// =========================================================================
|
|
202
|
+
|
|
203
|
+
// Warmup
|
|
204
|
+
benchObjectArrays(1000);
|
|
205
|
+
benchTypedArraysSoA(1000);
|
|
206
|
+
|
|
207
|
+
const fmt = (bytes) => {
|
|
208
|
+
if (Math.abs(bytes) < 1024) return `${bytes} B`;
|
|
209
|
+
if (Math.abs(bytes) < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
210
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
211
|
+
};
|
|
212
|
+
const pad = (s, n) => String(s).padStart(n);
|
|
213
|
+
|
|
214
|
+
// --- Performance ---
|
|
215
|
+
console.log(`\n=== System loop: Position += Velocity ===\n`);
|
|
216
|
+
console.log('Entities | Frames | Objects | TypedArr SoA | Speedup');
|
|
217
|
+
console.log('------------|--------|--------------|--------------|--------');
|
|
218
|
+
|
|
219
|
+
for (const count of ENTITY_COUNTS) {
|
|
220
|
+
const obj = benchObjectArrays(count);
|
|
221
|
+
const soa = benchTypedArraysSoA(count);
|
|
222
|
+
const perFrameObj = obj.ms / obj.iters;
|
|
223
|
+
const perFrameSoa = soa.ms / soa.iters;
|
|
224
|
+
console.log(
|
|
225
|
+
`${pad(count.toLocaleString(), 11)} | ` +
|
|
226
|
+
`${pad(obj.iters, 6)} | ` +
|
|
227
|
+
`${pad(perFrameObj.toFixed(3), 9)} ms | ` +
|
|
228
|
+
`${pad(perFrameSoa.toFixed(3), 9)} ms | ` +
|
|
229
|
+
`${pad((perFrameObj / perFrameSoa).toFixed(1), 5)}x`
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// --- Storage footprint ---
|
|
234
|
+
console.log(`\n=== Storage footprint (2 components × 2 fields each) ===\n`);
|
|
235
|
+
console.log('Entities | Objects | TypedArrays | Savings');
|
|
236
|
+
console.log('----------|--------------|--------------|--------');
|
|
237
|
+
|
|
238
|
+
for (const count of ENTITY_COUNTS) {
|
|
239
|
+
const { objectBytes, typedBytes } = benchStorageAlloc(count);
|
|
240
|
+
console.log(
|
|
241
|
+
`${pad(count.toLocaleString(), 9)} | ` +
|
|
242
|
+
`${pad(fmt(objectBytes), 12)} | ` +
|
|
243
|
+
`${pad(fmt(typedBytes), 12)} | ` +
|
|
244
|
+
`${pad(((1 - typedBytes / objectBytes) * 100).toFixed(0), 4)}%`
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// --- getComponent GC pressure ---
|
|
249
|
+
console.log(`\n=== getComponent ×100k: existing object vs reconstruct from TypedArray ===\n`);
|
|
250
|
+
|
|
251
|
+
for (const count of [1_000, 10_000, 1_000_000]) {
|
|
252
|
+
const r = benchGetComponentGC(count);
|
|
253
|
+
console.log(`${count.toLocaleString()} entities, ${r.calls.toLocaleString()} getComponent calls:`);
|
|
254
|
+
console.log(` Object (return ref): ${pad(r.objTime.toFixed(1), 6)} ms | heap Δ ${fmt(r.objHeapDelta)}`);
|
|
255
|
+
console.log(` TypedArr (construct): ${pad(r.typedTime.toFixed(1), 6)} ms | heap Δ ${fmt(r.typedHeapDelta)}`);
|
|
256
|
+
console.log();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// --- addComponent allocation ---
|
|
260
|
+
console.log(`=== addComponent ×100k: object creation vs TypedArray write ===\n`);
|
|
261
|
+
|
|
262
|
+
for (const count of [1_000, 10_000, 1_000_000]) {
|
|
263
|
+
const r = benchAddComponentAlloc(count);
|
|
264
|
+
console.log(`${count.toLocaleString()} entities, ${r.calls.toLocaleString()} addComponent calls:`);
|
|
265
|
+
console.log(` Object (new {}): ${pad(r.objTime.toFixed(1), 6)} ms | heap Δ ${fmt(r.objHeapDelta)}`);
|
|
266
|
+
console.log(` TypedArr (write): ${pad(r.typedTime.toFixed(1), 6)} ms | heap Δ ${fmt(r.typedHeapDelta)}`);
|
|
267
|
+
console.log();
|
|
268
|
+
}
|