microecs 0.3.0__tar.gz → 0.3.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.3.0
3
+ Version: 0.3.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
@@ -24,8 +24,8 @@ There are only 4 primitives (bottom up): `Component`, `Pool`, `QueryResult`, `Wo
24
24
 
25
25
  - `Component` is a simple python dataclass holding only data. All entries must be numpy arrays with metadata fields: shape and dtype. We support 5 dtypes only: `int32`, `float32`, `bool`, `str` and `object`. A component with no fields is a valid **tag** for querying (e.g. `class Frozen(Component): pass`).
26
26
  - `Pool` is a simple 'archetype' dynamic array, holding entities of the same type (same set of components). Usses `Components` metadata to construct contiguous arrays for all entities of the same type.
27
- - `QueryResult` is a list of pools that match some query on all the entities of the `World`. It acts as a contiguous numpy-like container that implements numpy's `__array_function__` and `__array_ufunc__`. For all intents and purposes it should feel like a `(N, ...)` view over all the entities. If you need a numpy array (not all ops are supported, for e.g. indexing on the first axis), use `QueryResult.numpy()`. It also exposes `entity_ids`: a flat `(N,)` array of the matched entities' ids, in the same pool-by-pool order as the fields, so you can `zip(qr.entity_ids, qr.position)` or feed an id back to `world.get_entity` / `world.remove_entity`.
28
- - `World` is a manager of `Pools` and has an overview of all the entities in the scene. It also manages the migration of entities from one pool to the other. A `World` can also require extra metadata keys on every field via `World(extra_field_metadata=["serializable"])`, to enforce component-level behavior such as field serialization.
27
+ - `QueryResult` is a list of pools that match some query on all the entities of the `World`. It acts as a contiguous numpy-like container that implements numpy's `__array_function__` and `__array_ufunc__`. For all intents and purposes it should feel like a `(N, ...)` view over all the entities. If you need a numpy array (not all ops are supported, for e.g. indexing on the first axis), use `QueryResult.numpy()` (see [the field numpy contract](#the-field-_field-numpy-contract) below). It also exposes `entity_ids`: a flat `(N,)` array of the matched entities' ids, in the same pool-by-pool order as the fields, so you can `zip(qr.entity_ids, qr.position)` or feed an id back to `world.get_entity` / `world.remove_entity`.
28
+ - `World` is a manager of `Pools` and has an overview of all the entities in the scene. It also manages the migration of entities from one pool to the other. A `World` can also require extra metadata keys on every field via `World(extra_metadata=["serializable"])`, to enforce component-level behavior such as field serialization.
29
29
 
30
30
  Few relevant concepts:
31
31
 
@@ -43,7 +43,8 @@ from microecs import World, Component
43
43
 
44
44
  # components
45
45
  class HasPosition(Component):
46
- position: np.ndarray = field(metadata={"shape": (2, ), "dtype": "float32"}) # 'shape' + 'dtype' are always required
46
+ # 'shape' + 'dtype' are always required. For additional metadata (e.g. examples/03-serialization) use extra_metadata
47
+ position: np.ndarray = field(metadata={"shape": (2, ), "dtype": "float32"})
47
48
  class HasVelocity(Component):
48
49
  velocity: np.ndarray = field(metadata={"shape": (2, ), "dtype": "float32"})
49
50
  class HasColor(Component):
@@ -68,7 +69,7 @@ def main():
68
69
  render_system: list[Callable] = RenderSystem()
69
70
  update_systems: list[Callable] = [MotionSystem()]
70
71
 
71
- world = World(components=[HasPosition, HasColor, HasVelocity], extra_field_metadata=None)
72
+ world = World(components=[HasPosition, HasColor, HasVelocity], extra_metadata=None) # extra_metadata is optional
72
73
  for _ in range(n_objects):
73
74
  # NOTE: world.{add/remove}_{entity/component} are lazy. They take effect after the first world.update() call.
74
75
  world.add_entity(components=(HasPosition, HasVelocity, HasColor), # tuple of components (types)
@@ -87,3 +88,67 @@ def main():
87
88
  render_system(world=world)
88
89
  rl.EndDrawing()
89
90
  ```
91
+
92
+ ## The field (`_Field`) numpy contract
93
+
94
+ `qr.position` returns a `_Field`: a view over the matching pools that behaves like one `(N, *e)`
95
+ numpy array (e.g. `(N, 2)` for a `(2,)` field). It applies each op **per pool** and stitches the
96
+ result back. So the contract is exactly:
97
+
98
+ > Any op that treats rows independently behaves **identically to numpy** on the concatenated
99
+ > `(N, *e)` array — same values, same shape, and the same error on bad shapes.
100
+
101
+ That covers elementwise math and ufuncs, broadcasting (every operand shape numpy accepts, and it
102
+ raises on the ones numpy rejects), comparisons, `np.where` / `np.clip`, batched `np.matmul`,
103
+ **feature-axis** reductions/indexing (`np.linalg.norm(qr.velocity, axis=1)`, `qr.pose[:, 0]`), and
104
+ write-back broadcasting (`qr.position[:] = <scalar | (*e,) | (N, *e)>`). Pinned in
105
+ `test/unit/test_field_numpy_parity.py`.
106
+
107
+ Edge cases worth knowing:
108
+
109
+ - **Not a full ndarray — these *raise*, never lie.** Entity-axis indexing beyond a single
110
+ `qr.f[i]` (`qr.f[:]`, `qr.f[2:4]`, `qr.f[mask]`, fancy), partial entity writes, and ndarray
111
+ methods/attrs (`.sum()`, `.mean()`, `.dtype`, `.ndim`, `.T`). Need any of these? Materialize
112
+ first with `qr.f.numpy()`.
113
+ - **Axis-0 ops are per-pool, not global (footgun).** `np.sort` / `np.cumsum` / `np.sum` over
114
+ `axis=0` run *within each pool* and reset at pool boundaries — they do **not** see all entities
115
+ at once, so they differ from numpy. They're allowed, but if you want a global result, do
116
+ `qr.f.numpy()` first. A reduction that collapses the entity axis is rejected when its length no
117
+ longer matches the pool's row count.
118
+ - **Operands must come from the same query.** Alignment is per-pool, not by flat index, so don't
119
+ mix a `_Field` from one `world.query(...)` into an op on another.
120
+
121
+ ## Benchmark: ECS vs OOP
122
+
123
+ The same physics step -- `pos += vel*dt` over N=100k entities split across 2 pools -- computed 8
124
+ ways, all verified to produce the identical result. Reproduce with
125
+ `python test/manual/perf/bench_ecs_vs_oop.py` (it prints `{mode: avg_seconds_per_step}`).
126
+
127
+ | pattern | ns/entity | vs OOP-scalar |
128
+ |---|---:|---:|
129
+ | `micro-ecs-pool-vectorized` — `for pool: pool.f[:] = pool.f + …` | 0.9 | **52× faster** |
130
+ | `micro-ecs-vectorized` — `qr.f[:] = qr.f + …` (the `_Field`) | 1.8 | **27× faster** |
131
+ | **`oop-scalar`** — `for o: o.x += o.vx*dt` (python floats) | 48 | 1× (baseline) |
132
+ | `oop-numpy` — objects holding `(2,)` numpy arrays | 605 | 13× slower |
133
+ | `micro-ecs-zip-rows` — `for p, v in zip(qr.pos, qr.vel)` | 744 | 15× slower |
134
+ | `micro-ecs-pool-loop` — `for pool: for i: pool.f[i]` | 870 | 18× slower |
135
+ | `micro-ecs-get-entity` — `world.get_entity(eid)` per entity | 1674 | 35× slower |
136
+ | `micro-ecs-index` — `qr.f[i]` per entity (an `np.searchsorted` each) | 4573 | 95× slower |
137
+
138
+ *Intel Core Ultra 7 165H, single-threaded, python 3.12 / numpy 2.2. ns/entity = seconds/step ÷ N.*
139
+
140
+ Three things to take from it:
141
+
142
+ 1. **Vectorized wins big.** Batched ops (`_Field` or per-pool) run at 1–2 ns/entity — **27–52×
143
+ faster** than the *fastest* OOP loop. Same for data-parallel branches: an `np.where` clamp or
144
+ bounce is ~34× faster than a per-entity `if`.
145
+ 2. **Per-entity loops are a cliff, not a tie.** Every per-entity microecs path is **15–95× slower**
146
+ than idiomatic float-based OOP — because microecs is numpy-backed, so a per-entity step pays
147
+ numpy's tiny-array overhead (`oop-numpy` shows the same ~13× tax). One unavoidable per-entity
148
+ pass (~750 ns/entity) costs ~500× a vectorized op (~1.5 ns) and will dominate the frame.
149
+ 3. **If you must loop, loop right.** `zip`-rows (15×) < pool-loop (18×) < `get_entity` (35×) <
150
+ `qr.f[i]` (95× — never in a hot loop; `qr.f[i] += …` also raises, you must bind the row first).
151
+
152
+ **Rule of thumb:** keep systems vectorized and push branches into `np.where` / `np.clip`. If a
153
+ workload is *irreducibly* per-entity (data-dependent control flow), plain python objects beat
154
+ microecs ~15× — use them there. microecs is the right tool for **vectorizable** simulation.
@@ -0,0 +1,142 @@
1
+ # MicroECS
2
+
3
+ Minimal (~300 LoC) Entity Component System in python and numpy. Examples also use raylib for rendering.
4
+
5
+ Usage:
6
+
7
+ - `pip install -r requirements.txt`
8
+ - Sandbox: `python main.py`
9
+ - Tests: `pytest test/`
10
+
11
+ There are only 4 primitives (bottom up): `Component`, `Pool`, `QueryResult`, `World`:
12
+
13
+ - `Component` is a simple python dataclass holding only data. All entries must be numpy arrays with metadata fields: shape and dtype. We support 5 dtypes only: `int32`, `float32`, `bool`, `str` and `object`. A component with no fields is a valid **tag** for querying (e.g. `class Frozen(Component): pass`).
14
+ - `Pool` is a simple 'archetype' dynamic array, holding entities of the same type (same set of components). Usses `Components` metadata to construct contiguous arrays for all entities of the same type.
15
+ - `QueryResult` is a list of pools that match some query on all the entities of the `World`. It acts as a contiguous numpy-like container that implements numpy's `__array_function__` and `__array_ufunc__`. For all intents and purposes it should feel like a `(N, ...)` view over all the entities. If you need a numpy array (not all ops are supported, for e.g. indexing on the first axis), use `QueryResult.numpy()` (see [the field numpy contract](#the-field-_field-numpy-contract) below). It also exposes `entity_ids`: a flat `(N,)` array of the matched entities' ids, in the same pool-by-pool order as the fields, so you can `zip(qr.entity_ids, qr.position)` or feed an id back to `world.get_entity` / `world.remove_entity`.
16
+ - `World` is a manager of `Pools` and has an overview of all the entities in the scene. It also manages the migration of entities from one pool to the other. A `World` can also require extra metadata keys on every field via `World(extra_metadata=["serializable"])`, to enforce component-level behavior such as field serialization.
17
+
18
+ Few relevant concepts:
19
+
20
+ - `Pool` operates on array indices, while `World` operates on entity IDs (also integers). This allows seamless movement between pools while the high-level systems still working as intended.
21
+ - All mutable operations on `World` are lazy. These are: `add_entity`, `remove_entity`, `add_component`, `remove_component`. They are added to a command buffer which is only executed when calling `world.update()`.
22
+ - `Systems` are a convention, they are not part of this library. They can be defined at application level and act as hooks or callbacks. The `World` object doesn't need to know more than entities and components.
23
+
24
+ Super simplified main loop structure:
25
+
26
+ ```python
27
+ from typing import Callable
28
+ import numpy as np
29
+ import raylib as rl
30
+ from microecs import World, Component
31
+
32
+ # components
33
+ class HasPosition(Component):
34
+ # 'shape' + 'dtype' are always required. For additional metadata (e.g. examples/03-serialization) use extra_metadata
35
+ position: np.ndarray = field(metadata={"shape": (2, ), "dtype": "float32"})
36
+ class HasVelocity(Component):
37
+ velocity: np.ndarray = field(metadata={"shape": (2, ), "dtype": "float32"})
38
+ class HasColor(Component):
39
+ color: np.ndarray = field(metadata={"shape": (4, ), "dtype": "int32"})
40
+
41
+ # systems: Note they are a convention!
42
+ class RenderSystem:
43
+ def __call__(self, world: World):
44
+ query_result = world.query(HasPosition, HasColor, exclude=[]) # contiguous-like view of all entities matching
45
+ for position, color in zip(query_result.position, query_result.color): # draw each entity
46
+ DrawEntity(position, color)
47
+
48
+ class MotionSystem:
49
+ def __call__(self, world: World):
50
+ qr = world.query(HasPosition, HasVelocity) # 'exclude' is optional
51
+ qr.position[:] = qr.position + qr.velocity * DT # writes back to all the underlying pools using numpy's rules
52
+ # Alternative for per-pool update. Less ergonomic, but maybe faster in extreme cases as it avoids the _Field obj
53
+ for pool in qr.pool_list:
54
+ pool.position[:] = pool.position + pool.velocity * DT
55
+
56
+ def main():
57
+ render_system: list[Callable] = RenderSystem()
58
+ update_systems: list[Callable] = [MotionSystem()]
59
+
60
+ world = World(components=[HasPosition, HasColor, HasVelocity], extra_metadata=None) # extra_metadata is optional
61
+ for _ in range(n_objects):
62
+ # NOTE: world.{add/remove}_{entity/component} are lazy. They take effect after the first world.update() call.
63
+ world.add_entity(components=(HasPosition, HasVelocity, HasColor), # tuple of components (types)
64
+ position= np.array((x, y), "float32"), # data as kwargs
65
+ color= np.array("black", dtype="int32"),
66
+ velocity= np.array((vx, vy), "float32"))
67
+
68
+ while not rl.WindowShouldClose():
69
+ world.update() # must be called at each tick so the lazy methods are processed and entities are updated
70
+ # update stuff...
71
+ _ = [system(world=world) for system in update_systems]
72
+ # draw stuff, e.g. using raylib
73
+ rl.BeginDrawing()
74
+ rl.ClearBackground(rl.RAYWHITE)
75
+ rl.DrawFPS(rl.GetScreenWidth() - 100, 0)
76
+ render_system(world=world)
77
+ rl.EndDrawing()
78
+ ```
79
+
80
+ ## The field (`_Field`) numpy contract
81
+
82
+ `qr.position` returns a `_Field`: a view over the matching pools that behaves like one `(N, *e)`
83
+ numpy array (e.g. `(N, 2)` for a `(2,)` field). It applies each op **per pool** and stitches the
84
+ result back. So the contract is exactly:
85
+
86
+ > Any op that treats rows independently behaves **identically to numpy** on the concatenated
87
+ > `(N, *e)` array — same values, same shape, and the same error on bad shapes.
88
+
89
+ That covers elementwise math and ufuncs, broadcasting (every operand shape numpy accepts, and it
90
+ raises on the ones numpy rejects), comparisons, `np.where` / `np.clip`, batched `np.matmul`,
91
+ **feature-axis** reductions/indexing (`np.linalg.norm(qr.velocity, axis=1)`, `qr.pose[:, 0]`), and
92
+ write-back broadcasting (`qr.position[:] = <scalar | (*e,) | (N, *e)>`). Pinned in
93
+ `test/unit/test_field_numpy_parity.py`.
94
+
95
+ Edge cases worth knowing:
96
+
97
+ - **Not a full ndarray — these *raise*, never lie.** Entity-axis indexing beyond a single
98
+ `qr.f[i]` (`qr.f[:]`, `qr.f[2:4]`, `qr.f[mask]`, fancy), partial entity writes, and ndarray
99
+ methods/attrs (`.sum()`, `.mean()`, `.dtype`, `.ndim`, `.T`). Need any of these? Materialize
100
+ first with `qr.f.numpy()`.
101
+ - **Axis-0 ops are per-pool, not global (footgun).** `np.sort` / `np.cumsum` / `np.sum` over
102
+ `axis=0` run *within each pool* and reset at pool boundaries — they do **not** see all entities
103
+ at once, so they differ from numpy. They're allowed, but if you want a global result, do
104
+ `qr.f.numpy()` first. A reduction that collapses the entity axis is rejected when its length no
105
+ longer matches the pool's row count.
106
+ - **Operands must come from the same query.** Alignment is per-pool, not by flat index, so don't
107
+ mix a `_Field` from one `world.query(...)` into an op on another.
108
+
109
+ ## Benchmark: ECS vs OOP
110
+
111
+ The same physics step -- `pos += vel*dt` over N=100k entities split across 2 pools -- computed 8
112
+ ways, all verified to produce the identical result. Reproduce with
113
+ `python test/manual/perf/bench_ecs_vs_oop.py` (it prints `{mode: avg_seconds_per_step}`).
114
+
115
+ | pattern | ns/entity | vs OOP-scalar |
116
+ |---|---:|---:|
117
+ | `micro-ecs-pool-vectorized` — `for pool: pool.f[:] = pool.f + …` | 0.9 | **52× faster** |
118
+ | `micro-ecs-vectorized` — `qr.f[:] = qr.f + …` (the `_Field`) | 1.8 | **27× faster** |
119
+ | **`oop-scalar`** — `for o: o.x += o.vx*dt` (python floats) | 48 | 1× (baseline) |
120
+ | `oop-numpy` — objects holding `(2,)` numpy arrays | 605 | 13× slower |
121
+ | `micro-ecs-zip-rows` — `for p, v in zip(qr.pos, qr.vel)` | 744 | 15× slower |
122
+ | `micro-ecs-pool-loop` — `for pool: for i: pool.f[i]` | 870 | 18× slower |
123
+ | `micro-ecs-get-entity` — `world.get_entity(eid)` per entity | 1674 | 35× slower |
124
+ | `micro-ecs-index` — `qr.f[i]` per entity (an `np.searchsorted` each) | 4573 | 95× slower |
125
+
126
+ *Intel Core Ultra 7 165H, single-threaded, python 3.12 / numpy 2.2. ns/entity = seconds/step ÷ N.*
127
+
128
+ Three things to take from it:
129
+
130
+ 1. **Vectorized wins big.** Batched ops (`_Field` or per-pool) run at 1–2 ns/entity — **27–52×
131
+ faster** than the *fastest* OOP loop. Same for data-parallel branches: an `np.where` clamp or
132
+ bounce is ~34× faster than a per-entity `if`.
133
+ 2. **Per-entity loops are a cliff, not a tie.** Every per-entity microecs path is **15–95× slower**
134
+ than idiomatic float-based OOP — because microecs is numpy-backed, so a per-entity step pays
135
+ numpy's tiny-array overhead (`oop-numpy` shows the same ~13× tax). One unavoidable per-entity
136
+ pass (~750 ns/entity) costs ~500× a vectorized op (~1.5 ns) and will dominate the frame.
137
+ 3. **If you must loop, loop right.** `zip`-rows (15×) < pool-loop (18×) < `get_entity` (35×) <
138
+ `qr.f[i]` (95× — never in a hot loop; `qr.f[i] += …` also raises, you must bind the row first).
139
+
140
+ **Rule of thumb:** keep systems vectorized and push branches into `np.where` / `np.clip`. If a
141
+ workload is *irreducibly* per-entity (data-dependent control flow), plain python objects beat
142
+ microecs ~15× — use them there. microecs is the right tool for **vectorizable** simulation.
@@ -31,6 +31,7 @@ class Pool:
31
31
 
32
32
  for _field, field_shape, field_dtype in zip(self.fields, self.shapes, self.dtypes):
33
33
  new_item: np.ndarray = entity_fields[_field] # checked in World._get_entity_pool(entity).
34
+ assert isinstance(new_item, np.ndarray), f"{_field=} {new_item=} {type(new_item)}"
34
35
  assert new_item.shape == field_shape, f"{_field=} {new_item=}, {new_item.shape=}, {field_shape=}"
35
36
  assert np.issubdtype(new_item.dtype, field_dtype), f"{_field=} {new_item=} {new_item.dtype=} {field_dtype=}"
36
37
  self.data[_field][self.size] = new_item
@@ -6,8 +6,6 @@ from .utils import Shape
6
6
  from .pool import Pool
7
7
 
8
8
  class _Field(np.lib.mixins.NDArrayOperatorsMixin):
9
- _PER_POOL_OK = {np.where, np.clip}
10
-
11
9
  def __init__(self, parts: list[np.ndarray]):
12
10
  self.parts = parts
13
11
  self._lens = [len(p) for p in self.parts]
@@ -27,14 +25,20 @@ class _Field(np.lib.mixins.NDArrayOperatorsMixin):
27
25
  return x
28
26
 
29
27
  def _apply_fn_on_parts(self, fn: Callable, op_args: list, **kwargs):
28
+ # op args can be 1 element (-qr.velocity), 2 elements (qr.position * 0.1), 3 elements (np.where(a, b, c)), etc.
29
+ # all of them must be chunked based on how many we have in this _Field so each subpart is called independently.
30
30
  results = []
31
- for i in range(len(self.parts)):
31
+ for i, part in enumerate(self.parts):
32
32
  pool_args = [self._chunk(x, i) for x in op_args]
33
- results.append(fn(*pool_args, **kwargs))
33
+ part_result: _Field = fn(*pool_args, **kwargs)
34
+ # we expect f(arr(N, ...)) -> arr(N, ...) where N = number of items in the pool
35
+ # for e.g. np.linalg.norm(velocity, axis=1) should do (N, 2) -> (N, 1) so the first axis is preserved
36
+ assert len(part_result) == part.shape[0], f"Result: {part_result.shape} vs {part.shape}"
37
+ results.append(part_result)
34
38
  return _Field(results)
35
39
 
36
40
  def __array_ufunc__(self, ufunc, method, *inputs, out=None, **kwargs):
37
- """wrapper forelementwise (python) primitives, e.g. qr.position[:] += 1"""
41
+ """wrapper for elementwise (python) primitives, e.g. qr.position[:] += 1"""
38
42
  if method != "__call__":
39
43
  return NotImplemented
40
44
  if out is None:
@@ -48,8 +52,6 @@ class _Field(np.lib.mixins.NDArrayOperatorsMixin):
48
52
 
49
53
  def __array_function__(self, func: Callable, _types, args: list, kwargs: dict):
50
54
  """wrapper for elementwise numpy functions, e.g. qr.velocity[:] = np.where(mask, -qr.velocity, qr.velocity)"""
51
- if func not in self._PER_POOL_OK:
52
- return NotImplemented # add them manually
53
55
  return self._apply_fn_on_parts(func, args, **kwargs)
54
56
 
55
57
  # qr.position[:] = <field | scalar | per-entity broadcast> -> scatter through the views
@@ -1,6 +1,7 @@
1
1
  """world.py - The world container for ECS. It manages all the pools (one per archetype). Entities are id-based."""
2
2
  from typing import Callable, get_type_hints
3
3
  from functools import partial
4
+ from dataclasses import fields
4
5
  import numpy as np
5
6
 
6
7
  from .pool import Pool
@@ -13,27 +14,29 @@ class World:
13
14
  Generic container for pools of components. Newly added components are assigned a unique id and go in a pool
14
15
  Parameters
15
16
  - components The list of components that the world accepts
16
- - extra_field_metadata The list of required extra metadata for each field besides shape and dtype.
17
+ - extra_metadata The list of required extra metadata for each field besides shape and dtype.
17
18
  """
18
- def __init__(self, components: list[ComponentType], extra_field_metadata: list[str] | None = None):
19
- self.extra_field_metadata = extra_field_metadata or []
19
+ def __init__(self, components: list[ComponentType], extra_metadata: list[str] | None = None):
20
+ self.extra_metadata = extra_metadata or []
21
+ assert isinstance(self.extra_metadata, list), type(self.extra_metadata)
20
22
  self._check_components(components)
21
23
  self.pools: dict[PoolKey, Pool] = {}
22
24
  self.pool_to_components: dict[Pool, list[ComponentType]] = {}
23
25
  # components management
24
26
  self.component_names = [x.__name__ for x in components]
25
27
  self.component_types = list(components)
26
- self.component_to_bit: dict[type, int] = {t: 2**i for i, t in enumerate(components)} # unique bit for querying
27
- self.component_to_shapes: dict[type, list[Shape]] = {
28
- t: [f.metadata["shape"] for f in t.__dataclass_fields__.values()] for t in components}
29
- self.component_to_dtypes: dict[type, list[str]] = {
30
- t: [f.metadata["dtype"] for f in t.__dataclass_fields__.values()] for t in components}
31
- self.component_to_field_names: dict[type, list[str]] = {t: list(t.__dataclass_fields__) for t in components}
28
+ self.component_to_bit: dict[ComponentType, int] = {t: 2**i for i, t in enumerate(components)} # bit for querying
29
+ self.component_to_shapes: dict[ComponentType, list[Shape]] = {
30
+ c: [f.metadata["shape"] for f in fields(c)] for c in components}
31
+ self.component_to_dtypes: dict[ComponentType, list[str]] = {
32
+ c: [f.metadata["dtype"] for f in fields(c)] for c in components}
33
+ self.component_to_field_names: dict[ComponentType, list[str]] = {
34
+ c: [f.name for f in fields(c)] for c in components}
32
35
  # entity id management
33
36
  self._eid_to_pool_ix: dict[EntityId, tuple[Pool, int]] = {}
34
37
  self._pool_ids: dict[Pool, list[EntityId]] = {}
35
38
  self._last_id: EntityId = -1
36
- self._live_ids: set[EntityId] = set() # 'eager' mode ids so e.g. calling remove_entity twice before update fails
39
+ self.live_ids: set[EntityId] = set() # 'eager' mode ids so e.g. calling remove_entity twice before update fails
37
40
  # command buffer management. {add/remove}_{entity/component} are lazy. Taken into account after update().
38
41
  self._command_buffer: list[Callable] = []
39
42
  self._cache: dict[tuple[PoolKey, PoolKey], QueryResult] = {} # include+exclude key
@@ -54,15 +57,16 @@ class World:
54
57
  """Adds an entity to the world based on components (data->kwargs). Returns an entity id. Lazy; call update()"""
55
58
  self._check_components_against_pool(components, **kwargs)
56
59
  self._last_id += 1
57
- self._live_ids.add(self._last_id)
60
+ self.live_ids.add(self._last_id)
58
61
  self._command_buffer.append(partial(self._add_to_pool, entity_id=self._last_id,
59
62
  components=components, **kwargs))
63
+ logger.debug(f"Created entity. ID: {self._last_id}. Components: {[c.__name__ for c in components]}")
60
64
  return self._last_id
61
65
 
62
66
  def remove_entity(self, entity_id: EntityId):
63
67
  """Removes an entity based on its unique entity id. Lazy; call update()"""
64
- assert entity_id in self._live_ids, f"Entity id: {entity_id} not in {self._live_ids=}"
65
- self._live_ids.remove(entity_id)
68
+ assert entity_id in self.live_ids, f"Entity id: {entity_id} not in {self.live_ids=}"
69
+ self.live_ids.remove(entity_id)
66
70
  self._command_buffer.append(partial(self._pop_from_pool, entity_id=entity_id))
67
71
 
68
72
  def get_entity(self, entity_id: EntityId) -> tuple[EntityData, list[ComponentType]]:
@@ -75,13 +79,13 @@ class World:
75
79
 
76
80
  def add_component(self, entity_id: EntityId, component: ComponentType, **kwargs):
77
81
  """Adds a component to an entity given its id. Component data is sent to kwargs. Lazy; call update()"""
78
- assert entity_id in self._live_ids, f"Entity id: {entity_id} not in {self._live_ids=}"
82
+ assert entity_id in self.live_ids, f"Entity id: {entity_id} not in {self.live_ids=}"
79
83
  assert component in self.component_types, f"Component '{component}' not in {self.component_types}"
80
84
  self._command_buffer.append(partial(self._do_add_component, entity_id=entity_id, component=component, **kwargs))
81
85
 
82
86
  def remove_component(self, entity_id: EntityId, component: ComponentType):
83
87
  """Removes a component from an entity given its id. Lazy; call update()"""
84
- assert entity_id in self._live_ids, f"Entity id: {entity_id} not in {self._live_ids=}"
88
+ assert entity_id in self.live_ids, f"Entity id: {entity_id} not in {self.live_ids=}"
85
89
  assert component in self.component_types, f"Component '{component}' not in {self.component_types}"
86
90
  self._command_buffer.append(partial(self._do_remove_component, entity_id=entity_id, component=component))
87
91
 
@@ -170,15 +174,15 @@ class World:
170
174
  for component in components:
171
175
  for _field in self.component_to_field_names[component]:
172
176
  expected_fields.add(_field)
173
- assert _field in entity_fields, f"Entity doesn't have '{component}/{_field}'"
177
+ assert _field in entity_fields, f"Entity doesn't have '{component.__name__}/{_field}'"
174
178
  assert (extra := set(entity_fields) - expected_fields) == set(), f"Extra fields: {extra}; {expected_fields=}"
175
179
 
176
180
  def _get_entity_pool(self, components: list[ComponentType]) -> Pool:
177
181
  if (key := self._make_key(components)) not in self.pools:
178
- fields = sum([self.component_to_field_names[component] for component in components], []) # merge fields
182
+ _fields = sum([self.component_to_field_names[component] for component in components], []) # merge fields
179
183
  shapes = sum([self.component_to_shapes[component] for component in components], []) # merge shapes
180
184
  dtypes = sum([self.component_to_dtypes[component] for component in components], []) # merge dtypes
181
- self.pools[key] = Pool(fields, shapes, dtypes)
185
+ self.pools[key] = Pool(_fields, shapes, dtypes)
182
186
  self.pool_to_components[self.pools[key]] = components
183
187
  return self.pools[key]
184
188
 
@@ -190,21 +194,23 @@ class World:
190
194
  return key
191
195
 
192
196
  def _check_components(self, components: list[ComponentType]):
193
- _query_result_reserved_names = _qres = sorted(vars(QueryResult([], {}, {}, [])))
194
- _dtypes = {"float32", "int32", "bool", "str", "object"}
197
+ qr_reserved_names = _qres = sorted(vars(QueryResult([], {}, {}, [])))
198
+ dtypes = {"float32", "int32", "bool", "str", "object"}
199
+ expected_meta = {"shape", "dtype", *self.extra_metadata}
195
200
 
196
201
  for component in components:
197
- assert hasattr(component, "__dataclass_fields__"), f"Component '{component}' is not a dataclass"
202
+ cn = component.__name__
203
+ assert hasattr(component, "__dataclass_fields__"), f"Component '{cn}' is not a dataclass"
198
204
  hints = get_type_hints(component) # make it work with from __future__ import annotations
199
- for field_name, _field in component.__dataclass_fields__.items():
200
- assert hints[field_name] is np.ndarray, f"Field '{field_name}' of '{component=}' not an array: {_field}"
201
- assert _field.metadata.keys() == {"shape", "dtype", *self.extra_field_metadata}, _field.metadata
202
- assert isinstance(_field.metadata["shape"], tuple), _field.metadata["shape"]
203
- assert isinstance(fmd := _field.metadata["dtype"], str) and fmd in _dtypes, f"{fmd} not in {_dtypes}"
204
- assert field_name not in _query_result_reserved_names, f"Field '{field_name}' in {_qres}"
205
+ for f in fields(component):
206
+ assert hints[f.name] is np.ndarray, f"Field '{cn}/{f.name}' not an array: {f}"
207
+ assert f.name not in qr_reserved_names, f"Field '{cn}/{f.name}' in {qr_reserved_names}"
208
+ assert f.metadata.keys() == expected_meta, f"Field '{cn}/{f.name}':\n{f.metadata} vs\n{expected_meta}"
209
+ assert isinstance(f.metadata["shape"], tuple), f.metadata["shape"]
210
+ assert isinstance(fmd := f.metadata["dtype"], str) and fmd in dtypes, f"{fmd} not in {dtypes}"
205
211
 
206
212
  def __len__(self):
207
- return len(self._live_ids)
213
+ return len(self.live_ids)
208
214
 
209
215
  def __repr__(self):
210
216
  return (f"[World]\n- Entities: {len(self)} (last id: {self._last_id})"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: microecs
3
- Version: 0.3.0
3
+ Version: 0.3.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
@@ -24,8 +24,8 @@ There are only 4 primitives (bottom up): `Component`, `Pool`, `QueryResult`, `Wo
24
24
 
25
25
  - `Component` is a simple python dataclass holding only data. All entries must be numpy arrays with metadata fields: shape and dtype. We support 5 dtypes only: `int32`, `float32`, `bool`, `str` and `object`. A component with no fields is a valid **tag** for querying (e.g. `class Frozen(Component): pass`).
26
26
  - `Pool` is a simple 'archetype' dynamic array, holding entities of the same type (same set of components). Usses `Components` metadata to construct contiguous arrays for all entities of the same type.
27
- - `QueryResult` is a list of pools that match some query on all the entities of the `World`. It acts as a contiguous numpy-like container that implements numpy's `__array_function__` and `__array_ufunc__`. For all intents and purposes it should feel like a `(N, ...)` view over all the entities. If you need a numpy array (not all ops are supported, for e.g. indexing on the first axis), use `QueryResult.numpy()`. It also exposes `entity_ids`: a flat `(N,)` array of the matched entities' ids, in the same pool-by-pool order as the fields, so you can `zip(qr.entity_ids, qr.position)` or feed an id back to `world.get_entity` / `world.remove_entity`.
28
- - `World` is a manager of `Pools` and has an overview of all the entities in the scene. It also manages the migration of entities from one pool to the other. A `World` can also require extra metadata keys on every field via `World(extra_field_metadata=["serializable"])`, to enforce component-level behavior such as field serialization.
27
+ - `QueryResult` is a list of pools that match some query on all the entities of the `World`. It acts as a contiguous numpy-like container that implements numpy's `__array_function__` and `__array_ufunc__`. For all intents and purposes it should feel like a `(N, ...)` view over all the entities. If you need a numpy array (not all ops are supported, for e.g. indexing on the first axis), use `QueryResult.numpy()` (see [the field numpy contract](#the-field-_field-numpy-contract) below). It also exposes `entity_ids`: a flat `(N,)` array of the matched entities' ids, in the same pool-by-pool order as the fields, so you can `zip(qr.entity_ids, qr.position)` or feed an id back to `world.get_entity` / `world.remove_entity`.
28
+ - `World` is a manager of `Pools` and has an overview of all the entities in the scene. It also manages the migration of entities from one pool to the other. A `World` can also require extra metadata keys on every field via `World(extra_metadata=["serializable"])`, to enforce component-level behavior such as field serialization.
29
29
 
30
30
  Few relevant concepts:
31
31
 
@@ -43,7 +43,8 @@ from microecs import World, Component
43
43
 
44
44
  # components
45
45
  class HasPosition(Component):
46
- position: np.ndarray = field(metadata={"shape": (2, ), "dtype": "float32"}) # 'shape' + 'dtype' are always required
46
+ # 'shape' + 'dtype' are always required. For additional metadata (e.g. examples/03-serialization) use extra_metadata
47
+ position: np.ndarray = field(metadata={"shape": (2, ), "dtype": "float32"})
47
48
  class HasVelocity(Component):
48
49
  velocity: np.ndarray = field(metadata={"shape": (2, ), "dtype": "float32"})
49
50
  class HasColor(Component):
@@ -68,7 +69,7 @@ def main():
68
69
  render_system: list[Callable] = RenderSystem()
69
70
  update_systems: list[Callable] = [MotionSystem()]
70
71
 
71
- world = World(components=[HasPosition, HasColor, HasVelocity], extra_field_metadata=None)
72
+ world = World(components=[HasPosition, HasColor, HasVelocity], extra_metadata=None) # extra_metadata is optional
72
73
  for _ in range(n_objects):
73
74
  # NOTE: world.{add/remove}_{entity/component} are lazy. They take effect after the first world.update() call.
74
75
  world.add_entity(components=(HasPosition, HasVelocity, HasColor), # tuple of components (types)
@@ -87,3 +88,67 @@ def main():
87
88
  render_system(world=world)
88
89
  rl.EndDrawing()
89
90
  ```
91
+
92
+ ## The field (`_Field`) numpy contract
93
+
94
+ `qr.position` returns a `_Field`: a view over the matching pools that behaves like one `(N, *e)`
95
+ numpy array (e.g. `(N, 2)` for a `(2,)` field). It applies each op **per pool** and stitches the
96
+ result back. So the contract is exactly:
97
+
98
+ > Any op that treats rows independently behaves **identically to numpy** on the concatenated
99
+ > `(N, *e)` array — same values, same shape, and the same error on bad shapes.
100
+
101
+ That covers elementwise math and ufuncs, broadcasting (every operand shape numpy accepts, and it
102
+ raises on the ones numpy rejects), comparisons, `np.where` / `np.clip`, batched `np.matmul`,
103
+ **feature-axis** reductions/indexing (`np.linalg.norm(qr.velocity, axis=1)`, `qr.pose[:, 0]`), and
104
+ write-back broadcasting (`qr.position[:] = <scalar | (*e,) | (N, *e)>`). Pinned in
105
+ `test/unit/test_field_numpy_parity.py`.
106
+
107
+ Edge cases worth knowing:
108
+
109
+ - **Not a full ndarray — these *raise*, never lie.** Entity-axis indexing beyond a single
110
+ `qr.f[i]` (`qr.f[:]`, `qr.f[2:4]`, `qr.f[mask]`, fancy), partial entity writes, and ndarray
111
+ methods/attrs (`.sum()`, `.mean()`, `.dtype`, `.ndim`, `.T`). Need any of these? Materialize
112
+ first with `qr.f.numpy()`.
113
+ - **Axis-0 ops are per-pool, not global (footgun).** `np.sort` / `np.cumsum` / `np.sum` over
114
+ `axis=0` run *within each pool* and reset at pool boundaries — they do **not** see all entities
115
+ at once, so they differ from numpy. They're allowed, but if you want a global result, do
116
+ `qr.f.numpy()` first. A reduction that collapses the entity axis is rejected when its length no
117
+ longer matches the pool's row count.
118
+ - **Operands must come from the same query.** Alignment is per-pool, not by flat index, so don't
119
+ mix a `_Field` from one `world.query(...)` into an op on another.
120
+
121
+ ## Benchmark: ECS vs OOP
122
+
123
+ The same physics step -- `pos += vel*dt` over N=100k entities split across 2 pools -- computed 8
124
+ ways, all verified to produce the identical result. Reproduce with
125
+ `python test/manual/perf/bench_ecs_vs_oop.py` (it prints `{mode: avg_seconds_per_step}`).
126
+
127
+ | pattern | ns/entity | vs OOP-scalar |
128
+ |---|---:|---:|
129
+ | `micro-ecs-pool-vectorized` — `for pool: pool.f[:] = pool.f + …` | 0.9 | **52× faster** |
130
+ | `micro-ecs-vectorized` — `qr.f[:] = qr.f + …` (the `_Field`) | 1.8 | **27× faster** |
131
+ | **`oop-scalar`** — `for o: o.x += o.vx*dt` (python floats) | 48 | 1× (baseline) |
132
+ | `oop-numpy` — objects holding `(2,)` numpy arrays | 605 | 13× slower |
133
+ | `micro-ecs-zip-rows` — `for p, v in zip(qr.pos, qr.vel)` | 744 | 15× slower |
134
+ | `micro-ecs-pool-loop` — `for pool: for i: pool.f[i]` | 870 | 18× slower |
135
+ | `micro-ecs-get-entity` — `world.get_entity(eid)` per entity | 1674 | 35× slower |
136
+ | `micro-ecs-index` — `qr.f[i]` per entity (an `np.searchsorted` each) | 4573 | 95× slower |
137
+
138
+ *Intel Core Ultra 7 165H, single-threaded, python 3.12 / numpy 2.2. ns/entity = seconds/step ÷ N.*
139
+
140
+ Three things to take from it:
141
+
142
+ 1. **Vectorized wins big.** Batched ops (`_Field` or per-pool) run at 1–2 ns/entity — **27–52×
143
+ faster** than the *fastest* OOP loop. Same for data-parallel branches: an `np.where` clamp or
144
+ bounce is ~34× faster than a per-entity `if`.
145
+ 2. **Per-entity loops are a cliff, not a tie.** Every per-entity microecs path is **15–95× slower**
146
+ than idiomatic float-based OOP — because microecs is numpy-backed, so a per-entity step pays
147
+ numpy's tiny-array overhead (`oop-numpy` shows the same ~13× tax). One unavoidable per-entity
148
+ pass (~750 ns/entity) costs ~500× a vectorized op (~1.5 ns) and will dominate the frame.
149
+ 3. **If you must loop, loop right.** `zip`-rows (15×) < pool-loop (18×) < `get_entity` (35×) <
150
+ `qr.f[i]` (95× — never in a hot loop; `qr.f[i] += …` also raises, you must bind the row first).
151
+
152
+ **Rule of thumb:** keep systems vectorized and push branches into `np.where` / `np.clip`. If a
153
+ workload is *irreducibly* per-entity (data-dependent control flow), plain python objects beat
154
+ microecs ~15× — use them there. microecs is the right tool for **vectorizable** simulation.
@@ -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.3.0"
6
+ VERSION = "0.3.2"
7
7
  DESCRIPTION = "MicroECS: Minimal Entity Component System (ECS) in python and numpy"
8
8
  URL = "https://gitlab.com/meehai/microecs"
9
9
 
microecs-0.3.0/README.md DELETED
@@ -1,77 +0,0 @@
1
- # MicroECS
2
-
3
- Minimal (~300 LoC) Entity Component System in python and numpy. Examples also use raylib for rendering.
4
-
5
- Usage:
6
-
7
- - `pip install -r requirements.txt`
8
- - Sandbox: `python main.py`
9
- - Tests: `pytest test/`
10
-
11
- There are only 4 primitives (bottom up): `Component`, `Pool`, `QueryResult`, `World`:
12
-
13
- - `Component` is a simple python dataclass holding only data. All entries must be numpy arrays with metadata fields: shape and dtype. We support 5 dtypes only: `int32`, `float32`, `bool`, `str` and `object`. A component with no fields is a valid **tag** for querying (e.g. `class Frozen(Component): pass`).
14
- - `Pool` is a simple 'archetype' dynamic array, holding entities of the same type (same set of components). Usses `Components` metadata to construct contiguous arrays for all entities of the same type.
15
- - `QueryResult` is a list of pools that match some query on all the entities of the `World`. It acts as a contiguous numpy-like container that implements numpy's `__array_function__` and `__array_ufunc__`. For all intents and purposes it should feel like a `(N, ...)` view over all the entities. If you need a numpy array (not all ops are supported, for e.g. indexing on the first axis), use `QueryResult.numpy()`. It also exposes `entity_ids`: a flat `(N,)` array of the matched entities' ids, in the same pool-by-pool order as the fields, so you can `zip(qr.entity_ids, qr.position)` or feed an id back to `world.get_entity` / `world.remove_entity`.
16
- - `World` is a manager of `Pools` and has an overview of all the entities in the scene. It also manages the migration of entities from one pool to the other. A `World` can also require extra metadata keys on every field via `World(extra_field_metadata=["serializable"])`, to enforce component-level behavior such as field serialization.
17
-
18
- Few relevant concepts:
19
-
20
- - `Pool` operates on array indices, while `World` operates on entity IDs (also integers). This allows seamless movement between pools while the high-level systems still working as intended.
21
- - All mutable operations on `World` are lazy. These are: `add_entity`, `remove_entity`, `add_component`, `remove_component`. They are added to a command buffer which is only executed when calling `world.update()`.
22
- - `Systems` are a convention, they are not part of this library. They can be defined at application level and act as hooks or callbacks. The `World` object doesn't need to know more than entities and components.
23
-
24
- Super simplified main loop structure:
25
-
26
- ```python
27
- from typing import Callable
28
- import numpy as np
29
- import raylib as rl
30
- from microecs import World, Component
31
-
32
- # components
33
- class HasPosition(Component):
34
- position: np.ndarray = field(metadata={"shape": (2, ), "dtype": "float32"}) # 'shape' + 'dtype' are always required
35
- class HasVelocity(Component):
36
- velocity: np.ndarray = field(metadata={"shape": (2, ), "dtype": "float32"})
37
- class HasColor(Component):
38
- color: np.ndarray = field(metadata={"shape": (4, ), "dtype": "int32"})
39
-
40
- # systems: Note they are a convention!
41
- class RenderSystem:
42
- def __call__(self, world: World):
43
- query_result = world.query(HasPosition, HasColor, exclude=[]) # contiguous-like view of all entities matching
44
- for position, color in zip(query_result.position, query_result.color): # draw each entity
45
- DrawEntity(position, color)
46
-
47
- class MotionSystem:
48
- def __call__(self, world: World):
49
- qr = world.query(HasPosition, HasVelocity) # 'exclude' is optional
50
- qr.position[:] = qr.position + qr.velocity * DT # writes back to all the underlying pools using numpy's rules
51
- # Alternative for per-pool update. Less ergonomic, but maybe faster in extreme cases as it avoids the _Field obj
52
- for pool in qr.pool_list:
53
- pool.position[:] = pool.position + pool.velocity * DT
54
-
55
- def main():
56
- render_system: list[Callable] = RenderSystem()
57
- update_systems: list[Callable] = [MotionSystem()]
58
-
59
- world = World(components=[HasPosition, HasColor, HasVelocity], extra_field_metadata=None)
60
- for _ in range(n_objects):
61
- # NOTE: world.{add/remove}_{entity/component} are lazy. They take effect after the first world.update() call.
62
- world.add_entity(components=(HasPosition, HasVelocity, HasColor), # tuple of components (types)
63
- position= np.array((x, y), "float32"), # data as kwargs
64
- color= np.array("black", dtype="int32"),
65
- velocity= np.array((vx, vy), "float32"))
66
-
67
- while not rl.WindowShouldClose():
68
- world.update() # must be called at each tick so the lazy methods are processed and entities are updated
69
- # update stuff...
70
- _ = [system(world=world) for system in update_systems]
71
- # draw stuff, e.g. using raylib
72
- rl.BeginDrawing()
73
- rl.ClearBackground(rl.RAYWHITE)
74
- rl.DrawFPS(rl.GetScreenWidth() - 100, 0)
75
- render_system(world=world)
76
- rl.EndDrawing()
77
- ```
File without changes
File without changes
File without changes
File without changes
File without changes