archetype-ecs 1.4.2 → 1.5.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/README.md +79 -14
- package/bench/allocations-1m.js +1 -1
- package/bench/component-churn-bench.js +193 -0
- package/bench/iterate.wat +95 -0
- package/bench/multi-ecs-bench.js +1 -1
- package/bench/run-js-vs-go-ts.sh +147 -0
- package/bench/typed-vs-bitecs-1m.js +1 -1
- package/bench/typed-vs-untyped.js +1 -1
- package/bench/vs-bitecs.js +1 -1
- package/bench/wasm-iteration-bench.js +289 -0
- package/dist/{src/ComponentRegistry.d.ts → ComponentRegistry.d.ts} +8 -3
- package/dist/{src/EntityManager.d.ts → EntityManager.d.ts} +15 -10
- package/dist/{src/EntityManager.js → EntityManager.js} +196 -95
- package/dist/{src/System.d.ts → System.d.ts} +4 -0
- package/dist/{src/System.js → System.js} +25 -5
- package/dist/WasmArena.d.ts +13 -0
- package/dist/WasmArena.js +48 -0
- package/dist/{src/index.d.ts → index.d.ts} +7 -9
- package/dist/{src/index.js → index.js} +2 -0
- package/dist/wasm-kernels.d.ts +10 -0
- package/dist/wasm-kernels.js +59 -0
- package/package.json +12 -7
- package/src/ComponentRegistry.ts +7 -3
- package/src/EntityManager.ts +209 -119
- package/src/System.ts +34 -9
- package/src/WasmArena.ts +83 -0
- package/src/index.ts +16 -11
- package/src/iterate.wat +135 -0
- package/src/wasm-kernels.ts +68 -0
- package/tests/EntityManager.test.ts +51 -86
- package/tests/System.test.ts +184 -0
- package/tests/types.ts +1 -1
- package/tsconfig.json +2 -2
- package/tsconfig.test.json +13 -0
- package/dist/tests/EntityManager.test.d.ts +0 -1
- package/dist/tests/EntityManager.test.js +0 -651
- package/dist/tests/System.test.d.ts +0 -1
- package/dist/tests/System.test.js +0 -630
- package/dist/tests/types.d.ts +0 -1
- package/dist/tests/types.js +0 -129
- /package/dist/{src/ComponentRegistry.js → ComponentRegistry.js} +0 -0
- /package/dist/{src/Profiler.d.ts → Profiler.d.ts} +0 -0
- /package/dist/{src/Profiler.js → Profiler.js} +0 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// WASM SIMD Iteration Benchmark
|
|
2
|
+
// Compares: ECS forEach (JS), ECS forEach (WASM-backed), ECS + WASM SIMD kernel
|
|
3
|
+
// Run with: node --expose-gc bench/wasm-iteration-bench.js
|
|
4
|
+
|
|
5
|
+
import { createEntityManager, component, instantiateKernels } from '../dist/index.js';
|
|
6
|
+
|
|
7
|
+
const COUNT = 1_000_000;
|
|
8
|
+
const FRAMES = 500;
|
|
9
|
+
const WARMUP = 10;
|
|
10
|
+
const RUNS = 5;
|
|
11
|
+
|
|
12
|
+
// ── Utilities ────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
const median = (arr) => {
|
|
15
|
+
const s = [...arr].sort((a, b) => a - b);
|
|
16
|
+
const mid = s.length >> 1;
|
|
17
|
+
return s.length & 1 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const pad = (s, n) => String(s).padStart(n);
|
|
21
|
+
const padEnd = (s, n) => String(s).padEnd(n);
|
|
22
|
+
|
|
23
|
+
// ── Benchmark 1: ECS forEach — default JS storage (baseline) ─────────────────
|
|
24
|
+
|
|
25
|
+
function benchECSForEach() {
|
|
26
|
+
const Position = component('BPos', { x: 'f32', y: 'f32' });
|
|
27
|
+
const Velocity = component('BVel', { vx: 'f32', vy: 'f32' });
|
|
28
|
+
const em = createEntityManager();
|
|
29
|
+
|
|
30
|
+
for (let i = 0; i < COUNT; i++) {
|
|
31
|
+
em.createEntityWith(
|
|
32
|
+
Position, { x: i * 0.1, y: i * 0.1 },
|
|
33
|
+
Velocity, { vx: 1, vy: 1 },
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for (let f = 0; f < WARMUP; f++) {
|
|
38
|
+
em.forEach([Position, Velocity], (arch) => {
|
|
39
|
+
const px = arch.field(Position.x);
|
|
40
|
+
const py = arch.field(Position.y);
|
|
41
|
+
const vx = arch.field(Velocity.vx);
|
|
42
|
+
const vy = arch.field(Velocity.vy);
|
|
43
|
+
for (let i = 0; i < arch.count; i++) {
|
|
44
|
+
px[i] += vx[i];
|
|
45
|
+
py[i] += vy[i];
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const t0 = performance.now();
|
|
51
|
+
for (let f = 0; f < FRAMES; f++) {
|
|
52
|
+
em.forEach([Position, Velocity], (arch) => {
|
|
53
|
+
const px = arch.field(Position.x);
|
|
54
|
+
const py = arch.field(Position.y);
|
|
55
|
+
const vx = arch.field(Velocity.vx);
|
|
56
|
+
const vy = arch.field(Velocity.vy);
|
|
57
|
+
for (let i = 0; i < arch.count; i++) {
|
|
58
|
+
px[i] += vx[i];
|
|
59
|
+
py[i] += vy[i];
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return (performance.now() - t0) / FRAMES;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Benchmark 2: ECS forEach — WASM-backed storage, JS iteration ────────────
|
|
67
|
+
|
|
68
|
+
function benchECSWasmStorage() {
|
|
69
|
+
const Position = component('WPos', { x: 'f32', y: 'f32' });
|
|
70
|
+
const Velocity = component('WVel', { vx: 'f32', vy: 'f32' });
|
|
71
|
+
const em = createEntityManager({ wasm: true });
|
|
72
|
+
|
|
73
|
+
for (let i = 0; i < COUNT; i++) {
|
|
74
|
+
em.createEntityWith(
|
|
75
|
+
Position, { x: i * 0.1, y: i * 0.1 },
|
|
76
|
+
Velocity, { vx: 1, vy: 1 },
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (let f = 0; f < WARMUP; f++) {
|
|
81
|
+
em.forEach([Position, Velocity], (arch) => {
|
|
82
|
+
const px = arch.field(Position.x);
|
|
83
|
+
const py = arch.field(Position.y);
|
|
84
|
+
const vx = arch.field(Velocity.vx);
|
|
85
|
+
const vy = arch.field(Velocity.vy);
|
|
86
|
+
for (let i = 0; i < arch.count; i++) {
|
|
87
|
+
px[i] += vx[i];
|
|
88
|
+
py[i] += vy[i];
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const t0 = performance.now();
|
|
94
|
+
for (let f = 0; f < FRAMES; f++) {
|
|
95
|
+
em.forEach([Position, Velocity], (arch) => {
|
|
96
|
+
const px = arch.field(Position.x);
|
|
97
|
+
const py = arch.field(Position.y);
|
|
98
|
+
const vx = arch.field(Velocity.vx);
|
|
99
|
+
const vy = arch.field(Velocity.vy);
|
|
100
|
+
for (let i = 0; i < arch.count; i++) {
|
|
101
|
+
px[i] += vx[i];
|
|
102
|
+
py[i] += vy[i];
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return (performance.now() - t0) / FRAMES;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Benchmark 3: ECS WASM-backed + WASM SIMD kernel ─────────────────────────
|
|
110
|
+
|
|
111
|
+
async function benchECSWasmSIMD() {
|
|
112
|
+
const Position = component('SPos', { x: 'f32', y: 'f32' });
|
|
113
|
+
const Velocity = component('SVel', { vx: 'f32', vy: 'f32' });
|
|
114
|
+
const em = createEntityManager({ wasm: true });
|
|
115
|
+
|
|
116
|
+
for (let i = 0; i < COUNT; i++) {
|
|
117
|
+
em.createEntityWith(
|
|
118
|
+
Position, { x: i * 0.1, y: i * 0.1 },
|
|
119
|
+
Velocity, { vx: 1, vy: 1 },
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const kernels = await instantiateKernels(em.wasmMemory);
|
|
124
|
+
|
|
125
|
+
for (let f = 0; f < WARMUP; f++) {
|
|
126
|
+
em.forEach([Position, Velocity], (arch) => {
|
|
127
|
+
kernels.iterate_simd(
|
|
128
|
+
arch.fieldOffset(Position.x),
|
|
129
|
+
arch.fieldOffset(Position.y),
|
|
130
|
+
arch.fieldOffset(Velocity.vx),
|
|
131
|
+
arch.fieldOffset(Velocity.vy),
|
|
132
|
+
arch.count,
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const t0 = performance.now();
|
|
138
|
+
for (let f = 0; f < FRAMES; f++) {
|
|
139
|
+
em.forEach([Position, Velocity], (arch) => {
|
|
140
|
+
kernels.iterate_simd(
|
|
141
|
+
arch.fieldOffset(Position.x),
|
|
142
|
+
arch.fieldOffset(Position.y),
|
|
143
|
+
arch.fieldOffset(Velocity.vx),
|
|
144
|
+
arch.fieldOffset(Velocity.vy),
|
|
145
|
+
arch.count,
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return (performance.now() - t0) / FRAMES;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Benchmark 4: ECS fieldAdd (auto SIMD dispatch) ──────────────────────────
|
|
153
|
+
|
|
154
|
+
function benchECSFieldAdd() {
|
|
155
|
+
const Position = component('FAPos', { x: 'f32', y: 'f32' });
|
|
156
|
+
const Velocity = component('FAVel', { vx: 'f32', vy: 'f32' });
|
|
157
|
+
const em = createEntityManager({ wasm: true });
|
|
158
|
+
|
|
159
|
+
for (let i = 0; i < COUNT; i++) {
|
|
160
|
+
em.createEntityWith(
|
|
161
|
+
Position, { x: i * 0.1, y: i * 0.1 },
|
|
162
|
+
Velocity, { vx: 1, vy: 1 },
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
for (let f = 0; f < WARMUP; f++) {
|
|
167
|
+
em.forEach([Position, Velocity], (arch) => {
|
|
168
|
+
arch.fieldAdd(Position.x, Velocity.vx);
|
|
169
|
+
arch.fieldAdd(Position.y, Velocity.vy);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const t0 = performance.now();
|
|
174
|
+
for (let f = 0; f < FRAMES; f++) {
|
|
175
|
+
em.forEach([Position, Velocity], (arch) => {
|
|
176
|
+
arch.fieldAdd(Position.x, Velocity.vx);
|
|
177
|
+
arch.fieldAdd(Position.y, Velocity.vy);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return (performance.now() - t0) / FRAMES;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── Output ───────────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
function printTable(title, results, baselineName) {
|
|
186
|
+
console.log(`\n--- ${title} ---\n`);
|
|
187
|
+
|
|
188
|
+
const baseline = results.find(r => r.name === baselineName)?.value;
|
|
189
|
+
const nameWidth = 28;
|
|
190
|
+
const valueWidth = 12;
|
|
191
|
+
|
|
192
|
+
console.log(` ${padEnd('Test', nameWidth)} ${pad('ms/frame', valueWidth)} vs ${baselineName}`);
|
|
193
|
+
console.log(` ${'─'.repeat(nameWidth + valueWidth + 25)}`);
|
|
194
|
+
|
|
195
|
+
for (const { name, value } of results) {
|
|
196
|
+
const valueStr = pad(value.toFixed(3), valueWidth);
|
|
197
|
+
let comparison;
|
|
198
|
+
if (name === baselineName) {
|
|
199
|
+
comparison = 'baseline';
|
|
200
|
+
} else if (baseline != null && baseline > 0) {
|
|
201
|
+
const ratio = value / baseline;
|
|
202
|
+
if (ratio > 1.05) {
|
|
203
|
+
comparison = `${ratio.toFixed(2)}x slower`;
|
|
204
|
+
} else if (ratio < 0.95) {
|
|
205
|
+
comparison = `${(1 / ratio).toFixed(2)}x faster`;
|
|
206
|
+
} else {
|
|
207
|
+
comparison = '~same';
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
comparison = '';
|
|
211
|
+
}
|
|
212
|
+
console.log(` ${padEnd(name, nameWidth)} ${valueStr} ${comparison}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
async function main() {
|
|
219
|
+
console.log(`=== WASM SIMD Iteration Benchmark: ${(COUNT / 1e6).toFixed(0)}M entities ===`);
|
|
220
|
+
console.log(` ${FRAMES} frames | ${RUNS} runs (median) | ${WARMUP} warmup frames`);
|
|
221
|
+
console.log();
|
|
222
|
+
|
|
223
|
+
const results = [];
|
|
224
|
+
|
|
225
|
+
// 1. ECS forEach (default JS storage)
|
|
226
|
+
{
|
|
227
|
+
console.log(' [1/4] ECS forEach (JS storage, baseline)');
|
|
228
|
+
const times = [];
|
|
229
|
+
for (let r = 0; r < RUNS; r++) {
|
|
230
|
+
process.stdout.write(` Run ${r + 1}/${RUNS}...`);
|
|
231
|
+
const ms = benchECSForEach();
|
|
232
|
+
times.push(ms);
|
|
233
|
+
console.log(` ${ms.toFixed(3)} ms/frame`);
|
|
234
|
+
}
|
|
235
|
+
results.push({ name: 'ECS forEach (JS)', value: median(times) });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 2. ECS forEach (WASM-backed storage, JS iteration)
|
|
239
|
+
{
|
|
240
|
+
console.log(' [2/4] ECS forEach (WASM storage, JS iter)');
|
|
241
|
+
const times = [];
|
|
242
|
+
for (let r = 0; r < RUNS; r++) {
|
|
243
|
+
process.stdout.write(` Run ${r + 1}/${RUNS}...`);
|
|
244
|
+
const ms = benchECSWasmStorage();
|
|
245
|
+
times.push(ms);
|
|
246
|
+
console.log(` ${ms.toFixed(3)} ms/frame`);
|
|
247
|
+
}
|
|
248
|
+
results.push({ name: 'ECS forEach (WASM storage)', value: median(times) });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// 3. ECS WASM-backed + WASM SIMD kernel
|
|
252
|
+
{
|
|
253
|
+
console.log(' [3/4] ECS + WASM SIMD kernel (manual)');
|
|
254
|
+
const times = [];
|
|
255
|
+
for (let r = 0; r < RUNS; r++) {
|
|
256
|
+
process.stdout.write(` Run ${r + 1}/${RUNS}...`);
|
|
257
|
+
const ms = await benchECSWasmSIMD();
|
|
258
|
+
times.push(ms);
|
|
259
|
+
console.log(` ${ms.toFixed(3)} ms/frame`);
|
|
260
|
+
}
|
|
261
|
+
results.push({ name: 'ECS + WASM SIMD (manual)', value: median(times) });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 4. ECS fieldAdd (auto SIMD dispatch)
|
|
265
|
+
{
|
|
266
|
+
console.log(' [4/4] ECS fieldAdd (auto SIMD)');
|
|
267
|
+
const times = [];
|
|
268
|
+
for (let r = 0; r < RUNS; r++) {
|
|
269
|
+
process.stdout.write(` Run ${r + 1}/${RUNS}...`);
|
|
270
|
+
const ms = benchECSFieldAdd();
|
|
271
|
+
times.push(ms);
|
|
272
|
+
console.log(` ${ms.toFixed(3)} ms/frame`);
|
|
273
|
+
}
|
|
274
|
+
results.push({ name: 'ECS fieldAdd (auto)', value: median(times) });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
printTable(
|
|
278
|
+
`Iteration (${FRAMES} frames, ${(COUNT / 1e6).toFixed(0)}M entities) — ms/frame`,
|
|
279
|
+
results,
|
|
280
|
+
'ECS forEach (JS)',
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
console.log();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
main().catch(e => {
|
|
287
|
+
console.error(e);
|
|
288
|
+
process.exit(1);
|
|
289
|
+
});
|
|
@@ -4,10 +4,15 @@ export declare const TYPE_MAP: Record<string, TypedArrayConstructor>;
|
|
|
4
4
|
export type TypeSpec = TypedArrayConstructor | [TypedArrayConstructor, number];
|
|
5
5
|
export declare function parseTypeSpec(typeStr: string): TypeSpec;
|
|
6
6
|
export declare const componentSchemas: Map<symbol, Record<string, TypeSpec>>;
|
|
7
|
-
export interface
|
|
7
|
+
export interface FieldRef {
|
|
8
8
|
readonly _sym: symbol;
|
|
9
|
-
readonly
|
|
10
|
-
[key: string]: unknown;
|
|
9
|
+
readonly _field: string;
|
|
11
10
|
}
|
|
11
|
+
export type ComponentDef<F extends string = never> = {
|
|
12
|
+
readonly _sym: symbol;
|
|
13
|
+
readonly _name: string;
|
|
14
|
+
} & {
|
|
15
|
+
readonly [K in F]: FieldRef;
|
|
16
|
+
};
|
|
12
17
|
export declare function toSym(type: ComponentDef | symbol): symbol;
|
|
13
18
|
export {};
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import { type ComponentDef } from './ComponentRegistry.js';
|
|
1
|
+
import { type ComponentDef, type FieldRef } from './ComponentRegistry.js';
|
|
2
|
+
export type { FieldRef } from './ComponentRegistry.js';
|
|
2
3
|
export type EntityId = number;
|
|
3
|
-
export
|
|
4
|
-
readonly _sym: symbol;
|
|
5
|
-
readonly _field: string;
|
|
6
|
-
}
|
|
4
|
+
export type SoAArrayValue = Float32Array | Float64Array | Int8Array | Int16Array | Int32Array | Uint8Array | Uint16Array | Uint32Array | unknown[];
|
|
7
5
|
export interface ArchetypeView {
|
|
8
6
|
readonly id: number;
|
|
9
7
|
readonly entityIds: EntityId[];
|
|
@@ -12,6 +10,8 @@ export interface ArchetypeView {
|
|
|
12
10
|
readonly snapshotCount: number;
|
|
13
11
|
field(ref: FieldRef): any;
|
|
14
12
|
fieldStride(ref: FieldRef): number;
|
|
13
|
+
fieldOffset(ref: FieldRef): number;
|
|
14
|
+
fieldAdd(target: FieldRef, source: FieldRef): void;
|
|
15
15
|
snapshot(ref: FieldRef): any;
|
|
16
16
|
}
|
|
17
17
|
export interface SerializedData {
|
|
@@ -19,14 +19,15 @@ export interface SerializedData {
|
|
|
19
19
|
entities: EntityId[];
|
|
20
20
|
components: Record<string, Record<string, unknown>>;
|
|
21
21
|
}
|
|
22
|
+
export type ComponentData = Record<string, number | string | ArrayLike<number>> | null | undefined;
|
|
22
23
|
export interface EntityManager {
|
|
23
24
|
createEntity(): EntityId;
|
|
24
25
|
destroyEntity(id: EntityId): void;
|
|
25
|
-
addComponent(entityId: EntityId, type: ComponentDef, data?:
|
|
26
|
+
addComponent(entityId: EntityId, type: ComponentDef, data?: ComponentData): void;
|
|
26
27
|
removeComponent(entityId: EntityId, type: ComponentDef): void;
|
|
27
|
-
getComponent(entityId: EntityId, type: ComponentDef):
|
|
28
|
-
get(entityId: EntityId, fieldRef: FieldRef):
|
|
29
|
-
set(entityId: EntityId, fieldRef: FieldRef, value:
|
|
28
|
+
getComponent(entityId: EntityId, type: ComponentDef): Record<string, number | string | number[]> | undefined;
|
|
29
|
+
get(entityId: EntityId, fieldRef: FieldRef): number | string | undefined;
|
|
30
|
+
set(entityId: EntityId, fieldRef: FieldRef, value: number | string | ArrayLike<number>): void;
|
|
30
31
|
hasComponent(entityId: EntityId, type: ComponentDef): boolean;
|
|
31
32
|
query(include: ComponentDef[], exclude?: ComponentDef[]): EntityId[];
|
|
32
33
|
getAllEntities(): EntityId[];
|
|
@@ -36,6 +37,7 @@ export interface EntityManager {
|
|
|
36
37
|
onAdd(type: ComponentDef, callback: (entityId: EntityId) => void): () => void;
|
|
37
38
|
onRemove(type: ComponentDef, callback: (entityId: EntityId) => void): () => void;
|
|
38
39
|
flushHooks(): void;
|
|
40
|
+
commitRemovals(): void;
|
|
39
41
|
enableTracking(filterComponent: ComponentDef): void;
|
|
40
42
|
flushChanges(): {
|
|
41
43
|
created: Set<EntityId>;
|
|
@@ -48,5 +50,8 @@ export interface EntityManager {
|
|
|
48
50
|
deserialize(data: SerializedData, nameToSymbol: Record<string, ComponentDef>, options?: {
|
|
49
51
|
deserializers?: Map<string, (data: unknown) => unknown>;
|
|
50
52
|
}): void;
|
|
53
|
+
readonly wasmMemory: WebAssembly.Memory | null;
|
|
51
54
|
}
|
|
52
|
-
export declare function createEntityManager(
|
|
55
|
+
export declare function createEntityManager(options?: {
|
|
56
|
+
wasm?: boolean;
|
|
57
|
+
}): EntityManager;
|