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.
- {microecs-0.3.0 → microecs-0.3.2}/PKG-INFO +70 -5
- microecs-0.3.2/README.md +142 -0
- {microecs-0.3.0 → microecs-0.3.2}/microecs/pool.py +1 -0
- {microecs-0.3.0 → microecs-0.3.2}/microecs/query_result.py +9 -7
- {microecs-0.3.0 → microecs-0.3.2}/microecs/world.py +34 -28
- {microecs-0.3.0 → microecs-0.3.2}/microecs.egg-info/PKG-INFO +70 -5
- {microecs-0.3.0 → microecs-0.3.2}/setup.py +1 -1
- microecs-0.3.0/README.md +0 -77
- {microecs-0.3.0 → microecs-0.3.2}/LICENSE.TXT +0 -0
- {microecs-0.3.0 → microecs-0.3.2}/microecs/__init__.py +0 -0
- {microecs-0.3.0 → microecs-0.3.2}/microecs/component.py +0 -0
- {microecs-0.3.0 → microecs-0.3.2}/microecs/utils.py +0 -0
- {microecs-0.3.0 → microecs-0.3.2}/microecs.egg-info/SOURCES.txt +0 -0
- {microecs-0.3.0 → microecs-0.3.2}/microecs.egg-info/dependency_links.txt +0 -0
- {microecs-0.3.0 → microecs-0.3.2}/microecs.egg-info/requires.txt +0 -0
- {microecs-0.3.0 → microecs-0.3.2}/microecs.egg-info/top_level.txt +0 -0
- {microecs-0.3.0 → microecs-0.3.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: microecs
|
|
3
|
-
Version: 0.3.
|
|
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()
|
|
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(
|
|
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
|
-
|
|
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],
|
|
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.
|
microecs-0.3.2/README.md
ADDED
|
@@ -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
|
|
31
|
+
for i, part in enumerate(self.parts):
|
|
32
32
|
pool_args = [self._chunk(x, i) for x in op_args]
|
|
33
|
-
|
|
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
|
|
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
|
-
-
|
|
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],
|
|
19
|
-
self.
|
|
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[
|
|
27
|
-
self.component_to_shapes: dict[
|
|
28
|
-
|
|
29
|
-
self.component_to_dtypes: dict[
|
|
30
|
-
|
|
31
|
-
self.component_to_field_names: dict[
|
|
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.
|
|
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.
|
|
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.
|
|
65
|
-
self.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
|
200
|
-
assert hints[
|
|
201
|
-
assert
|
|
202
|
-
assert
|
|
203
|
-
assert isinstance(
|
|
204
|
-
assert
|
|
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.
|
|
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.
|
|
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()
|
|
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(
|
|
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
|
-
|
|
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],
|
|
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.
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|