@daneren2005/shared-memory-ecs 1.1.1 → 1.2.1
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/LICENSE +21 -0
- package/README.md +151 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +220 -83
- package/dist/index.js.map +1 -1
- package/dist/performance-timing.d.ts +44 -0
- package/dist/systems/component-system.d.ts +3 -1
- package/dist/systems/entity-system.d.ts +1 -1
- package/dist/systems/system.d.ts +2 -1
- package/dist/systems/workers/component-worker-message.d.ts +10 -5
- package/dist/world.d.ts +2 -4
- package/package.json +1 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Scott Jackson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -20,7 +20,8 @@ fog of war, sub-classed entities, required components, etc).
|
|
|
20
20
|
needs the `type` plus the entity's serialization. `BaseWorld#loadEntity` always goes through the factory.
|
|
21
21
|
- **`BaseWorld<C>`** – builds one `MemoryComponent` per registered component (attached to that component's
|
|
22
22
|
definition as `world.registry[name].memoryComponent`) and runs systems. It is generic over your component
|
|
23
|
-
map `C`, so `world.registry`, `entity.components`, `setComponent`, etc. are fully typed.
|
|
23
|
+
map `C`, so `world.registry`, `entity.components`, `setComponent`, etc. are fully typed. Its entities live
|
|
24
|
+
in `world.entities`, a `Map` keyed by `eid` — see [Iterating entities](#iterating-entities).
|
|
24
25
|
- **`BaseEntity<C>`** – an `eid`, an optional `id`, and a bag of memory-backed components. It has no
|
|
25
26
|
direct property accessors and only loads/saves component data. Every entity has an `entity` component
|
|
26
27
|
whose `type` (a plain, worker-invisible string) records the factory template it was built from.
|
|
@@ -109,6 +110,63 @@ console.log(goblin.save()); // { type: 'goblin', health: 10 } - no templated max
|
|
|
109
110
|
world.loadEntity(goblin.save());
|
|
110
111
|
```
|
|
111
112
|
|
|
113
|
+
## Iterating entities
|
|
114
|
+
|
|
115
|
+
`world.entities` is a `Map` keyed by `eid`, not an array, and so is `entities` on `EntitySystem` and
|
|
116
|
+
`ComponentSystem`:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
world.entities.forEach(entity => { ... }); // in the order they were added
|
|
120
|
+
world.entities.get(eid); // same as world.getEntityByEid(eid)
|
|
121
|
+
world.entities.size;
|
|
122
|
+
for(const entity of world.entities.values()) { ... }
|
|
123
|
+
|
|
124
|
+
// For the array methods a Map does not have:
|
|
125
|
+
Array.from(world.entities.values()).filter(entity => !!entity.components.health);
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
An entity leaves from anywhere in the middle of that collection — every death is one — and finding it in an
|
|
129
|
+
array meant a scan of the whole world, then a shift of everything after it, repeated for each system that
|
|
130
|
+
held it as well. A few thousand entities with a few dozen deaths a run made that the most expensive thing the
|
|
131
|
+
main thread did on a busy frame; a `Map` deletes in constant time. It also replaced the separate
|
|
132
|
+
`entitiesByEid` lookup, so there is only ever one collection to keep straight.
|
|
133
|
+
|
|
134
|
+
Iteration order is still insertion order, so `load` / `save` round-trip in the order they always did.
|
|
135
|
+
|
|
136
|
+
## Reading components on a hot path
|
|
137
|
+
|
|
138
|
+
`entity.components.health!.health` is the way to read a component, and for almost everything it is the right
|
|
139
|
+
one: it is typed, it is readable, and one read costs nothing worth measuring.
|
|
140
|
+
|
|
141
|
+
It is not free, though, and the cost shows up in exactly one situation — reading the same component off
|
|
142
|
+
*thousands* of entities, *every frame*. The accessor walks several objects to get there and ends in a getter
|
|
143
|
+
closure over the shared block, and because every entity has its own closure those call sites go megamorphic
|
|
144
|
+
once enough entities are alive, so none of it inlines. Measured over ~10,000 entities, reading four values per
|
|
145
|
+
entity cost **~950ns through the accessors against ~140ns straight off the block** — the difference between
|
|
146
|
+
10ms a frame and 1.5ms.
|
|
147
|
+
|
|
148
|
+
Where that matters, hold the block instead. It is the same memory the accessors read, so nothing changes about
|
|
149
|
+
what you get, and it is what the update functions already work on:
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
import { TRANSFORM_X_INDEX } from '@daneren2005/shared-memory-physics';
|
|
153
|
+
|
|
154
|
+
// Resolve once, when whatever is doing the reading is set up.
|
|
155
|
+
const health = entity.components.health!;
|
|
156
|
+
const block = world.registry.health.memoryComponent.getBlock(health.index) as Int32Array;
|
|
157
|
+
|
|
158
|
+
// Then per frame, per entity:
|
|
159
|
+
block[HEALTH_INDEX];
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Two things come with that. The block is only valid while the component is: resolve it again if the component
|
|
163
|
+
can be removed and re-added, or hang it off something that dies with the entity. And it is indexed rather than
|
|
164
|
+
named, so the offsets have to be exported alongside the definition — which is why every component in the
|
|
165
|
+
physics library exports its `*_INDEX` constants.
|
|
166
|
+
|
|
167
|
+
Reach for this when a profile says to, not by default. A menu, a save, a system that touches a dozen entities:
|
|
168
|
+
use the accessors.
|
|
169
|
+
|
|
112
170
|
## ComponentSystem workers
|
|
113
171
|
|
|
114
172
|
`ComponentSystem` needs a `getWorker()` that returns a real `Worker`, and an `updateFunction`. Your
|
|
@@ -131,6 +189,98 @@ class DamageSystem extends ComponentSystem<Components, { health: Int32Array }, D
|
|
|
131
189
|
}
|
|
132
190
|
```
|
|
133
191
|
|
|
192
|
+
### Reporting back to the main thread
|
|
193
|
+
|
|
194
|
+
An update function runs on shared memory, so anything it writes is already visible on the main thread. What
|
|
195
|
+
it cannot do from there is touch the world, so the things that have to happen back on it go through
|
|
196
|
+
`callbacks`: `entityComponentChanged` (emitted on the entity as `component-property-updated`), `entityDied`
|
|
197
|
+
(as `death`), and `createEntity`. All of them are collected during the run and applied once it completes.
|
|
198
|
+
|
|
199
|
+
`emitEntityEvent` is the escape hatch for an event of your own: name it whatever you like and give it
|
|
200
|
+
whatever args suit it, and it is emitted on the entity under that name. It exists so a system does not have
|
|
201
|
+
to spend one `component-property-updated` per property when the listener only cares about all of them
|
|
202
|
+
together - a move that reports `x` and `y` as one `position-updated` is half the events of one per axis:
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
// in the update function
|
|
206
|
+
callbacks.emitEntityEvent(entityId, 'position-updated', x, y);
|
|
207
|
+
|
|
208
|
+
// on the main thread
|
|
209
|
+
entity.on('position-updated', (x: number, y: number) => { ... });
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The args are structured-cloned across the worker boundary, so they have to be plain values - no functions,
|
|
213
|
+
no class instances. Nothing about the name or the args is checked against your component map, since the
|
|
214
|
+
event is the system's own concept rather than a component, so export both alongside the update function that
|
|
215
|
+
emits them.
|
|
216
|
+
|
|
217
|
+
### Reporting something that happens to everything, every run
|
|
218
|
+
|
|
219
|
+
Every callback above costs an event object per entity: an allocation in the worker, a structured clone of it,
|
|
220
|
+
an eid lookup on the main thread, and an emit on that entity. That is fine for a death or a hit, which happen
|
|
221
|
+
to a handful of entities a run. It is not fine for a move, which happens to nearly all of them, every run -
|
|
222
|
+
at ten thousand entities that is ten thousand objects and ten thousand emits a frame, and it can easily cost
|
|
223
|
+
more than the simulation it is reporting.
|
|
224
|
+
|
|
225
|
+
`emitSystemEvent` is the version of that with everything avoidable taken out. Name the event and give it an
|
|
226
|
+
entity id; the whole run arrives on the **system** as one call with one array of ids:
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
// in the update function - just the id, once per entity that moved
|
|
230
|
+
callbacks.emitSystemEvent('position-updated', entityId);
|
|
231
|
+
|
|
232
|
+
// on the main thread - one call for the run, however many entities are in it
|
|
233
|
+
system.on('position-updated', (entityIds: Array<number>) => {
|
|
234
|
+
for(const eid of entityIds) {
|
|
235
|
+
const sprite = sprites.get(eid);
|
|
236
|
+
// The worker wrote the position into shared memory, so it is already here to read.
|
|
237
|
+
if(sprite) {
|
|
238
|
+
const transform = world.getEntityByEid(eid)?.components.transform;
|
|
239
|
+
...
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Nothing travels with the id on purpose. The blocks the update just wrote are shared memory, so the main
|
|
246
|
+
thread already holds the values - sending them along would only pay to copy what is already there. Reach for
|
|
247
|
+
`emitEntityEvent` when the thing you want to report is *not* in a component block, and for this when it is.
|
|
248
|
+
|
|
249
|
+
System events are dispatched before the per-entity events of the same run, so an entity a run both moved and
|
|
250
|
+
killed is still in the world when its move is reported.
|
|
251
|
+
|
|
252
|
+
## Measuring performance
|
|
253
|
+
|
|
254
|
+
`PerformanceTiming` watches a world and reports what running it costs. Hand it the world and it hooks itself
|
|
255
|
+
up to the events the world already emits - there is nothing to call per frame and nothing added to the hot
|
|
256
|
+
path:
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
const timing = new PerformanceTiming(world);
|
|
260
|
+
timing.on('stats-updated', (stats: PerformanceStats) => renderDebugPanel(stats));
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
It gathers samples every frame and collapses them into a fresh `timing.stats` snapshot once
|
|
264
|
+
`ticksBetweenUpdates` (default `1_000`) worth of elapsed time has gone by, then emits `stats-updated` with it.
|
|
265
|
+
The window is measured in whatever unit you drive `world.update` with, so a game running on milliseconds gets
|
|
266
|
+
a snapshot a second. Frames the world was paused for are skipped, since it does no work on them.
|
|
267
|
+
|
|
268
|
+
Every entry is an `{ avg, min, max, samples }` over the window just closed:
|
|
269
|
+
|
|
270
|
+
- `stats.update` - one whole `world.update` call on the thread the world lives on.
|
|
271
|
+
- `stats.systems[]` - per system, in run order: `run` is the run itself on its worker, as the worker measured
|
|
272
|
+
it, and `events` is what handling that run's results (the events it reported onto entities, the entities it
|
|
273
|
+
asked to be created) cost back on the calling thread. A system running on the main-thread fallback never
|
|
274
|
+
reports either, so it sits at zero with `samples: 0` - which is what tells it apart from one that genuinely
|
|
275
|
+
cost nothing.
|
|
276
|
+
- `stats.events` - every system's event handling added together. Workers finish on their own schedule rather
|
|
277
|
+
than on a frame boundary, so there is no per-frame combined sample to take; these are the per-system figures
|
|
278
|
+
summed at the end of the window, giving what a run of every system costs the main thread between them.
|
|
279
|
+
|
|
280
|
+
`getSystemStats(name)` pulls one system out of the latest snapshot, `reset()` throws away everything collected
|
|
281
|
+
so far (worth doing after loading a new scene, when the samples either side are not comparable), and
|
|
282
|
+
`destroy()` unhooks it from the world.
|
|
283
|
+
|
|
134
284
|
## Building
|
|
135
285
|
|
|
136
286
|
```sh
|
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ export type { EntityComponent, EntityComponentConfig, EntityComponentSerializati
|
|
|
7
7
|
export { default as killEntity } from './actions/kill-entity';
|
|
8
8
|
export { default as killEntityWorker } from './actions/kill-entity-worker';
|
|
9
9
|
export { default as createEntityWorker } from './actions/create-entity-worker';
|
|
10
|
+
export { default as PerformanceTiming, DEFAULT_TICKS_BETWEEN_UPDATES } from './performance-timing';
|
|
11
|
+
export type { TimingStats, SystemTimingStats, PerformanceStats, PerformanceTimingOptions, } from './performance-timing';
|
|
10
12
|
export { default as MemoryComponent } from './memory-component';
|
|
11
13
|
export type { ComponentTypedArray } from './memory-component';
|
|
12
14
|
export type { BaseComponent, ComponentMap, ComponentDefinition, ComponentDefinitionMap, ComponentRegistry, ComponentsOf, EntityConfigOf, RegisteredComponentDefinition, RegisteredComponentRegistry, } from './component-definition';
|
|
@@ -22,3 +24,4 @@ export { default as WebWorker } from './systems/workers/web-worker';
|
|
|
22
24
|
export { default as ComponentWebWorker } from './systems/workers/component-web-worker';
|
|
23
25
|
export { default as createComponentWorker } from './systems/workers/create-component-worker';
|
|
24
26
|
export type { default as ComponentWorkerMessage } from './systems/workers/component-worker-message';
|
|
27
|
+
export type { EntityEvent, SystemEvents } from './systems/workers/component-worker-message';
|