microecs 0.3.2__tar.gz → 0.3.4__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.2
3
+ Version: 0.3.4
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
@@ -16,24 +16,35 @@ Minimal (~300 LoC) Entity Component System in python and numpy. Examples also us
16
16
 
17
17
  Usage:
18
18
 
19
- - `pip install -r requirements.txt`
20
- - Sandbox: `python main.py`
21
- - Tests: `pytest test/`
19
+ - Via pip: `pip install microecs`
20
+ - From source code:
21
+ ```bash
22
+ git clone https://gitlab.com/meehai/microecs # clone the source code
23
+ cd microecs # go in the cloned directory
24
+ python -m venv .venv && source .venv/bin/activate # make a virtual env, optional but useful
25
+ python -m pip install -e . # install micro ecs in this virtual env
26
+ python -m pytest test/ # run the unit & integration tests to verify installation
27
+ python examples/01-hello-world.py # run the basic hello world example (others in that dir)
28
+ ```
29
+
30
+ Docs: [meehai.gitlab.io/microecs](https://meehai.gitlab.io/microecs/)
31
+
32
+ ## Relevant primitives: `Component`, `Pool`, `QueryResult`, `World`
22
33
 
23
- There are only 4 primitives (bottom up): `Component`, `Pool`, `QueryResult`, `World`:
34
+ There are only 4 primitives (bottom up):
24
35
 
25
36
  - `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
37
  - `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()` (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`.
38
+ - `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 interface. For all intents and purposes it should feel like a `(N, ...)` view over all selected entities. To get a proper numpy array, use `qr.numpy()`. To iterate over each entity in a query result (e.g. rendering), use `for eid, position in zip(qr.entity_ids, qr.position): ...`.
28
39
  - `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
40
 
30
- Few relevant concepts:
41
+ ### Few relevant concepts:
31
42
 
32
43
  - `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.
33
44
  - 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()`.
34
45
  - `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.
35
46
 
36
- Super simplified main loop structure:
47
+ ## Super simplified main loop structure
37
48
 
38
49
  ```python
39
50
  from typing import Callable
@@ -89,40 +100,22 @@ def main():
89
100
  rl.EndDrawing()
90
101
  ```
91
102
 
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.
103
+ ## Per-entity systems (e.g. rendering, foreign APIs)
100
104
 
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`.
105
+ Not every system vectorizes. A renderer calls a draw function per primitive; same for any per-entity foreign API. That's a per-entity system inside an otherwise-vectorized app. The cheapest way to iterate through all the entities is by using `zip()` on the `QueryResult` object on the fields you need, e.g:
106
106
 
107
- Edge cases worth knowing:
107
+ ```python
108
+ qr = world.query(HasPosition, HasColor, HasRadius)
109
+ for pos, color, radius in zip(qr.position, qr.color, qr.radius): # one entity per step, fields aligned
110
+ rl.DrawCircle(int(pos[0]), int(pos[1]), float(radius[0]), color) # per-entity by necessity (no "draw all")
111
+ ```
108
112
 
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.
113
+ 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.
120
114
 
121
- ## Benchmark: ECS vs OOP
115
+ <details>
116
+ <summary> Microbenchmark: ECS vs OOP on a simple physics step </summary>
122
117
 
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}`).
118
+ Benchmark: we run the same physics step `pos += vel*dt` over N=100k entities split across 2 pools in various ways (ECS or OOP). All methods are verified to produce the identical result. Reproduce with `python test/manual/perf/bench_ecs_vs_oop.py` (it prints `{mode: avg_seconds_per_step}`).
126
119
 
127
120
  | pattern | ns/entity | vs OOP-scalar |
128
121
  |---|---:|---:|
@@ -135,8 +128,6 @@ ways, all verified to produce the identical result. Reproduce with
135
128
  | `micro-ecs-get-entity` — `world.get_entity(eid)` per entity | 1674 | 35× slower |
136
129
  | `micro-ecs-index` — `qr.f[i]` per entity (an `np.searchsorted` each) | 4573 | 95× slower |
137
130
 
138
- *Intel Core Ultra 7 165H, single-threaded, python 3.12 / numpy 2.2. ns/entity = seconds/step ÷ N.*
139
-
140
131
  Three things to take from it:
141
132
 
142
133
  1. **Vectorized wins big.** Batched ops (`_Field` or per-pool) run at 1–2 ns/entity — **27–52×
@@ -152,3 +143,26 @@ Three things to take from it:
152
143
  **Rule of thumb:** keep systems vectorized and push branches into `np.where` / `np.clip`. If a
153
144
  workload is *irreducibly* per-entity (data-dependent control flow), plain python objects beat
154
145
  microecs ~15× — use them there. microecs is the right tool for **vectorizable** simulation.
146
+
147
+ </details>
148
+
149
+ ## How much are `Pool` and `QueryResult` numpy-like and corner cases
150
+
151
+ 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)`
152
+ numpy array (e.g. `(N, 2)` for a `(2,)` field). It applies each op **per pool** and stitches result back.
153
+
154
+ 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.
155
+
156
+ Edge cases worth knowing:
157
+
158
+ - **Not a full ndarray — these raise, never lie.** Entity-axis indexing beyond a single
159
+ `qr.f[i]` (`qr.f[:]`, `qr.f[2:4]`, `qr.f[mask]`, fancy), partial entity writes, and ndarray
160
+ methods/attrs (`.sum()`, `.mean()`, `.dtype`, `.ndim`, `.T`). Need any of these? Materialize
161
+ first with `qr.f.numpy()`.
162
+ - **Axis-0 ops are per-pool, not global (footgun).** `np.sort` / `np.cumsum` / `np.sum` over
163
+ `axis=0` run within each pool and reset at pool boundaries — they do **not** see all entities
164
+ at once, so they differ from numpy. They're allowed, but if you want a global result, do
165
+ `qr.f.numpy()` first. A reduction that collapses the entity axis is rejected when its length no
166
+ longer matches the pool's row count.
167
+ - **Operands must come from the same query.** Alignment is per-pool, not by flat index, so don't
168
+ mix a `_Field` from one `world.query(...)` into an op on another.
@@ -4,24 +4,35 @@ Minimal (~300 LoC) Entity Component System in python and numpy. Examples also us
4
4
 
5
5
  Usage:
6
6
 
7
- - `pip install -r requirements.txt`
8
- - Sandbox: `python main.py`
9
- - Tests: `pytest test/`
7
+ - Via pip: `pip install microecs`
8
+ - From source code:
9
+ ```bash
10
+ git clone https://gitlab.com/meehai/microecs # clone the source code
11
+ cd microecs # go in the cloned directory
12
+ python -m venv .venv && source .venv/bin/activate # make a virtual env, optional but useful
13
+ python -m pip install -e . # install micro ecs in this virtual env
14
+ python -m pytest test/ # run the unit & integration tests to verify installation
15
+ python examples/01-hello-world.py # run the basic hello world example (others in that dir)
16
+ ```
17
+
18
+ Docs: [meehai.gitlab.io/microecs](https://meehai.gitlab.io/microecs/)
19
+
20
+ ## Relevant primitives: `Component`, `Pool`, `QueryResult`, `World`
10
21
 
11
- There are only 4 primitives (bottom up): `Component`, `Pool`, `QueryResult`, `World`:
22
+ There are only 4 primitives (bottom up):
12
23
 
13
24
  - `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
25
  - `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`.
26
+ - `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 interface. For all intents and purposes it should feel like a `(N, ...)` view over all selected entities. To get a proper numpy array, use `qr.numpy()`. To iterate over each entity in a query result (e.g. rendering), use `for eid, position in zip(qr.entity_ids, qr.position): ...`.
16
27
  - `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
28
 
18
- Few relevant concepts:
29
+ ### Few relevant concepts:
19
30
 
20
31
  - `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
32
  - 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
33
  - `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
34
 
24
- Super simplified main loop structure:
35
+ ## Super simplified main loop structure
25
36
 
26
37
  ```python
27
38
  from typing import Callable
@@ -77,40 +88,22 @@ def main():
77
88
  rl.EndDrawing()
78
89
  ```
79
90
 
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.
91
+ ## Per-entity systems (e.g. rendering, foreign APIs)
88
92
 
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`.
93
+ Not every system vectorizes. A renderer calls a draw function per primitive; same for any per-entity foreign API. That's a per-entity system inside an otherwise-vectorized app. The cheapest way to iterate through all the entities is by using `zip()` on the `QueryResult` object on the fields you need, e.g:
94
94
 
95
- Edge cases worth knowing:
95
+ ```python
96
+ qr = world.query(HasPosition, HasColor, HasRadius)
97
+ for pos, color, radius in zip(qr.position, qr.color, qr.radius): # one entity per step, fields aligned
98
+ rl.DrawCircle(int(pos[0]), int(pos[1]), float(radius[0]), color) # per-entity by necessity (no "draw all")
99
+ ```
96
100
 
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.
101
+ 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.
108
102
 
109
- ## Benchmark: ECS vs OOP
103
+ <details>
104
+ <summary> Microbenchmark: ECS vs OOP on a simple physics step </summary>
110
105
 
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}`).
106
+ Benchmark: we run the same physics step `pos += vel*dt` over N=100k entities split across 2 pools in various ways (ECS or OOP). All methods are verified to produce the identical result. Reproduce with `python test/manual/perf/bench_ecs_vs_oop.py` (it prints `{mode: avg_seconds_per_step}`).
114
107
 
115
108
  | pattern | ns/entity | vs OOP-scalar |
116
109
  |---|---:|---:|
@@ -123,8 +116,6 @@ ways, all verified to produce the identical result. Reproduce with
123
116
  | `micro-ecs-get-entity` — `world.get_entity(eid)` per entity | 1674 | 35× slower |
124
117
  | `micro-ecs-index` — `qr.f[i]` per entity (an `np.searchsorted` each) | 4573 | 95× slower |
125
118
 
126
- *Intel Core Ultra 7 165H, single-threaded, python 3.12 / numpy 2.2. ns/entity = seconds/step ÷ N.*
127
-
128
119
  Three things to take from it:
129
120
 
130
121
  1. **Vectorized wins big.** Batched ops (`_Field` or per-pool) run at 1–2 ns/entity — **27–52×
@@ -140,3 +131,26 @@ Three things to take from it:
140
131
  **Rule of thumb:** keep systems vectorized and push branches into `np.where` / `np.clip`. If a
141
132
  workload is *irreducibly* per-entity (data-dependent control flow), plain python objects beat
142
133
  microecs ~15× — use them there. microecs is the right tool for **vectorizable** simulation.
134
+
135
+ </details>
136
+
137
+ ## How much are `Pool` and `QueryResult` numpy-like and corner cases
138
+
139
+ 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)`
140
+ numpy array (e.g. `(N, 2)` for a `(2,)` field). It applies each op **per pool** and stitches result back.
141
+
142
+ 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.
143
+
144
+ Edge cases worth knowing:
145
+
146
+ - **Not a full ndarray — these raise, never lie.** Entity-axis indexing beyond a single
147
+ `qr.f[i]` (`qr.f[:]`, `qr.f[2:4]`, `qr.f[mask]`, fancy), partial entity writes, and ndarray
148
+ methods/attrs (`.sum()`, `.mean()`, `.dtype`, `.ndim`, `.T`). Need any of these? Materialize
149
+ first with `qr.f.numpy()`.
150
+ - **Axis-0 ops are per-pool, not global (footgun).** `np.sort` / `np.cumsum` / `np.sum` over
151
+ `axis=0` run within each pool and reset at pool boundaries — they do **not** see all entities
152
+ at once, so they differ from numpy. They're allowed, but if you want a global result, do
153
+ `qr.f.numpy()` first. A reduction that collapses the entity axis is rejected when its length no
154
+ longer matches the pool's row count.
155
+ - **Operands must come from the same query.** Alignment is per-pool, not by flat index, so don't
156
+ mix a `_Field` from one `world.query(...)` into an op on another.
@@ -17,7 +17,7 @@ class Pool:
17
17
  self.shapes = shapes
18
18
  self.dtypes = dtypes
19
19
 
20
- self.data: dict[str, np.ndarray] = {} # the actual data is stored in a dynamic arrray, one per field
20
+ self.data: dict[str, np.ndarray] = {} # the actual data is stored in dict of dynamic arrrays, one per field
21
21
  self.size = 0
22
22
  self.capacity = Pool.INITIAL_CAPACITY
23
23
  for _field, shape, dtype in zip(fields, shapes, dtypes):
@@ -91,6 +91,9 @@ class _Field(np.lib.mixins.NDArrayOperatorsMixin):
91
91
  def __len__(self):
92
92
  return self.len
93
93
 
94
+ def __repr__(self):
95
+ return f"[Field] Shape: {self.shape} (across {len(self.parts)} pools)"
96
+
94
97
  class QueryResult:
95
98
  """A list of pools seen as a contiguous view. Fields (qr.position) implement array interface to look like numpy"""
96
99
  def __init__(self, pool_list: list[Pool], field_shapes: dict[str, Shape], field_dtypes: dict[str, np.dtype],
@@ -119,6 +122,6 @@ class QueryResult:
119
122
  return self._len
120
123
 
121
124
  def __repr__(self):
122
- return (f"[QueryResult]\n- Entities: {len(self.entity_ids)}\n- Fields: {self._fields}"
123
- f"\n- Pools: {len(self.pool_list)}\n- Len: {self._len}\n- Shapes: {list(self._field_shapes.values())}"
124
- f"\n- Dtypes: {list(self._field_dtypes.values())}")
125
+ return (f"[QueryResult]\n- Entities: {len(self.entity_ids)} (pools: {len(self.pool_list)})"
126
+ f"\n- Fields: {self._fields}"
127
+ f"\n- Shapes: {list(self._field_shapes.values())}\n- Dtypes: {list(self._field_dtypes.values())}")
@@ -24,7 +24,7 @@ class World:
24
24
  self.pool_to_components: dict[Pool, list[ComponentType]] = {}
25
25
  # components management
26
26
  self.component_names = [x.__name__ for x in components]
27
- self.component_types = list(components)
27
+ self.component_types = set(components)
28
28
  self.component_to_bit: dict[ComponentType, int] = {t: 2**i for i, t in enumerate(components)} # bit for querying
29
29
  self.component_to_shapes: dict[ComponentType, list[Shape]] = {
30
30
  c: [f.metadata["shape"] for f in fields(c)] for c in components}
@@ -77,6 +77,12 @@ class World:
77
77
  components = self.pool_to_components[pool]
78
78
  return entity, components
79
79
 
80
+ def set_entity_data(self, entity_id: EntityId, field_name: str, value: np.ndarray):
81
+ """Sets the value of one specific entity's field given an enttiy id. Non-vectorized operation, use rarely"""
82
+ assert entity_id in self._eid_to_pool_ix, f"Entity id {entity_id} not found. Perhaps you didn't world.update()"
83
+ pool, pool_ix = self._eid_to_pool_ix[entity_id]
84
+ pool.data[field_name][pool_ix] = value
85
+
80
86
  def add_component(self, entity_id: EntityId, component: ComponentType, **kwargs):
81
87
  """Adds a component to an entity given its id. Component data is sent to kwargs. Lazy; call update()"""
82
88
  assert entity_id in self.live_ids, f"Entity id: {entity_id} not in {self.live_ids=}"
@@ -168,8 +174,8 @@ class World:
168
174
  # other low-level methods
169
175
 
170
176
  def _check_components_against_pool(self, components: list[ComponentType], **entity_fields):
171
- assert len(cs := components) > 0, f"Entity has no components: {self.component_names}"
172
- assert all(c in self.component_types for c in cs), f"Components '{cs}' not in {self.component_types}"
177
+ assert len(cs := set(components)) > 0, f"Entity has no components: {self.component_names}"
178
+ assert (diff := cs - self.component_types) == set(), f"Missing comps:\n{cs=}\n{self.component_types=}\n{diff=}"
173
179
  expected_fields = set()
174
180
  for component in components:
175
181
  for _field in self.component_to_field_names[component]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: microecs
3
- Version: 0.3.2
3
+ Version: 0.3.4
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
@@ -16,24 +16,35 @@ Minimal (~300 LoC) Entity Component System in python and numpy. Examples also us
16
16
 
17
17
  Usage:
18
18
 
19
- - `pip install -r requirements.txt`
20
- - Sandbox: `python main.py`
21
- - Tests: `pytest test/`
19
+ - Via pip: `pip install microecs`
20
+ - From source code:
21
+ ```bash
22
+ git clone https://gitlab.com/meehai/microecs # clone the source code
23
+ cd microecs # go in the cloned directory
24
+ python -m venv .venv && source .venv/bin/activate # make a virtual env, optional but useful
25
+ python -m pip install -e . # install micro ecs in this virtual env
26
+ python -m pytest test/ # run the unit & integration tests to verify installation
27
+ python examples/01-hello-world.py # run the basic hello world example (others in that dir)
28
+ ```
29
+
30
+ Docs: [meehai.gitlab.io/microecs](https://meehai.gitlab.io/microecs/)
31
+
32
+ ## Relevant primitives: `Component`, `Pool`, `QueryResult`, `World`
22
33
 
23
- There are only 4 primitives (bottom up): `Component`, `Pool`, `QueryResult`, `World`:
34
+ There are only 4 primitives (bottom up):
24
35
 
25
36
  - `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
37
  - `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()` (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`.
38
+ - `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 interface. For all intents and purposes it should feel like a `(N, ...)` view over all selected entities. To get a proper numpy array, use `qr.numpy()`. To iterate over each entity in a query result (e.g. rendering), use `for eid, position in zip(qr.entity_ids, qr.position): ...`.
28
39
  - `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
40
 
30
- Few relevant concepts:
41
+ ### Few relevant concepts:
31
42
 
32
43
  - `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.
33
44
  - 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()`.
34
45
  - `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.
35
46
 
36
- Super simplified main loop structure:
47
+ ## Super simplified main loop structure
37
48
 
38
49
  ```python
39
50
  from typing import Callable
@@ -89,40 +100,22 @@ def main():
89
100
  rl.EndDrawing()
90
101
  ```
91
102
 
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.
103
+ ## Per-entity systems (e.g. rendering, foreign APIs)
100
104
 
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`.
105
+ Not every system vectorizes. A renderer calls a draw function per primitive; same for any per-entity foreign API. That's a per-entity system inside an otherwise-vectorized app. The cheapest way to iterate through all the entities is by using `zip()` on the `QueryResult` object on the fields you need, e.g:
106
106
 
107
- Edge cases worth knowing:
107
+ ```python
108
+ qr = world.query(HasPosition, HasColor, HasRadius)
109
+ for pos, color, radius in zip(qr.position, qr.color, qr.radius): # one entity per step, fields aligned
110
+ rl.DrawCircle(int(pos[0]), int(pos[1]), float(radius[0]), color) # per-entity by necessity (no "draw all")
111
+ ```
108
112
 
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.
113
+ 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.
120
114
 
121
- ## Benchmark: ECS vs OOP
115
+ <details>
116
+ <summary> Microbenchmark: ECS vs OOP on a simple physics step </summary>
122
117
 
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}`).
118
+ Benchmark: we run the same physics step `pos += vel*dt` over N=100k entities split across 2 pools in various ways (ECS or OOP). All methods are verified to produce the identical result. Reproduce with `python test/manual/perf/bench_ecs_vs_oop.py` (it prints `{mode: avg_seconds_per_step}`).
126
119
 
127
120
  | pattern | ns/entity | vs OOP-scalar |
128
121
  |---|---:|---:|
@@ -135,8 +128,6 @@ ways, all verified to produce the identical result. Reproduce with
135
128
  | `micro-ecs-get-entity` — `world.get_entity(eid)` per entity | 1674 | 35× slower |
136
129
  | `micro-ecs-index` — `qr.f[i]` per entity (an `np.searchsorted` each) | 4573 | 95× slower |
137
130
 
138
- *Intel Core Ultra 7 165H, single-threaded, python 3.12 / numpy 2.2. ns/entity = seconds/step ÷ N.*
139
-
140
131
  Three things to take from it:
141
132
 
142
133
  1. **Vectorized wins big.** Batched ops (`_Field` or per-pool) run at 1–2 ns/entity — **27–52×
@@ -152,3 +143,26 @@ Three things to take from it:
152
143
  **Rule of thumb:** keep systems vectorized and push branches into `np.where` / `np.clip`. If a
153
144
  workload is *irreducibly* per-entity (data-dependent control flow), plain python objects beat
154
145
  microecs ~15× — use them there. microecs is the right tool for **vectorizable** simulation.
146
+
147
+ </details>
148
+
149
+ ## How much are `Pool` and `QueryResult` numpy-like and corner cases
150
+
151
+ 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)`
152
+ numpy array (e.g. `(N, 2)` for a `(2,)` field). It applies each op **per pool** and stitches result back.
153
+
154
+ 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.
155
+
156
+ Edge cases worth knowing:
157
+
158
+ - **Not a full ndarray — these raise, never lie.** Entity-axis indexing beyond a single
159
+ `qr.f[i]` (`qr.f[:]`, `qr.f[2:4]`, `qr.f[mask]`, fancy), partial entity writes, and ndarray
160
+ methods/attrs (`.sum()`, `.mean()`, `.dtype`, `.ndim`, `.T`). Need any of these? Materialize
161
+ first with `qr.f.numpy()`.
162
+ - **Axis-0 ops are per-pool, not global (footgun).** `np.sort` / `np.cumsum` / `np.sum` over
163
+ `axis=0` run within each pool and reset at pool boundaries — they do **not** see all entities
164
+ at once, so they differ from numpy. They're allowed, but if you want a global result, do
165
+ `qr.f.numpy()` first. A reduction that collapses the entity axis is rejected when its length no
166
+ longer matches the pool's row count.
167
+ - **Operands must come from the same query.** Alignment is per-pool, not by flat index, so don't
168
+ mix a `_Field` from one `world.query(...)` into an op on another.
@@ -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.2"
6
+ VERSION = "0.3.4"
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
File without changes