microecs 0.4.0__tar.gz → 0.4.2__tar.gz
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.
- {microecs-0.4.0 → microecs-0.4.2}/PKG-INFO +32 -16
- {microecs-0.4.0 → microecs-0.4.2}/README.md +31 -15
- {microecs-0.4.0 → microecs-0.4.2}/microecs/__init__.py +2 -2
- {microecs-0.4.0 → microecs-0.4.2}/microecs/entity.py +2 -2
- {microecs-0.4.0 → microecs-0.4.2}/microecs/pool.py +3 -2
- {microecs-0.4.0 → microecs-0.4.2}/microecs/query_result.py +12 -16
- {microecs-0.4.0 → microecs-0.4.2}/microecs/world.py +12 -3
- {microecs-0.4.0 → microecs-0.4.2}/microecs.egg-info/PKG-INFO +32 -16
- {microecs-0.4.0 → microecs-0.4.2}/setup.py +1 -1
- {microecs-0.4.0 → microecs-0.4.2}/LICENSE.TXT +0 -0
- {microecs-0.4.0 → microecs-0.4.2}/microecs/component.py +0 -0
- {microecs-0.4.0 → microecs-0.4.2}/microecs/utils.py +0 -0
- {microecs-0.4.0 → microecs-0.4.2}/microecs.egg-info/SOURCES.txt +0 -0
- {microecs-0.4.0 → microecs-0.4.2}/microecs.egg-info/dependency_links.txt +0 -0
- {microecs-0.4.0 → microecs-0.4.2}/microecs.egg-info/requires.txt +0 -0
- {microecs-0.4.0 → microecs-0.4.2}/microecs.egg-info/top_level.txt +0 -0
- {microecs-0.4.0 → microecs-0.4.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: microecs
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: MicroECS: Minimal Entity Component System (ECS) in python and numpy
|
|
5
5
|
Home-page: https://gitlab.com/meehai/microecs
|
|
6
6
|
License: MIT
|
|
@@ -76,7 +76,7 @@ class MotionSystem:
|
|
|
76
76
|
def __call__(self, world: World):
|
|
77
77
|
qr = world.query(HasPosition, HasVelocity) # 'exclude' is optional
|
|
78
78
|
qr.position[:] = qr.position + qr.velocity * DT # writes back to all the underlying pools using numpy's rules
|
|
79
|
-
# Alternative for per-pool update. Less ergonomic, but maybe faster in extreme cases as it avoids the
|
|
79
|
+
# Alternative for per-pool update. Less ergonomic, but maybe faster in extreme cases as it avoids the Field obj
|
|
80
80
|
for pool in qr.pool_list:
|
|
81
81
|
pool.position[:] = pool.position + pool.velocity * DT
|
|
82
82
|
|
|
@@ -114,7 +114,7 @@ for pos, color, radius in zip(qr.position, qr.color, qr.radius): # one entit
|
|
|
114
114
|
rl.DrawCircle(int(pos[0]), int(pos[1]), float(radius[0]), color) # per-entity by necessity (no "draw all")
|
|
115
115
|
```
|
|
116
116
|
|
|
117
|
-
|
|
117
|
+
For random single-entity access, `e = world.get_entity(eid)` (or `world.get_entity(qr.entity_ids[i])` to go by query position). That's slower in the general case and should be avoided in expensive loops, like rendering, even if it is a bit more ergonomic. See benchmark below. (There is no `qr.field[i]` shortcut — the entity axis is off-limits on a query result; it raises.)
|
|
118
118
|
|
|
119
119
|
<details>
|
|
120
120
|
<summary> Microbenchmark: ECS vs OOP on a simple physics step </summary>
|
|
@@ -124,25 +124,25 @@ Benchmark: we run the same physics step `pos += vel*dt` over N=100k entities spl
|
|
|
124
124
|
| pattern | ns/entity | vs OOP-scalar |
|
|
125
125
|
|---|---:|---:|
|
|
126
126
|
| `micro-ecs-pool-vectorized` — `for pool: pool.f[:] = pool.f + …` | 0.9 | **52× faster** |
|
|
127
|
-
| `micro-ecs-vectorized` — `qr.f[:] = qr.f + …` (the `
|
|
127
|
+
| `micro-ecs-vectorized` — `qr.f[:] = qr.f + …` (the `Field`) | 1.8 | **27× faster** |
|
|
128
128
|
| **`oop-scalar`** — `for o: o.x += o.vx*dt` (python floats) | 48 | 1× (baseline) |
|
|
129
129
|
| `oop-numpy` — objects holding `(2,)` numpy arrays | 605 | 13× slower |
|
|
130
130
|
| `micro-ecs-zip-rows` — `for p, v in zip(qr.pos, qr.vel)` | 744 | 15× slower |
|
|
131
131
|
| `micro-ecs-pool-loop` — `for pool: for i: pool.f[i]` | 870 | 18× slower |
|
|
132
132
|
| `micro-ecs-get-entity` — `world.get_entity(eid)` per entity | 1450 | 30× slower |
|
|
133
|
-
| `micro-ecs-index` — `qr.f[i]` per entity (an `np.searchsorted` each) | 3040 | 64× slower |
|
|
134
133
|
|
|
135
134
|
Three things to take from it:
|
|
136
135
|
|
|
137
|
-
1. **Vectorized wins big.** Batched ops (`
|
|
136
|
+
1. **Vectorized wins big.** Batched ops (`Field` or per-pool) run at 1–2 ns/entity — **27–52×
|
|
138
137
|
faster** than the *fastest* OOP loop. Same for data-parallel branches: an `np.where` clamp or
|
|
139
138
|
bounce is ~34× faster than a per-entity `if`.
|
|
140
|
-
2. **Per-entity loops are a cliff, not a tie.** Every per-entity microecs path is **15–
|
|
139
|
+
2. **Per-entity loops are a cliff, not a tie.** Every per-entity microecs path is **15–30× slower**
|
|
141
140
|
than idiomatic float-based OOP — because microecs is numpy-backed, so a per-entity step pays
|
|
142
141
|
numpy's tiny-array overhead (`oop-numpy` shows the same ~13× tax). One unavoidable per-entity
|
|
143
142
|
pass (~750 ns/entity) costs ~500× a vectorized op (~1.5 ns) and will dominate the frame.
|
|
144
|
-
3. **If you must loop, loop right.** `zip`-rows (15×) < pool-loop (18×) < `get_entity` (30×)
|
|
145
|
-
`qr.
|
|
143
|
+
3. **If you must loop, loop right.** `zip`-rows (15×) < pool-loop (18×) < `get_entity` (30×).
|
|
144
|
+
For random single-entity access use `world.get_entity(qr.entity_ids[i])` — there's no `qr.f[i]`
|
|
145
|
+
shortcut (the entity axis is off-limits on a query; it raises).
|
|
146
146
|
|
|
147
147
|
**Rule of thumb:** keep systems vectorized and push branches into `np.where` / `np.clip`. If a
|
|
148
148
|
workload is *irreducibly* per-entity (data-dependent control flow), plain python objects beat
|
|
@@ -152,27 +152,43 @@ microecs ~15× — use them there. microecs is the right tool for **vectorizable
|
|
|
152
152
|
|
|
153
153
|
## How much are `Pool` and `QueryResult` numpy-like and corner cases
|
|
154
154
|
|
|
155
|
-
Given `qr=world.query(A, B)`, then `qr.position` returns a `
|
|
155
|
+
Given `qr=world.query(A, B)`, then `qr.position` returns a `Field`: a view over the matching pools that behaves like one contiguous-like `(N, *e)`
|
|
156
156
|
numpy array (e.g. `(N, 2)` for a `(2,)` field). It applies each op **per pool** and stitches result back.
|
|
157
157
|
|
|
158
158
|
That covers elementwise math and ufuncs (e.g. `np.where`, `np.linalg.norm(..., axis=1)` etc.), broadcasting (every operand shape numpy accepts, and it raises on the ones numpy rejects). See `test/unit/test_field_numpy_parity.py` for a whole set of operations comparing both.
|
|
159
159
|
|
|
160
160
|
Edge cases worth knowing:
|
|
161
161
|
|
|
162
|
-
- **Not a full ndarray — these raise, never lie.** Entity-axis indexing
|
|
163
|
-
`qr.f[i]
|
|
164
|
-
methods/attrs (`.sum()`, `.mean()`, `.dtype`, `.ndim`, `.T`). Need
|
|
165
|
-
first with `qr.f.numpy()`.
|
|
162
|
+
- **Not a full ndarray — these raise, never lie.** Entity-axis indexing of any kind
|
|
163
|
+
(`qr.f[i]`, `qr.f[:]`, `qr.f[2:4]`, `qr.f[mask]`, fancy), partial entity writes, and ndarray
|
|
164
|
+
methods/attrs (`.sum()`, `.mean()`, `.dtype`, `.ndim`, `.T`). Need a single entity? Use
|
|
165
|
+
`world.get_entity(qr.entity_ids[i])`. Need a real array? Materialize first with `qr.f.numpy()`.
|
|
166
166
|
- **Axis-0 ops are per-pool, not global (footgun).** `np.sort` / `np.cumsum` / `np.sum` over
|
|
167
167
|
`axis=0` run within each pool and reset at pool boundaries — they do **not** see all entities
|
|
168
168
|
at once, so they differ from numpy. They're allowed, but if you want a global result, do
|
|
169
169
|
`qr.f.numpy()` first. A reduction that collapses the entity axis is rejected when its length no
|
|
170
170
|
longer matches the pool's row count.
|
|
171
171
|
- **Operands must come from the same query.** Alignment is per-pool, not by flat index, so don't
|
|
172
|
-
mix a `
|
|
172
|
+
mix a `Field` from one `world.query(...)` into an op on another.
|
|
173
173
|
- **Reserved field names.** A field is read back as `qr.<field>` and `entity.<field>`, so it may not
|
|
174
174
|
collide with an attribute or method of `QueryResult` or `Entity` — `World(...)` rejects such a
|
|
175
175
|
component at construction instead of silently shadowing it. The reserved set (source of truth:
|
|
176
|
-
`
|
|
176
|
+
`QUERY_RESULT_INTERNAL_ATTRS` in `query_result.py`, `ENTITY_INTERNAL_ATTRS` in `entity.py`):
|
|
177
177
|
- from `QueryResult`: `pool_list`, `entity_ids`, `fields`, `_data`, `_len`, `_field_shapes`, `_field_dtypes`
|
|
178
178
|
- from `Entity`: `entity_id`, `get_components`, `get_fields`, `to_dict`, `_eid_to_pool_ix`, `_pool_to_components`
|
|
179
|
+
|
|
180
|
+
## Mutation timing: field writes are eager, structural changes are deferred
|
|
181
|
+
|
|
182
|
+
One frame holds two different timings. Know which is which:
|
|
183
|
+
|
|
184
|
+
- **Structural changes are lazy (command-buffered).** `add_entity`, `remove_entity`, `add_component`,
|
|
185
|
+
`remove_component` only queue a command; they take effect at the next `world.update()`. This is what
|
|
186
|
+
keeps queries stable within a tick — pools don't move under a running system.
|
|
187
|
+
- **Field writes are eager.** A write through an `Entity` (`e.position = ...`, `e.position += ...`),
|
|
188
|
+
`world.set_entity_data(id, f, v)`, and the vectorized `qr.field[:] = ...` path all write straight into
|
|
189
|
+
the pool buffer and are visible immediately — no `update()` needed.
|
|
190
|
+
|
|
191
|
+
So inside one tick: a freshly spawned entity is **not** visible until `update()`, but a field write on an
|
|
192
|
+
already-committed entity **is** visible at once. Rule of thumb: **structure is deferred, data is live.**
|
|
193
|
+
If a field write must be ordered against a spawn/despawn, do the structural change, call `update()`, then
|
|
194
|
+
write.
|
|
@@ -64,7 +64,7 @@ class MotionSystem:
|
|
|
64
64
|
def __call__(self, world: World):
|
|
65
65
|
qr = world.query(HasPosition, HasVelocity) # 'exclude' is optional
|
|
66
66
|
qr.position[:] = qr.position + qr.velocity * DT # writes back to all the underlying pools using numpy's rules
|
|
67
|
-
# Alternative for per-pool update. Less ergonomic, but maybe faster in extreme cases as it avoids the
|
|
67
|
+
# Alternative for per-pool update. Less ergonomic, but maybe faster in extreme cases as it avoids the Field obj
|
|
68
68
|
for pool in qr.pool_list:
|
|
69
69
|
pool.position[:] = pool.position + pool.velocity * DT
|
|
70
70
|
|
|
@@ -102,7 +102,7 @@ for pos, color, radius in zip(qr.position, qr.color, qr.radius): # one entit
|
|
|
102
102
|
rl.DrawCircle(int(pos[0]), int(pos[1]), float(radius[0]), color) # per-entity by necessity (no "draw all")
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
For random single-entity access, `e = world.get_entity(eid)` (or `world.get_entity(qr.entity_ids[i])` to go by query position). That's slower in the general case and should be avoided in expensive loops, like rendering, even if it is a bit more ergonomic. See benchmark below. (There is no `qr.field[i]` shortcut — the entity axis is off-limits on a query result; it raises.)
|
|
106
106
|
|
|
107
107
|
<details>
|
|
108
108
|
<summary> Microbenchmark: ECS vs OOP on a simple physics step </summary>
|
|
@@ -112,25 +112,25 @@ Benchmark: we run the same physics step `pos += vel*dt` over N=100k entities spl
|
|
|
112
112
|
| pattern | ns/entity | vs OOP-scalar |
|
|
113
113
|
|---|---:|---:|
|
|
114
114
|
| `micro-ecs-pool-vectorized` — `for pool: pool.f[:] = pool.f + …` | 0.9 | **52× faster** |
|
|
115
|
-
| `micro-ecs-vectorized` — `qr.f[:] = qr.f + …` (the `
|
|
115
|
+
| `micro-ecs-vectorized` — `qr.f[:] = qr.f + …` (the `Field`) | 1.8 | **27× faster** |
|
|
116
116
|
| **`oop-scalar`** — `for o: o.x += o.vx*dt` (python floats) | 48 | 1× (baseline) |
|
|
117
117
|
| `oop-numpy` — objects holding `(2,)` numpy arrays | 605 | 13× slower |
|
|
118
118
|
| `micro-ecs-zip-rows` — `for p, v in zip(qr.pos, qr.vel)` | 744 | 15× slower |
|
|
119
119
|
| `micro-ecs-pool-loop` — `for pool: for i: pool.f[i]` | 870 | 18× slower |
|
|
120
120
|
| `micro-ecs-get-entity` — `world.get_entity(eid)` per entity | 1450 | 30× slower |
|
|
121
|
-
| `micro-ecs-index` — `qr.f[i]` per entity (an `np.searchsorted` each) | 3040 | 64× slower |
|
|
122
121
|
|
|
123
122
|
Three things to take from it:
|
|
124
123
|
|
|
125
|
-
1. **Vectorized wins big.** Batched ops (`
|
|
124
|
+
1. **Vectorized wins big.** Batched ops (`Field` or per-pool) run at 1–2 ns/entity — **27–52×
|
|
126
125
|
faster** than the *fastest* OOP loop. Same for data-parallel branches: an `np.where` clamp or
|
|
127
126
|
bounce is ~34× faster than a per-entity `if`.
|
|
128
|
-
2. **Per-entity loops are a cliff, not a tie.** Every per-entity microecs path is **15–
|
|
127
|
+
2. **Per-entity loops are a cliff, not a tie.** Every per-entity microecs path is **15–30× slower**
|
|
129
128
|
than idiomatic float-based OOP — because microecs is numpy-backed, so a per-entity step pays
|
|
130
129
|
numpy's tiny-array overhead (`oop-numpy` shows the same ~13× tax). One unavoidable per-entity
|
|
131
130
|
pass (~750 ns/entity) costs ~500× a vectorized op (~1.5 ns) and will dominate the frame.
|
|
132
|
-
3. **If you must loop, loop right.** `zip`-rows (15×) < pool-loop (18×) < `get_entity` (30×)
|
|
133
|
-
`qr.
|
|
131
|
+
3. **If you must loop, loop right.** `zip`-rows (15×) < pool-loop (18×) < `get_entity` (30×).
|
|
132
|
+
For random single-entity access use `world.get_entity(qr.entity_ids[i])` — there's no `qr.f[i]`
|
|
133
|
+
shortcut (the entity axis is off-limits on a query; it raises).
|
|
134
134
|
|
|
135
135
|
**Rule of thumb:** keep systems vectorized and push branches into `np.where` / `np.clip`. If a
|
|
136
136
|
workload is *irreducibly* per-entity (data-dependent control flow), plain python objects beat
|
|
@@ -140,27 +140,43 @@ microecs ~15× — use them there. microecs is the right tool for **vectorizable
|
|
|
140
140
|
|
|
141
141
|
## How much are `Pool` and `QueryResult` numpy-like and corner cases
|
|
142
142
|
|
|
143
|
-
Given `qr=world.query(A, B)`, then `qr.position` returns a `
|
|
143
|
+
Given `qr=world.query(A, B)`, then `qr.position` returns a `Field`: a view over the matching pools that behaves like one contiguous-like `(N, *e)`
|
|
144
144
|
numpy array (e.g. `(N, 2)` for a `(2,)` field). It applies each op **per pool** and stitches result back.
|
|
145
145
|
|
|
146
146
|
That covers elementwise math and ufuncs (e.g. `np.where`, `np.linalg.norm(..., axis=1)` etc.), broadcasting (every operand shape numpy accepts, and it raises on the ones numpy rejects). See `test/unit/test_field_numpy_parity.py` for a whole set of operations comparing both.
|
|
147
147
|
|
|
148
148
|
Edge cases worth knowing:
|
|
149
149
|
|
|
150
|
-
- **Not a full ndarray — these raise, never lie.** Entity-axis indexing
|
|
151
|
-
`qr.f[i]
|
|
152
|
-
methods/attrs (`.sum()`, `.mean()`, `.dtype`, `.ndim`, `.T`). Need
|
|
153
|
-
first with `qr.f.numpy()`.
|
|
150
|
+
- **Not a full ndarray — these raise, never lie.** Entity-axis indexing of any kind
|
|
151
|
+
(`qr.f[i]`, `qr.f[:]`, `qr.f[2:4]`, `qr.f[mask]`, fancy), partial entity writes, and ndarray
|
|
152
|
+
methods/attrs (`.sum()`, `.mean()`, `.dtype`, `.ndim`, `.T`). Need a single entity? Use
|
|
153
|
+
`world.get_entity(qr.entity_ids[i])`. Need a real array? Materialize first with `qr.f.numpy()`.
|
|
154
154
|
- **Axis-0 ops are per-pool, not global (footgun).** `np.sort` / `np.cumsum` / `np.sum` over
|
|
155
155
|
`axis=0` run within each pool and reset at pool boundaries — they do **not** see all entities
|
|
156
156
|
at once, so they differ from numpy. They're allowed, but if you want a global result, do
|
|
157
157
|
`qr.f.numpy()` first. A reduction that collapses the entity axis is rejected when its length no
|
|
158
158
|
longer matches the pool's row count.
|
|
159
159
|
- **Operands must come from the same query.** Alignment is per-pool, not by flat index, so don't
|
|
160
|
-
mix a `
|
|
160
|
+
mix a `Field` from one `world.query(...)` into an op on another.
|
|
161
161
|
- **Reserved field names.** A field is read back as `qr.<field>` and `entity.<field>`, so it may not
|
|
162
162
|
collide with an attribute or method of `QueryResult` or `Entity` — `World(...)` rejects such a
|
|
163
163
|
component at construction instead of silently shadowing it. The reserved set (source of truth:
|
|
164
|
-
`
|
|
164
|
+
`QUERY_RESULT_INTERNAL_ATTRS` in `query_result.py`, `ENTITY_INTERNAL_ATTRS` in `entity.py`):
|
|
165
165
|
- from `QueryResult`: `pool_list`, `entity_ids`, `fields`, `_data`, `_len`, `_field_shapes`, `_field_dtypes`
|
|
166
166
|
- from `Entity`: `entity_id`, `get_components`, `get_fields`, `to_dict`, `_eid_to_pool_ix`, `_pool_to_components`
|
|
167
|
+
|
|
168
|
+
## Mutation timing: field writes are eager, structural changes are deferred
|
|
169
|
+
|
|
170
|
+
One frame holds two different timings. Know which is which:
|
|
171
|
+
|
|
172
|
+
- **Structural changes are lazy (command-buffered).** `add_entity`, `remove_entity`, `add_component`,
|
|
173
|
+
`remove_component` only queue a command; they take effect at the next `world.update()`. This is what
|
|
174
|
+
keeps queries stable within a tick — pools don't move under a running system.
|
|
175
|
+
- **Field writes are eager.** A write through an `Entity` (`e.position = ...`, `e.position += ...`),
|
|
176
|
+
`world.set_entity_data(id, f, v)`, and the vectorized `qr.field[:] = ...` path all write straight into
|
|
177
|
+
the pool buffer and are visible immediately — no `update()` needed.
|
|
178
|
+
|
|
179
|
+
So inside one tick: a freshly spawned entity is **not** visible until `update()`, but a field write on an
|
|
180
|
+
already-committed entity **is** visible at once. Rule of thumb: **structure is deferred, data is live.**
|
|
181
|
+
If a field write must be ordered against a spawn/despawn, do the structural change, call `update()`, then
|
|
182
|
+
write.
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
"""init file"""
|
|
2
2
|
|
|
3
3
|
from .world import World
|
|
4
|
-
from .query_result import QueryResult
|
|
4
|
+
from .query_result import QueryResult, Field
|
|
5
5
|
from .pool import Pool
|
|
6
6
|
from .entity import Entity
|
|
7
7
|
from .component import Component
|
|
8
8
|
from .utils import EntityId
|
|
9
9
|
|
|
10
|
-
__all__ = ["World", "QueryResult", "Pool", "Component", "Entity", "EntityId"]
|
|
10
|
+
__all__ = ["World", "QueryResult", "Field", "Pool", "Component", "Entity", "EntityId"]
|
|
@@ -57,7 +57,7 @@ class Entity:
|
|
|
57
57
|
except KeyError:
|
|
58
58
|
raise AttributeError(f"Entity {self.entity_id} not committed yet. Call `world.update()` (reading '{name}')")
|
|
59
59
|
|
|
60
|
-
if name not in (_fields := pool.
|
|
60
|
+
if name not in (_fields := pool.fields_set):
|
|
61
61
|
raise AttributeError(f"Attribute '{name}' not in entity fields: {_fields} (entity id: {self.entity_id})")
|
|
62
62
|
|
|
63
63
|
return pool.data[name][pool_index]
|
|
@@ -67,6 +67,6 @@ class Entity:
|
|
|
67
67
|
super().__setattr__(name, value)
|
|
68
68
|
return
|
|
69
69
|
pool, pool_index = self._eid_to_pool_ix[self.entity_id]
|
|
70
|
-
if name not in (_fields := pool.
|
|
70
|
+
if name not in (_fields := pool.fields_set):
|
|
71
71
|
raise AttributeError(f"Attribute '{name}' not in entity fields: {_fields} (entity id: {self.entity_id})")
|
|
72
72
|
pool.data[name][pool_index] = value
|
|
@@ -8,14 +8,15 @@ class Pool:
|
|
|
8
8
|
Pool has no concept of entity ids.
|
|
9
9
|
"""
|
|
10
10
|
INITIAL_CAPACITY = 100
|
|
11
|
-
RESERVED_NAMES = {"size", "capacity", "fields", "shapes", "dtypes", "data"}
|
|
11
|
+
RESERVED_NAMES = {"size", "capacity", "fields", "shapes", "dtypes", "data", "fields_set"}
|
|
12
12
|
|
|
13
13
|
def __init__(self, fields: list[str], shapes: list[Shape], dtypes: list[np.dtype]):
|
|
14
14
|
assert len(fields) == len(shapes) == len(dtypes), (len(fields), len(shapes), len(dtypes))
|
|
15
|
-
assert not (set(fields) & Pool.RESERVED_NAMES), f"One of {fields=} in {Pool.RESERVED_NAMES}"
|
|
15
|
+
assert not ((fields_set := set(fields)) & Pool.RESERVED_NAMES), f"One of {fields=} in {Pool.RESERVED_NAMES}"
|
|
16
16
|
self.fields = fields
|
|
17
17
|
self.shapes = shapes
|
|
18
18
|
self.dtypes = dtypes
|
|
19
|
+
self.fields_set = fields_set # useful for fast checking (e.g. in entity)
|
|
19
20
|
|
|
20
21
|
self.data: dict[str, np.ndarray] = {} # the actual data is stored in dict of dynamic arrrays, one per field
|
|
21
22
|
self.size = 0
|
|
@@ -8,7 +8,8 @@ from .pool import Pool
|
|
|
8
8
|
# Note: if QueryResult gets new fields, add them here! Otherwise the user code may overwrite them e.g. qr._len=xxx
|
|
9
9
|
QUERY_RESULT_INTERNAL_ATTRS = {"pool_list", "entity_ids", "fields", "_field_shapes", "_field_dtypes", "_data", "_len"}
|
|
10
10
|
|
|
11
|
-
class
|
|
11
|
+
class Field(np.lib.mixins.NDArrayOperatorsMixin):
|
|
12
|
+
"""Field is a single field (column) from a QueryResult object obtained from world.query(...)"""
|
|
12
13
|
def __init__(self, parts: list[np.ndarray]):
|
|
13
14
|
self.parts = parts
|
|
14
15
|
self._lens = [len(p) for p in self.parts]
|
|
@@ -22,7 +23,7 @@ class _Field(np.lib.mixins.NDArrayOperatorsMixin):
|
|
|
22
23
|
return np.concatenate(self.parts) if len(self.parts) != 1 else self.parts[0]
|
|
23
24
|
|
|
24
25
|
def _chunk(self, x: T, i: int) -> T:
|
|
25
|
-
if isinstance(x,
|
|
26
|
+
if isinstance(x, Field):
|
|
26
27
|
return x.parts[i]
|
|
27
28
|
if isinstance(x, np.ndarray) and x.ndim == len(self.shape) and x.shape[0] == self.len:
|
|
28
29
|
return x[self._bounds[i]:self._bounds[i + 1]]
|
|
@@ -30,16 +31,16 @@ class _Field(np.lib.mixins.NDArrayOperatorsMixin):
|
|
|
30
31
|
|
|
31
32
|
def _apply_fn_on_parts(self, fn: Callable, op_args: list, **kwargs):
|
|
32
33
|
# op args can be 1 element (-qr.velocity), 2 elements (qr.position * 0.1), 3 elements (np.where(a, b, c)), etc.
|
|
33
|
-
# all of them must be chunked based on how many we have in this
|
|
34
|
+
# all of them must be chunked based on how many we have in this Field so each subpart is called independently.
|
|
34
35
|
results = []
|
|
35
36
|
for i, part in enumerate(self.parts):
|
|
36
37
|
pool_args = [self._chunk(x, i) for x in op_args]
|
|
37
|
-
part_result:
|
|
38
|
+
part_result: Field = fn(*pool_args, **kwargs)
|
|
38
39
|
# we expect f(arr(N, ...)) -> arr(N, ...) where N = number of items in the pool
|
|
39
40
|
# for e.g. np.linalg.norm(velocity, axis=1) should do (N, 2) -> (N, 1) so the first axis is preserved
|
|
40
41
|
assert len(part_result) == part.shape[0], f"Result: {part_result.shape} vs {part.shape}"
|
|
41
42
|
results.append(part_result)
|
|
42
|
-
return
|
|
43
|
+
return Field(results)
|
|
43
44
|
|
|
44
45
|
def __array_ufunc__(self, ufunc, method, *inputs, out=None, **kwargs):
|
|
45
46
|
"""wrapper for elementwise (python) primitives, e.g. qr.position[:] += 1"""
|
|
@@ -48,7 +49,7 @@ class _Field(np.lib.mixins.NDArrayOperatorsMixin):
|
|
|
48
49
|
if out is None:
|
|
49
50
|
return self._apply_fn_on_parts(ufunc, inputs, **kwargs)
|
|
50
51
|
|
|
51
|
-
assert len(out) == 1 and isinstance(out[0],
|
|
52
|
+
assert len(out) == 1 and isinstance(out[0], Field), out
|
|
52
53
|
for i in range(len(self.parts)):
|
|
53
54
|
pool_args = [self._chunk(x, i) for x in inputs]
|
|
54
55
|
ufunc(*pool_args, out=out[0].parts[i], **kwargs)
|
|
@@ -64,7 +65,7 @@ class _Field(np.lib.mixins.NDArrayOperatorsMixin):
|
|
|
64
65
|
not (isinstance(key, tuple) and key and key[0] == slice(None))):
|
|
65
66
|
raise TypeError("entity-axis assignment crosses pools; use [:] or [:, k] or [i][...]")
|
|
66
67
|
|
|
67
|
-
if isinstance(value,
|
|
68
|
+
if isinstance(value, Field):
|
|
68
69
|
for i, part in enumerate(self.parts):
|
|
69
70
|
part[key] = value.parts[i]
|
|
70
71
|
return
|
|
@@ -77,16 +78,11 @@ class _Field(np.lib.mixins.NDArrayOperatorsMixin):
|
|
|
77
78
|
v[:] = chunk
|
|
78
79
|
|
|
79
80
|
def __getitem__(self, key):
|
|
80
|
-
if isinstance(key, (int, np.integer)): # qr.position[i] -> entity i's row, from its own pool
|
|
81
|
-
key = range(self.len)[key] # numpy's exact int-index rule, for free: wraps neg, raises on OOB
|
|
82
|
-
pool_ix = int(np.searchsorted(self._bounds, key, side="right")) - 1
|
|
83
|
-
return self.parts[pool_ix][key - self._bounds[pool_ix]]
|
|
84
|
-
|
|
85
81
|
if key is Ellipsis or (isinstance(key, tuple) and key and (key[0] is Ellipsis or key[0] == slice(None))):
|
|
86
|
-
return
|
|
82
|
+
return Field([part[key] for part in self.parts])
|
|
87
83
|
|
|
88
|
-
raise TypeError("
|
|
89
|
-
|
|
84
|
+
raise TypeError(("Only batch updates are supported, e.g. `qr.attr[:]=xxx` or `qr.attr[:, k]=xxx`. "
|
|
85
|
+
"Use .numpy() for a proper array. For entity-level ops use `world.get_entity(eid).attr=xxx`"))
|
|
90
86
|
|
|
91
87
|
def __iter__(self):
|
|
92
88
|
for part in self.parts:
|
|
@@ -113,7 +109,7 @@ class QueryResult:
|
|
|
113
109
|
def __getattr__(self, name):
|
|
114
110
|
if (data := self.__dict__.get("_data")) is not None and name in data:
|
|
115
111
|
# the 'or' part is in case no pools match the query and we want qr.position[:] += 1 still to work (noop)
|
|
116
|
-
return
|
|
112
|
+
return Field(data[name] or [np.empty((0, *self._field_shapes[name]), self._field_dtypes[name])])
|
|
117
113
|
raise AttributeError(f"'{name}' not part of {self.fields}")
|
|
118
114
|
|
|
119
115
|
def __setattr__(self, name, value):
|
|
@@ -21,8 +21,11 @@ class World:
|
|
|
21
21
|
self.extra_metadata = extra_metadata or []
|
|
22
22
|
assert isinstance(self.extra_metadata, list), type(self.extra_metadata)
|
|
23
23
|
self._check_components(components)
|
|
24
|
+
|
|
25
|
+
# pools management
|
|
24
26
|
self.pools: dict[PoolKey, Pool] = {}
|
|
25
27
|
self.pool_to_components: dict[Pool, list[ComponentType]] = {}
|
|
28
|
+
|
|
26
29
|
# components management
|
|
27
30
|
self.component_names = [x.__name__ for x in components]
|
|
28
31
|
self.component_types = set(components)
|
|
@@ -33,11 +36,14 @@ class World:
|
|
|
33
36
|
c: [f.metadata["dtype"] for f in fields(c)] for c in components}
|
|
34
37
|
self.component_to_field_names: dict[ComponentType, list[str]] = {
|
|
35
38
|
c: [f.name for f in fields(c)] for c in components}
|
|
36
|
-
#
|
|
39
|
+
# entities management
|
|
37
40
|
self._eid_to_pool_ix: dict[EntityId, tuple[Pool, int]] = {}
|
|
38
41
|
self._pool_ids: dict[Pool, list[EntityId]] = {}
|
|
39
42
|
self._last_id: EntityId = -1
|
|
40
|
-
|
|
43
|
+
# a dictionary of all live entities in 'eager' mode (before update()). The actual entity is created at request
|
|
44
|
+
# in get_entity, so we don't pay for the Entity object unless it's explicitly requested by the user.
|
|
45
|
+
self.live_entities: dict[EntityId, Entity | None] = {}
|
|
46
|
+
|
|
41
47
|
# command buffer management. {add/remove}_{entity/component} are lazy. Taken into account after update().
|
|
42
48
|
self._command_buffer: list[Callable] = []
|
|
43
49
|
self._cache: dict[tuple[PoolKey, PoolKey], QueryResult] = {} # include+exclude key
|
|
@@ -58,7 +64,7 @@ class World:
|
|
|
58
64
|
"""Adds an entity to the world based on components (data->kwargs). Returns an entity id. Lazy; call update()"""
|
|
59
65
|
self._check_components_against_pool(components, **kwargs)
|
|
60
66
|
self._last_id += 1
|
|
61
|
-
self.live_entities[self._last_id] =
|
|
67
|
+
self.live_entities[self._last_id] = None # add the id the live_entities, but the object is created in get_entity
|
|
62
68
|
self._command_buffer.append(partial(self._add_to_pool, entity_id=self._last_id,
|
|
63
69
|
components=components, **kwargs))
|
|
64
70
|
logger.debug(f"Created entity. ID: {self._last_id}. Components: {[c.__name__ for c in components]}")
|
|
@@ -73,6 +79,9 @@ class World:
|
|
|
73
79
|
def get_entity(self, entity_id: EntityId) -> Entity:
|
|
74
80
|
"""Gets the entity reference given an entity id. Used for 'object-like' ops. Lazy; call world.update()"""
|
|
75
81
|
assert entity_id in self.live_entities, f"Entity id: {entity_id} not in the world"
|
|
82
|
+
if self.live_entities[entity_id] is None:
|
|
83
|
+
# only instantiate on first request, so the object is not created for no reason at add_entity time.
|
|
84
|
+
self.live_entities[entity_id] = Entity(entity_id, self._eid_to_pool_ix, self.pool_to_components)
|
|
76
85
|
return self.live_entities[entity_id]
|
|
77
86
|
|
|
78
87
|
def set_entity_data(self, entity_id: EntityId, field_name: str, value: np.ndarray):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: microecs
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: MicroECS: Minimal Entity Component System (ECS) in python and numpy
|
|
5
5
|
Home-page: https://gitlab.com/meehai/microecs
|
|
6
6
|
License: MIT
|
|
@@ -76,7 +76,7 @@ class MotionSystem:
|
|
|
76
76
|
def __call__(self, world: World):
|
|
77
77
|
qr = world.query(HasPosition, HasVelocity) # 'exclude' is optional
|
|
78
78
|
qr.position[:] = qr.position + qr.velocity * DT # writes back to all the underlying pools using numpy's rules
|
|
79
|
-
# Alternative for per-pool update. Less ergonomic, but maybe faster in extreme cases as it avoids the
|
|
79
|
+
# Alternative for per-pool update. Less ergonomic, but maybe faster in extreme cases as it avoids the Field obj
|
|
80
80
|
for pool in qr.pool_list:
|
|
81
81
|
pool.position[:] = pool.position + pool.velocity * DT
|
|
82
82
|
|
|
@@ -114,7 +114,7 @@ for pos, color, radius in zip(qr.position, qr.color, qr.radius): # one entit
|
|
|
114
114
|
rl.DrawCircle(int(pos[0]), int(pos[1]), float(radius[0]), color) # per-entity by necessity (no "draw all")
|
|
115
115
|
```
|
|
116
116
|
|
|
117
|
-
|
|
117
|
+
For random single-entity access, `e = world.get_entity(eid)` (or `world.get_entity(qr.entity_ids[i])` to go by query position). That's slower in the general case and should be avoided in expensive loops, like rendering, even if it is a bit more ergonomic. See benchmark below. (There is no `qr.field[i]` shortcut — the entity axis is off-limits on a query result; it raises.)
|
|
118
118
|
|
|
119
119
|
<details>
|
|
120
120
|
<summary> Microbenchmark: ECS vs OOP on a simple physics step </summary>
|
|
@@ -124,25 +124,25 @@ Benchmark: we run the same physics step `pos += vel*dt` over N=100k entities spl
|
|
|
124
124
|
| pattern | ns/entity | vs OOP-scalar |
|
|
125
125
|
|---|---:|---:|
|
|
126
126
|
| `micro-ecs-pool-vectorized` — `for pool: pool.f[:] = pool.f + …` | 0.9 | **52× faster** |
|
|
127
|
-
| `micro-ecs-vectorized` — `qr.f[:] = qr.f + …` (the `
|
|
127
|
+
| `micro-ecs-vectorized` — `qr.f[:] = qr.f + …` (the `Field`) | 1.8 | **27× faster** |
|
|
128
128
|
| **`oop-scalar`** — `for o: o.x += o.vx*dt` (python floats) | 48 | 1× (baseline) |
|
|
129
129
|
| `oop-numpy` — objects holding `(2,)` numpy arrays | 605 | 13× slower |
|
|
130
130
|
| `micro-ecs-zip-rows` — `for p, v in zip(qr.pos, qr.vel)` | 744 | 15× slower |
|
|
131
131
|
| `micro-ecs-pool-loop` — `for pool: for i: pool.f[i]` | 870 | 18× slower |
|
|
132
132
|
| `micro-ecs-get-entity` — `world.get_entity(eid)` per entity | 1450 | 30× slower |
|
|
133
|
-
| `micro-ecs-index` — `qr.f[i]` per entity (an `np.searchsorted` each) | 3040 | 64× slower |
|
|
134
133
|
|
|
135
134
|
Three things to take from it:
|
|
136
135
|
|
|
137
|
-
1. **Vectorized wins big.** Batched ops (`
|
|
136
|
+
1. **Vectorized wins big.** Batched ops (`Field` or per-pool) run at 1–2 ns/entity — **27–52×
|
|
138
137
|
faster** than the *fastest* OOP loop. Same for data-parallel branches: an `np.where` clamp or
|
|
139
138
|
bounce is ~34× faster than a per-entity `if`.
|
|
140
|
-
2. **Per-entity loops are a cliff, not a tie.** Every per-entity microecs path is **15–
|
|
139
|
+
2. **Per-entity loops are a cliff, not a tie.** Every per-entity microecs path is **15–30× slower**
|
|
141
140
|
than idiomatic float-based OOP — because microecs is numpy-backed, so a per-entity step pays
|
|
142
141
|
numpy's tiny-array overhead (`oop-numpy` shows the same ~13× tax). One unavoidable per-entity
|
|
143
142
|
pass (~750 ns/entity) costs ~500× a vectorized op (~1.5 ns) and will dominate the frame.
|
|
144
|
-
3. **If you must loop, loop right.** `zip`-rows (15×) < pool-loop (18×) < `get_entity` (30×)
|
|
145
|
-
`qr.
|
|
143
|
+
3. **If you must loop, loop right.** `zip`-rows (15×) < pool-loop (18×) < `get_entity` (30×).
|
|
144
|
+
For random single-entity access use `world.get_entity(qr.entity_ids[i])` — there's no `qr.f[i]`
|
|
145
|
+
shortcut (the entity axis is off-limits on a query; it raises).
|
|
146
146
|
|
|
147
147
|
**Rule of thumb:** keep systems vectorized and push branches into `np.where` / `np.clip`. If a
|
|
148
148
|
workload is *irreducibly* per-entity (data-dependent control flow), plain python objects beat
|
|
@@ -152,27 +152,43 @@ microecs ~15× — use them there. microecs is the right tool for **vectorizable
|
|
|
152
152
|
|
|
153
153
|
## How much are `Pool` and `QueryResult` numpy-like and corner cases
|
|
154
154
|
|
|
155
|
-
Given `qr=world.query(A, B)`, then `qr.position` returns a `
|
|
155
|
+
Given `qr=world.query(A, B)`, then `qr.position` returns a `Field`: a view over the matching pools that behaves like one contiguous-like `(N, *e)`
|
|
156
156
|
numpy array (e.g. `(N, 2)` for a `(2,)` field). It applies each op **per pool** and stitches result back.
|
|
157
157
|
|
|
158
158
|
That covers elementwise math and ufuncs (e.g. `np.where`, `np.linalg.norm(..., axis=1)` etc.), broadcasting (every operand shape numpy accepts, and it raises on the ones numpy rejects). See `test/unit/test_field_numpy_parity.py` for a whole set of operations comparing both.
|
|
159
159
|
|
|
160
160
|
Edge cases worth knowing:
|
|
161
161
|
|
|
162
|
-
- **Not a full ndarray — these raise, never lie.** Entity-axis indexing
|
|
163
|
-
`qr.f[i]
|
|
164
|
-
methods/attrs (`.sum()`, `.mean()`, `.dtype`, `.ndim`, `.T`). Need
|
|
165
|
-
first with `qr.f.numpy()`.
|
|
162
|
+
- **Not a full ndarray — these raise, never lie.** Entity-axis indexing of any kind
|
|
163
|
+
(`qr.f[i]`, `qr.f[:]`, `qr.f[2:4]`, `qr.f[mask]`, fancy), partial entity writes, and ndarray
|
|
164
|
+
methods/attrs (`.sum()`, `.mean()`, `.dtype`, `.ndim`, `.T`). Need a single entity? Use
|
|
165
|
+
`world.get_entity(qr.entity_ids[i])`. Need a real array? Materialize first with `qr.f.numpy()`.
|
|
166
166
|
- **Axis-0 ops are per-pool, not global (footgun).** `np.sort` / `np.cumsum` / `np.sum` over
|
|
167
167
|
`axis=0` run within each pool and reset at pool boundaries — they do **not** see all entities
|
|
168
168
|
at once, so they differ from numpy. They're allowed, but if you want a global result, do
|
|
169
169
|
`qr.f.numpy()` first. A reduction that collapses the entity axis is rejected when its length no
|
|
170
170
|
longer matches the pool's row count.
|
|
171
171
|
- **Operands must come from the same query.** Alignment is per-pool, not by flat index, so don't
|
|
172
|
-
mix a `
|
|
172
|
+
mix a `Field` from one `world.query(...)` into an op on another.
|
|
173
173
|
- **Reserved field names.** A field is read back as `qr.<field>` and `entity.<field>`, so it may not
|
|
174
174
|
collide with an attribute or method of `QueryResult` or `Entity` — `World(...)` rejects such a
|
|
175
175
|
component at construction instead of silently shadowing it. The reserved set (source of truth:
|
|
176
|
-
`
|
|
176
|
+
`QUERY_RESULT_INTERNAL_ATTRS` in `query_result.py`, `ENTITY_INTERNAL_ATTRS` in `entity.py`):
|
|
177
177
|
- from `QueryResult`: `pool_list`, `entity_ids`, `fields`, `_data`, `_len`, `_field_shapes`, `_field_dtypes`
|
|
178
178
|
- from `Entity`: `entity_id`, `get_components`, `get_fields`, `to_dict`, `_eid_to_pool_ix`, `_pool_to_components`
|
|
179
|
+
|
|
180
|
+
## Mutation timing: field writes are eager, structural changes are deferred
|
|
181
|
+
|
|
182
|
+
One frame holds two different timings. Know which is which:
|
|
183
|
+
|
|
184
|
+
- **Structural changes are lazy (command-buffered).** `add_entity`, `remove_entity`, `add_component`,
|
|
185
|
+
`remove_component` only queue a command; they take effect at the next `world.update()`. This is what
|
|
186
|
+
keeps queries stable within a tick — pools don't move under a running system.
|
|
187
|
+
- **Field writes are eager.** A write through an `Entity` (`e.position = ...`, `e.position += ...`),
|
|
188
|
+
`world.set_entity_data(id, f, v)`, and the vectorized `qr.field[:] = ...` path all write straight into
|
|
189
|
+
the pool buffer and are visible immediately — no `update()` needed.
|
|
190
|
+
|
|
191
|
+
So inside one tick: a freshly spawned entity is **not** visible until `update()`, but a field write on an
|
|
192
|
+
already-committed entity **is** visible at once. Rule of thumb: **structure is deferred, data is live.**
|
|
193
|
+
If a field write must be ordered against a spawn/despawn, do the structural change, call `update()`, then
|
|
194
|
+
write.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|