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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: microecs
3
- Version: 0.4.0
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 _Field obj
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
- There are other ways to extract a single entity e.g. `qr.some_field[i]` or `e = world.get_entity(eid)`, but these are slower in the general case and should be avoided in expensive loops, like rendering, even if they are a bit more ergonomic. See benchmark below.
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 `_Field`) | 1.8 | **27× faster** |
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 (`_Field` or per-pool) run at 1–2 ns/entity — **27–52×
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–64× slower**
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.f[i]` (64× — never in a hot loop; `qr.f[i] += …` also raises, you must bind the row first).
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 `_Field`: a view over the matching pools that behaves like one contiguous-like `(N, *e)`
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 beyond a single
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 any of these? Materialize
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 `_Field` from one `world.query(...)` into an op on another.
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
- `QUERY_RESULT_RESERVED_NAMES` in `query_result.py`, `ENTITY_RESERVED_NAMES` in `entity.py`):
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 _Field obj
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
- There are other ways to extract a single entity e.g. `qr.some_field[i]` or `e = world.get_entity(eid)`, but these are slower in the general case and should be avoided in expensive loops, like rendering, even if they are a bit more ergonomic. See benchmark below.
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 `_Field`) | 1.8 | **27× faster** |
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 (`_Field` or per-pool) run at 1–2 ns/entity — **27–52×
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–64× slower**
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.f[i]` (64× — never in a hot loop; `qr.f[i] += …` also raises, you must bind the row first).
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 `_Field`: a view over the matching pools that behaves like one contiguous-like `(N, *e)`
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 beyond a single
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 any of these? Materialize
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 `_Field` from one `world.query(...)` into an op on another.
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
- `QUERY_RESULT_RESERVED_NAMES` in `query_result.py`, `ENTITY_RESERVED_NAMES` in `entity.py`):
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.fields):
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.fields):
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 _Field(np.lib.mixins.NDArrayOperatorsMixin):
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, _Field):
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 _Field so each subpart is called independently.
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: _Field = fn(*pool_args, **kwargs)
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 _Field(results)
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], _Field), out
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, _Field):
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 _Field([part[key] for part in self.parts])
82
+ return Field([part[key] for part in self.parts])
87
83
 
88
- raise TypeError("Unsupported indexing. Use .numpy() for a proper array. To set items, use qr.attr[:, k]=xxx. "
89
- "For fancy indexing qr[i, 0:3, k], use qr[i][0:3, k].")
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 _Field(data[name] or [np.empty((0, *self._field_shapes[name]), self._field_dtypes[name])])
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
- # entity id management
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
- self.live_entities: dict[EntityId, Entity] = {} # a cache of all live entities in 'eager' mode (before update())
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] = Entity(self._last_id, self._eid_to_pool_ix, self.pool_to_components)
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.0
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 _Field obj
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
- There are other ways to extract a single entity e.g. `qr.some_field[i]` or `e = world.get_entity(eid)`, but these are slower in the general case and should be avoided in expensive loops, like rendering, even if they are a bit more ergonomic. See benchmark below.
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 `_Field`) | 1.8 | **27× faster** |
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 (`_Field` or per-pool) run at 1–2 ns/entity — **27–52×
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–64× slower**
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.f[i]` (64× — never in a hot loop; `qr.f[i] += …` also raises, you must bind the row first).
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 `_Field`: a view over the matching pools that behaves like one contiguous-like `(N, *e)`
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 beyond a single
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 any of these? Materialize
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 `_Field` from one `world.query(...)` into an op on another.
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
- `QUERY_RESULT_RESERVED_NAMES` in `query_result.py`, `ENTITY_RESERVED_NAMES` in `entity.py`):
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.
@@ -3,7 +3,7 @@ from pathlib import Path
3
3
  from setuptools import setup, find_packages
4
4
 
5
5
  NAME = "microecs"
6
- VERSION = "0.4.0"
6
+ VERSION = "0.4.2"
7
7
  DESCRIPTION = "MicroECS: Minimal Entity Component System (ECS) in python and numpy"
8
8
  URL = "https://gitlab.com/meehai/microecs"
9
9
 
File without changes
File without changes
File without changes
File without changes