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,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Bash(node /mnt/d/Projects/ECS/bench/vs-bitecs.js:*)",
|
|
5
|
+
"Bash(node:*)",
|
|
6
|
+
"Bash(npm test:*)",
|
|
7
|
+
"Bash(git add:*)",
|
|
8
|
+
"Bash(git commit -m \"$\\(cat <<''EOF''\nAdd getField/setField for zero-allocation field access with TS generics\n\ngetField and setField read/write individual component fields directly\nfrom TypedArrays without reconstructing an object. TypeScript generics\non component\\(\\) flow through to autocomplete field names and catch\ninvalid fields at compile time.\n\nCo-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>\nEOF\n\\)\")",
|
|
9
|
+
"Bash(git push:*)",
|
|
10
|
+
"Bash(git commit -m \"$\\(cat <<''EOF''\nAdd compile-time type tests for TS generics validation\n\ntests/types.ts validates that component\\(\\) schema inference flows\nthrough getField, setField, addComponent, and forEach field\\(\\) with\n@ts-expect-error assertions for invalid field names. npm test now\nruns both runtime tests and tsc --noEmit.\n\nCo-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>\nEOF\n\\)\")",
|
|
11
|
+
"Bash(git -C /mnt/d/Projects/ECS status)",
|
|
12
|
+
"Bash(git -C /mnt/d/Projects/ECS diff)",
|
|
13
|
+
"Bash(git -C /mnt/d/Projects/ECS log --oneline -5)",
|
|
14
|
+
"Bash(git -C /mnt/d/Projects/ECS add bench/typed-vs-bitecs-1m.js src/ComponentRegistry.js src/EntityManager.js src/index.d.ts src/index.js tests/EntityManager.test.js tests/types.ts)",
|
|
15
|
+
"Bash(git -C /mnt/d/Projects/ECS commit -m \"$\\(cat <<''EOF''\nAdd field descriptor API: Position.x instead of string literals\n\nComponent objects now have field descriptors as properties, enabling\nem.get\\(id, Position.x\\) / em.set\\(id, Position.x, 5\\) for zero-alloc\nfield access and arch.field\\(Position.x\\) in forEach hot loops.\n\n- component\\(\\) supports short form: component\\(''Pos'', ''f32'', [''x'',''y'']\\)\n- toSym\\(\\) helper normalizes component objects to underlying symbols\n- Updated TS types: ComponentDef<T> with FieldRef descriptors\n- All tests and benchmarks updated to new API\n\nCo-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>\nEOF\n\\)\")",
|
|
16
|
+
"Bash(wc:*)",
|
|
17
|
+
"Bash(git commit:*)",
|
|
18
|
+
"WebSearch",
|
|
19
|
+
"WebFetch(domain:github.com)",
|
|
20
|
+
"WebFetch(domain:www.webgamedev.com)",
|
|
21
|
+
"WebFetch(domain:3mcd.github.io)",
|
|
22
|
+
"WebFetch(domain:www.npmjs.com)",
|
|
23
|
+
"WebFetch(domain:raw.githubusercontent.com)",
|
|
24
|
+
"Bash(npm view:*)",
|
|
25
|
+
"Bash(npm pack:*)",
|
|
26
|
+
"Bash(curl:*)",
|
|
27
|
+
"Bash(npm install:*)",
|
|
28
|
+
"Bash(npm run bench:*)",
|
|
29
|
+
"Bash(FILTER_BRANCH_SQUELCH_WARNING=1 git filter-branch:*)"
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
}
|
package/README.md
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<br>
|
|
3
|
+
<img src="https://em-content.zobj.net/source/apple/391/dna_1f9ec.png" width="80" />
|
|
4
|
+
<br><br>
|
|
5
|
+
<strong>archetype-ecs</strong>
|
|
6
|
+
<br>
|
|
7
|
+
<sub>Tiny, fast ECS with TypedArray storage. Zero dependencies.</sub>
|
|
8
|
+
<br><br>
|
|
9
|
+
<a href="https://www.npmjs.com/package/archetype-ecs"><img src="https://img.shields.io/npm/v/archetype-ecs.svg?style=flat-square&color=000" alt="npm" /></a>
|
|
10
|
+
<img src="https://img.shields.io/badge/gzip-~5kb-000?style=flat-square" alt="size" />
|
|
11
|
+
<a href="https://github.com/RvRooijen/archetype-ecs/blob/master/LICENSE"><img src="https://img.shields.io/npm/l/archetype-ecs.svg?style=flat-square&color=000" alt="license" /></a>
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
Entities grouped by component composition. Numeric fields in contiguous TypedArrays, strings in SoA arrays. Bitmask query matching. Zero-allocation hot paths.
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
npm i archetype-ecs
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
### The full picture in 20 lines
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { createEntityManager, component } from 'archetype-ecs'
|
|
28
|
+
|
|
29
|
+
const Position = component('Position', 'f32', ['x', 'y'])
|
|
30
|
+
const Velocity = component('Velocity', 'f32', ['vx', 'vy'])
|
|
31
|
+
|
|
32
|
+
const em = createEntityManager()
|
|
33
|
+
|
|
34
|
+
for (let i = 0; i < 10_000; i++) {
|
|
35
|
+
em.createEntityWith(
|
|
36
|
+
Position, { x: Math.random() * 800, y: Math.random() * 600 },
|
|
37
|
+
Velocity, { vx: Math.random() - 0.5, vy: Math.random() - 0.5 },
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
em.forEach([Position, Velocity], (arch) => {
|
|
42
|
+
const px = arch.field(Position.x) // Float32Array
|
|
43
|
+
const py = arch.field(Position.y)
|
|
44
|
+
const vx = arch.field(Velocity.vx)
|
|
45
|
+
const vy = arch.field(Velocity.vy)
|
|
46
|
+
for (let i = 0; i < arch.count; i++) {
|
|
47
|
+
px[i] += vx[i]
|
|
48
|
+
py[i] += vy[i]
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Define components, spawn entities, iterate with raw TypedArrays — no allocations, no cache misses, full type safety.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
### Why archetype-ecs?
|
|
58
|
+
|
|
59
|
+
<table>
|
|
60
|
+
<tr><td><strong>Fastest iteration</strong></td><td>SoA TypedArrays give the fastest iteration of any JS ECS we've tested. 1.7 ms/frame over 1M entities — see <a href="#benchmarks">benchmarks</a>.</td></tr>
|
|
61
|
+
<tr><td><strong>Compact memory</strong></td><td>Packed archetypes store 1M entities in 86 MB. Up to 2.4x less than sparse-array alternatives.</td></tr>
|
|
62
|
+
<tr><td><strong>Zero-alloc hot path</strong></td><td><code>em.get</code>, <code>em.set</code>, and <code>forEach</code> never allocate. Your GC stays quiet.</td></tr>
|
|
63
|
+
<tr><td><strong>Type-safe</strong></td><td>Full TypeScript generics. Field names autocomplete. Wrong fields don't compile.</td></tr>
|
|
64
|
+
<tr><td><strong>Zero dependencies</strong></td><td>~5kb gzipped. No build step. Ships as ES modules.</td></tr>
|
|
65
|
+
</table>
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
### Components
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { createEntityManager, component } from 'archetype-ecs'
|
|
73
|
+
|
|
74
|
+
// Numeric — backed by TypedArrays for cache-friendly iteration
|
|
75
|
+
const Position = component('Position', 'f32', ['x', 'y'])
|
|
76
|
+
const Velocity = component('Velocity', 'f32', ['vx', 'vy'])
|
|
77
|
+
const Health = component('Health', { hp: 'i32', maxHp: 'i32' })
|
|
78
|
+
|
|
79
|
+
// Strings — backed by SoA arrays, same field access API
|
|
80
|
+
const Name = component('Name', 'string', ['name', 'title'])
|
|
81
|
+
|
|
82
|
+
// Mixed — numeric and string fields in one component
|
|
83
|
+
const Item = component('Item', { name: 'string', weight: 'f32' })
|
|
84
|
+
|
|
85
|
+
// Tag — no data, just a marker
|
|
86
|
+
const Enemy = component('Enemy')
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
> Field types: `f32` `f64` `i8` `i16` `i32` `u8` `u16` `u32` `string`
|
|
90
|
+
|
|
91
|
+
### Entities
|
|
92
|
+
|
|
93
|
+
```js
|
|
94
|
+
const em = createEntityManager()
|
|
95
|
+
|
|
96
|
+
// One at a time
|
|
97
|
+
const player = em.createEntity()
|
|
98
|
+
em.addComponent(player, Position, { x: 0, y: 0 })
|
|
99
|
+
em.addComponent(player, Velocity, { vx: 0, vy: 0 })
|
|
100
|
+
em.addComponent(player, Health, { hp: 100, maxHp: 100 })
|
|
101
|
+
em.addComponent(player, Name, { name: 'Hero', title: 'Sir' })
|
|
102
|
+
|
|
103
|
+
// Or all at once — no archetype migration overhead
|
|
104
|
+
for (let i = 0; i < 10_000; i++) {
|
|
105
|
+
em.createEntityWith(
|
|
106
|
+
Position, { x: Math.random() * 800, y: Math.random() * 600 },
|
|
107
|
+
Velocity, { vx: Math.random() - 0.5, vy: Math.random() - 0.5 },
|
|
108
|
+
Enemy, {},
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
em.hasComponent(player, Health) // true
|
|
113
|
+
em.removeComponent(player, Health)
|
|
114
|
+
em.destroyEntity(player)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Read & write
|
|
118
|
+
|
|
119
|
+
```js
|
|
120
|
+
// Zero allocation — access any field directly
|
|
121
|
+
em.get(player, Position.x) // 0
|
|
122
|
+
em.get(player, Name.name) // 'Hero'
|
|
123
|
+
em.set(player, Velocity.vx, 5)
|
|
124
|
+
|
|
125
|
+
// Or grab the whole component as an object (allocates)
|
|
126
|
+
em.getComponent(player, Position) // { x: 0, y: 0 }
|
|
127
|
+
em.getComponent(player, Name) // { name: 'Hero', title: 'Sir' }
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Systems — `forEach` vs `query`
|
|
131
|
+
|
|
132
|
+
Two ways to work with entities in bulk. Pick the right one for the job:
|
|
133
|
+
|
|
134
|
+
#### `forEach` — zero-alloc bulk processing
|
|
135
|
+
|
|
136
|
+
Best for **systems that run every frame**. Gives you raw TypedArrays — no entity lookups, no object allocations, no cache misses.
|
|
137
|
+
|
|
138
|
+
```js
|
|
139
|
+
function movementSystem(dt) {
|
|
140
|
+
em.forEach([Position, Velocity], (arch) => {
|
|
141
|
+
const px = arch.field(Position.x) // Float32Array
|
|
142
|
+
const py = arch.field(Position.y)
|
|
143
|
+
const vx = arch.field(Velocity.vx)
|
|
144
|
+
const vy = arch.field(Velocity.vy)
|
|
145
|
+
for (let i = 0; i < arch.count; i++) {
|
|
146
|
+
px[i] += vx[i] * dt
|
|
147
|
+
py[i] += vy[i] * dt
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
#### `query` — when you need entity IDs
|
|
154
|
+
|
|
155
|
+
Best for **event-driven logic** where you need to store, pass around, or target specific entity IDs.
|
|
156
|
+
|
|
157
|
+
```js
|
|
158
|
+
// Find the closest enemy to the player
|
|
159
|
+
const enemies = em.query([Position, Enemy])
|
|
160
|
+
let closest = -1, minDist = Infinity
|
|
161
|
+
for (const id of enemies) {
|
|
162
|
+
const dx = em.get(id, Position.x) - playerX
|
|
163
|
+
const dy = em.get(id, Position.y) - playerY
|
|
164
|
+
const dist = dx * dx + dy * dy
|
|
165
|
+
if (dist < minDist) { minDist = dist; closest = id }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Store the result as a component
|
|
169
|
+
em.addComponent(player, Target, { entityId: closest })
|
|
170
|
+
|
|
171
|
+
// Exclude enemies from friendly queries
|
|
172
|
+
const friendly = em.query([Health], [Enemy])
|
|
173
|
+
|
|
174
|
+
// Just need a count? No allocation needed
|
|
175
|
+
const total = em.count([Position])
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
#### When to use which
|
|
179
|
+
|
|
180
|
+
| | `forEach` | `query` |
|
|
181
|
+
|---|---|---|
|
|
182
|
+
| **Use for** | Movement, physics, rendering | Damage events, UI, spawning |
|
|
183
|
+
| **Runs** | Every frame | On demand |
|
|
184
|
+
| **Allocates** | Nothing | `number[]` of entity IDs |
|
|
185
|
+
| **Access** | Raw TypedArrays by field | `get` / `set` by entity ID |
|
|
186
|
+
|
|
187
|
+
### Serialize
|
|
188
|
+
|
|
189
|
+
```js
|
|
190
|
+
const symbolToName = new Map([
|
|
191
|
+
[Position._sym, 'Position'],
|
|
192
|
+
[Velocity._sym, 'Velocity'],
|
|
193
|
+
[Health._sym, 'Health'],
|
|
194
|
+
])
|
|
195
|
+
|
|
196
|
+
const snapshot = em.serialize(symbolToName)
|
|
197
|
+
const json = JSON.stringify(snapshot)
|
|
198
|
+
|
|
199
|
+
// Later...
|
|
200
|
+
em.deserialize(JSON.parse(json), { Position, Velocity, Health })
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Strip components, skip entities, or plug in custom serializers — see the API section below.
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## TypeScript
|
|
208
|
+
|
|
209
|
+
Every component carries its type. Field names autocomplete, wrong fields and shapes are compile errors.
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
// Schema is inferred — Position becomes ComponentDef<{ x: number; y: number }>
|
|
213
|
+
const Position = component('Position', 'f32', ['x', 'y'])
|
|
214
|
+
|
|
215
|
+
Position.x // autocompletes to .x and .y
|
|
216
|
+
Position.z // Property 'z' does not exist
|
|
217
|
+
|
|
218
|
+
em.get(id, Position.x) // number | undefined
|
|
219
|
+
em.set(id, Position.z, 5) // Property 'z' does not exist
|
|
220
|
+
|
|
221
|
+
em.addComponent(id, Position, { x: 1, y: 2 }) // ok
|
|
222
|
+
em.addComponent(id, Position, { x: 1 }) // Property 'y' is missing
|
|
223
|
+
|
|
224
|
+
em.getComponent(id, Position) // { x: number; y: number } | undefined
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
String fields are fully typed too:
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
const Name = component('Name', 'string', ['name', 'title'])
|
|
231
|
+
|
|
232
|
+
em.get(id, Name.name) // string | undefined
|
|
233
|
+
em.set(id, Name.name, 'Hero') // ok
|
|
234
|
+
em.set(id, Name.name, 42) // number not assignable to string
|
|
235
|
+
|
|
236
|
+
em.addComponent(id, Name, { name: 'Hero', title: 'Sir' }) // ok
|
|
237
|
+
em.addComponent(id, Name, { foo: 'bar' }) // type error
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## API reference
|
|
243
|
+
|
|
244
|
+
### `component(name)`
|
|
245
|
+
|
|
246
|
+
Tag component — no data, used as a marker for queries.
|
|
247
|
+
|
|
248
|
+
### `component(name, type, fields)`
|
|
249
|
+
|
|
250
|
+
Schema component with uniform field type.
|
|
251
|
+
|
|
252
|
+
```js
|
|
253
|
+
const Position = component('Position', 'f32', ['x', 'y'])
|
|
254
|
+
const Name = component('Name', 'string', ['name', 'title'])
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
### `component(name, schema)`
|
|
258
|
+
|
|
259
|
+
Schema component with mixed field types.
|
|
260
|
+
|
|
261
|
+
```js
|
|
262
|
+
const Item = component('Item', { name: 'string', weight: 'f32', armor: 'u8' })
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
### `createEntityManager()`
|
|
266
|
+
|
|
267
|
+
Returns an entity manager with the following methods:
|
|
268
|
+
|
|
269
|
+
| Method | Description |
|
|
270
|
+
|---|---|
|
|
271
|
+
| `createEntity()` | Create an empty entity |
|
|
272
|
+
| `createEntityWith(Comp, data, ...)` | Create entity with components — no migration cost |
|
|
273
|
+
| `destroyEntity(id)` | Remove entity and all its components |
|
|
274
|
+
| `addComponent(id, Comp, data)` | Add a component to an existing entity |
|
|
275
|
+
| `removeComponent(id, Comp)` | Remove a component |
|
|
276
|
+
| `hasComponent(id, Comp)` | Check if entity has a component |
|
|
277
|
+
| `getComponent(id, Comp)` | Get component data as object *(allocates)* |
|
|
278
|
+
| `get(id, Comp.field)` | Read a single field *(zero-alloc)* |
|
|
279
|
+
| `set(id, Comp.field, value)` | Write a single field *(zero-alloc)* |
|
|
280
|
+
| `query(include, exclude?)` | Get matching entity IDs |
|
|
281
|
+
| `count(include, exclude?)` | Count matching entities |
|
|
282
|
+
| `forEach(include, callback, exclude?)` | Iterate archetypes with raw TypedArray access |
|
|
283
|
+
| `serialize(symbolToName, strip?, skip?, opts?)` | Serialize world to JSON-friendly object |
|
|
284
|
+
| `deserialize(data, nameToSymbol, opts?)` | Restore world from serialized data |
|
|
285
|
+
|
|
286
|
+
---
|
|
287
|
+
|
|
288
|
+
## Benchmarks
|
|
289
|
+
|
|
290
|
+
1M entities, Position += Velocity, 5 runs (median), Node.js:
|
|
291
|
+
|
|
292
|
+
| | archetype-ecs | bitecs | wolf-ecs | harmony-ecs | miniplex |
|
|
293
|
+
|---|---:|---:|---:|---:|---:|
|
|
294
|
+
| **Iteration** (ms/frame) | **1.7** | 2.2 | 2.2 | 1.8 | 32.5 |
|
|
295
|
+
| **Entity creation** (ms) | 401 | 366 | **106** | 248 | 265 |
|
|
296
|
+
| **Memory** (MB) | 86 | 204 | 60 | **31** | 166 |
|
|
297
|
+
|
|
298
|
+
Each library runs the same test — iterate 1M entities over 500 frames:
|
|
299
|
+
|
|
300
|
+
```js
|
|
301
|
+
// archetype-ecs
|
|
302
|
+
em.forEach([Position, Velocity], (arch) => {
|
|
303
|
+
const px = arch.field(Position.x) // Float32Array, dense
|
|
304
|
+
const py = arch.field(Position.y)
|
|
305
|
+
const vx = arch.field(Velocity.vx)
|
|
306
|
+
const vy = arch.field(Velocity.vy)
|
|
307
|
+
for (let i = 0; i < arch.count; i++) {
|
|
308
|
+
px[i] += vx[i]
|
|
309
|
+
py[i] += vy[i]
|
|
310
|
+
}
|
|
311
|
+
})
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
archetype-ecs has the fastest iteration — the metric that matters most for game loops. Harmony-ecs and wolf-ecs are close behind; miniplex pays for object-based storage with ~20x slower iteration.
|
|
315
|
+
|
|
316
|
+
Run them yourself:
|
|
317
|
+
|
|
318
|
+
```bash
|
|
319
|
+
npm run bench
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
---
|
|
323
|
+
|
|
324
|
+
## Feature comparison
|
|
325
|
+
|
|
326
|
+
How archetype-ecs stacks up against other JS ECS libraries:
|
|
327
|
+
|
|
328
|
+
### Unique to archetype-ecs
|
|
329
|
+
|
|
330
|
+
| Feature | archetype-ecs | bitecs | wolf-ecs | harmony-ecs | miniplex |
|
|
331
|
+
|---|:---:|:---:|:---:|:---:|:---:|
|
|
332
|
+
| String SoA storage | ✓ | — | — | — | — |
|
|
333
|
+
| Mixed string + numeric components | ✓ | — | — | — | — |
|
|
334
|
+
| `forEach` with dense TypedArray field access | ✓ | — | — | — | — |
|
|
335
|
+
| Field descriptors for both per-entity and bulk access | ✓ | — | — | — | — |
|
|
336
|
+
| Built-in profiler | ✓ | — | — | — | — |
|
|
337
|
+
|
|
338
|
+
### Full comparison
|
|
339
|
+
|
|
340
|
+
| Feature | archetype-ecs | bitecs | wolf-ecs | harmony-ecs | miniplex |
|
|
341
|
+
|---|:---:|:---:|:---:|:---:|:---:|
|
|
342
|
+
| TypedArray iteration | ✓ | ✓ | ✓ | ✓ | — |
|
|
343
|
+
| String support | ✓ | ✓ | — | — | ✓ |
|
|
344
|
+
| Serialize / deserialize | ✓ | ✓✓ | — | — | — |
|
|
345
|
+
| TypeScript type inference | ✓ | — | ✓ | ✓ | ✓✓ |
|
|
346
|
+
| Batch entity creation | ✓ | — | — | ✓ | ✓ |
|
|
347
|
+
| Zero-alloc per-entity access | ✓ | ✓ | ✓ | ✓ | — |
|
|
348
|
+
| Relations / hierarchies | — | ✓ | — | — | — |
|
|
349
|
+
| React integration | — | — | — | — | ✓ |
|
|
350
|
+
|
|
351
|
+
✓✓ = notably stronger implementation in that library.
|
|
352
|
+
|
|
353
|
+
archetype-ecs is the only library that combines fastest iteration, string SoA storage, serialization, type safety, and a built-in profiler.
|
|
354
|
+
|
|
355
|
+
---
|
|
356
|
+
|
|
357
|
+
## License
|
|
358
|
+
|
|
359
|
+
MIT
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Allocation benchmark: creating 1M entities with Position + Velocity
|
|
2
|
+
// Compares: archetype-ecs (typed) vs bitECS vs archetype-ecs (untyped/legacy)
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
createWorld, addEntity, addComponent, removeEntity, query
|
|
6
|
+
} from 'bitecs';
|
|
7
|
+
import { createEntityManager, component } from '../src/index.js';
|
|
8
|
+
|
|
9
|
+
const COUNT = 1_000_000;
|
|
10
|
+
const RUNS = 5;
|
|
11
|
+
|
|
12
|
+
function median(arr) {
|
|
13
|
+
const s = [...arr].sort((a, b) => a - b);
|
|
14
|
+
return s[Math.floor(s.length / 2)];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function memMB() {
|
|
18
|
+
if (globalThis.gc) globalThis.gc();
|
|
19
|
+
return process.memoryUsage().heapUsed / 1024 / 1024;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// --- archetype-ecs: typed (SoA) ---
|
|
23
|
+
function benchOursTyped() {
|
|
24
|
+
const Position = component('AllocPos', { x: 'f32', y: 'f32' });
|
|
25
|
+
const Velocity = component('AllocVel', { vx: 'f32', vy: 'f32' });
|
|
26
|
+
const em = createEntityManager();
|
|
27
|
+
|
|
28
|
+
const memBefore = memMB();
|
|
29
|
+
const t0 = performance.now();
|
|
30
|
+
|
|
31
|
+
for (let i = 0; i < COUNT; i++) {
|
|
32
|
+
const id = em.createEntity();
|
|
33
|
+
em.addComponent(id, Position, { x: i, y: i });
|
|
34
|
+
em.addComponent(id, Velocity, { vx: 1, vy: 1 });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const createTime = performance.now() - t0;
|
|
38
|
+
const memAfter = memMB();
|
|
39
|
+
|
|
40
|
+
// Destroy all
|
|
41
|
+
const t1 = performance.now();
|
|
42
|
+
const ids = em.query([Position]);
|
|
43
|
+
for (const id of ids) em.destroyEntity(id);
|
|
44
|
+
const destroyTime = performance.now() - t1;
|
|
45
|
+
|
|
46
|
+
return { createTime, destroyTime, memDelta: memAfter - memBefore };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- archetype-ecs: typed (SoA) with createEntityWith (no migration) ---
|
|
50
|
+
function benchOursTypedBatch() {
|
|
51
|
+
const Position = component('AllocPosBatch', { x: 'f32', y: 'f32' });
|
|
52
|
+
const Velocity = component('AllocVelBatch', { vx: 'f32', vy: 'f32' });
|
|
53
|
+
const em = createEntityManager();
|
|
54
|
+
|
|
55
|
+
const memBefore = memMB();
|
|
56
|
+
const t0 = performance.now();
|
|
57
|
+
|
|
58
|
+
for (let i = 0; i < COUNT; i++) {
|
|
59
|
+
em.createEntityWith(Position, { x: i, y: i }, Velocity, { vx: 1, vy: 1 });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const createTime = performance.now() - t0;
|
|
63
|
+
const memAfter = memMB();
|
|
64
|
+
|
|
65
|
+
const t1 = performance.now();
|
|
66
|
+
const ids = em.query([Position]);
|
|
67
|
+
for (const id of ids) em.destroyEntity(id);
|
|
68
|
+
const destroyTime = performance.now() - t1;
|
|
69
|
+
|
|
70
|
+
return { createTime, destroyTime, memDelta: memAfter - memBefore };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// --- archetype-ecs: untyped (legacy object arrays) ---
|
|
74
|
+
function benchOursUntyped() {
|
|
75
|
+
const Position = Symbol('AllocPosUn');
|
|
76
|
+
const Velocity = Symbol('AllocVelUn');
|
|
77
|
+
const em = createEntityManager();
|
|
78
|
+
|
|
79
|
+
const memBefore = memMB();
|
|
80
|
+
const t0 = performance.now();
|
|
81
|
+
|
|
82
|
+
for (let i = 0; i < COUNT; i++) {
|
|
83
|
+
const id = em.createEntity();
|
|
84
|
+
em.addComponent(id, Position, { x: i, y: i });
|
|
85
|
+
em.addComponent(id, Velocity, { vx: 1, vy: 1 });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const createTime = performance.now() - t0;
|
|
89
|
+
const memAfter = memMB();
|
|
90
|
+
|
|
91
|
+
const t1 = performance.now();
|
|
92
|
+
const ids = em.query([Position]);
|
|
93
|
+
for (const id of ids) em.destroyEntity(id);
|
|
94
|
+
const destroyTime = performance.now() - t1;
|
|
95
|
+
|
|
96
|
+
return { createTime, destroyTime, memDelta: memAfter - memBefore };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- bitECS ---
|
|
100
|
+
function benchBitECS() {
|
|
101
|
+
const world = createWorld();
|
|
102
|
+
const Position = { x: new Float32Array(COUNT + 10), y: new Float32Array(COUNT + 10) };
|
|
103
|
+
const Velocity = { vx: new Float32Array(COUNT + 10), vy: new Float32Array(COUNT + 10) };
|
|
104
|
+
|
|
105
|
+
const memBefore = memMB();
|
|
106
|
+
const t0 = performance.now();
|
|
107
|
+
|
|
108
|
+
for (let i = 0; i < COUNT; i++) {
|
|
109
|
+
const eid = addEntity(world);
|
|
110
|
+
addComponent(world, eid, Position);
|
|
111
|
+
addComponent(world, eid, Velocity);
|
|
112
|
+
Position.x[eid] = i;
|
|
113
|
+
Position.y[eid] = i;
|
|
114
|
+
Velocity.vx[eid] = 1;
|
|
115
|
+
Velocity.vy[eid] = 1;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const createTime = performance.now() - t0;
|
|
119
|
+
const memAfter = memMB();
|
|
120
|
+
|
|
121
|
+
const t1 = performance.now();
|
|
122
|
+
const entities = query(world, [Position, Velocity]);
|
|
123
|
+
for (const eid of entities) removeEntity(world, eid);
|
|
124
|
+
const destroyTime = performance.now() - t1;
|
|
125
|
+
|
|
126
|
+
return { createTime, destroyTime, memDelta: memAfter - memBefore };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// --- Run ---
|
|
130
|
+
console.log(`\n=== Allocation benchmark: ${(COUNT / 1e6).toFixed(0)}M entities (Position + Velocity) ===`);
|
|
131
|
+
console.log(` ${RUNS} runs, median taken\n`);
|
|
132
|
+
|
|
133
|
+
const results = { typed: [], typedBatch: [], untyped: [], bitecs: [] };
|
|
134
|
+
|
|
135
|
+
for (let r = 0; r < RUNS; r++) {
|
|
136
|
+
results.typed.push(benchOursTyped());
|
|
137
|
+
results.typedBatch.push(benchOursTypedBatch());
|
|
138
|
+
results.untyped.push(benchOursUntyped());
|
|
139
|
+
results.bitecs.push(benchBitECS());
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function report(label, runs) {
|
|
143
|
+
const create = median(runs.map(r => r.createTime));
|
|
144
|
+
const destroy = median(runs.map(r => r.destroyTime));
|
|
145
|
+
const mem = median(runs.map(r => r.memDelta));
|
|
146
|
+
console.log(` ${label}`);
|
|
147
|
+
console.log(` create: ${create.toFixed(1)} ms`);
|
|
148
|
+
console.log(` destroy: ${destroy.toFixed(1)} ms`);
|
|
149
|
+
console.log(` heap: +${mem.toFixed(1)} MB`);
|
|
150
|
+
return { create, destroy, mem };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const t = report('typed + addComponent (2 migraties)', results.typed);
|
|
154
|
+
const tb = report('typed + createEntityWith (0 migraties)', results.typedBatch);
|
|
155
|
+
const u = report('untyped + addComponent', results.untyped);
|
|
156
|
+
const b = report('bitECS', results.bitecs);
|
|
157
|
+
|
|
158
|
+
console.log(`\n createEntityWith vs addComponent: ${(t.create / tb.create).toFixed(1)}x sneller`);
|
|
159
|
+
console.log(` createEntityWith vs bitECS: ${(tb.create / b.create).toFixed(1)}x (${tb.create < b.create ? 'sneller' : 'trager'})`);
|
|
160
|
+
console.log(` memory: typed ${t.mem.toFixed(0)} MB, batch ${tb.mem.toFixed(0)} MB, bitECS ${b.mem.toFixed(0)} MB`);
|
|
161
|
+
console.log();
|