microecs 0.2.4__tar.gz → 0.3.1__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.2.4 → microecs-0.3.1}/PKG-INFO +38 -8
- {microecs-0.2.4 → microecs-0.3.1}/README.md +37 -7
- {microecs-0.2.4 → microecs-0.3.1}/microecs/query_result.py +9 -7
- {microecs-0.2.4 → microecs-0.3.1}/microecs/world.py +56 -36
- {microecs-0.2.4 → microecs-0.3.1}/microecs.egg-info/PKG-INFO +38 -8
- {microecs-0.2.4 → microecs-0.3.1}/setup.py +1 -1
- {microecs-0.2.4 → microecs-0.3.1}/LICENSE.TXT +0 -0
- {microecs-0.2.4 → microecs-0.3.1}/microecs/__init__.py +0 -0
- {microecs-0.2.4 → microecs-0.3.1}/microecs/component.py +0 -0
- {microecs-0.2.4 → microecs-0.3.1}/microecs/pool.py +0 -0
- {microecs-0.2.4 → microecs-0.3.1}/microecs/utils.py +0 -0
- {microecs-0.2.4 → microecs-0.3.1}/microecs.egg-info/SOURCES.txt +0 -0
- {microecs-0.2.4 → microecs-0.3.1}/microecs.egg-info/dependency_links.txt +0 -0
- {microecs-0.2.4 → microecs-0.3.1}/microecs.egg-info/requires.txt +0 -0
- {microecs-0.2.4 → microecs-0.3.1}/microecs.egg-info/top_level.txt +0 -0
- {microecs-0.2.4 → microecs-0.3.1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: microecs
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
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,6 +43,7 @@ from microecs import World, Component
|
|
|
43
43
|
|
|
44
44
|
# components
|
|
45
45
|
class HasPosition(Component):
|
|
46
|
+
# 'shape' + 'dtype' are always required. For additional metadata (e.g. examples/03-serialization) use extra_metadata
|
|
46
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"})
|
|
@@ -51,14 +52,14 @@ class HasColor(Component):
|
|
|
51
52
|
|
|
52
53
|
# systems: Note they are a convention!
|
|
53
54
|
class RenderSystem:
|
|
54
|
-
def __call__(self, world: World):
|
|
55
|
-
query_result = world.
|
|
55
|
+
def __call__(self, world: World):
|
|
56
|
+
query_result = world.query(HasPosition, HasColor, exclude=[]) # contiguous-like view of all entities matching
|
|
56
57
|
for position, color in zip(query_result.position, query_result.color): # draw each entity
|
|
57
58
|
DrawEntity(position, color)
|
|
58
59
|
|
|
59
60
|
class MotionSystem:
|
|
60
|
-
def __call__(self, world: World):
|
|
61
|
-
qr = world.
|
|
61
|
+
def __call__(self, world: World):
|
|
62
|
+
qr = world.query(HasPosition, HasVelocity) # 'exclude' is optional
|
|
62
63
|
qr.position[:] = qr.position + qr.velocity * DT # writes back to all the underlying pools using numpy's rules
|
|
63
64
|
# Alternative for per-pool update. Less ergonomic, but maybe faster in extreme cases as it avoids the _Field obj
|
|
64
65
|
for pool in qr.pool_list:
|
|
@@ -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,32 @@ 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.
|
|
@@ -12,8 +12,8 @@ There are only 4 primitives (bottom up): `Component`, `Pool`, `QueryResult`, `Wo
|
|
|
12
12
|
|
|
13
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
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()
|
|
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(
|
|
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
17
|
|
|
18
18
|
Few relevant concepts:
|
|
19
19
|
|
|
@@ -31,6 +31,7 @@ from microecs import World, Component
|
|
|
31
31
|
|
|
32
32
|
# components
|
|
33
33
|
class HasPosition(Component):
|
|
34
|
+
# 'shape' + 'dtype' are always required. For additional metadata (e.g. examples/03-serialization) use extra_metadata
|
|
34
35
|
position: np.ndarray = field(metadata={"shape": (2, ), "dtype": "float32"})
|
|
35
36
|
class HasVelocity(Component):
|
|
36
37
|
velocity: np.ndarray = field(metadata={"shape": (2, ), "dtype": "float32"})
|
|
@@ -39,14 +40,14 @@ class HasColor(Component):
|
|
|
39
40
|
|
|
40
41
|
# systems: Note they are a convention!
|
|
41
42
|
class RenderSystem:
|
|
42
|
-
def __call__(self, world: World):
|
|
43
|
-
query_result = world.
|
|
43
|
+
def __call__(self, world: World):
|
|
44
|
+
query_result = world.query(HasPosition, HasColor, exclude=[]) # contiguous-like view of all entities matching
|
|
44
45
|
for position, color in zip(query_result.position, query_result.color): # draw each entity
|
|
45
46
|
DrawEntity(position, color)
|
|
46
47
|
|
|
47
48
|
class MotionSystem:
|
|
48
|
-
def __call__(self, world: World):
|
|
49
|
-
qr = world.
|
|
49
|
+
def __call__(self, world: World):
|
|
50
|
+
qr = world.query(HasPosition, HasVelocity) # 'exclude' is optional
|
|
50
51
|
qr.position[:] = qr.position + qr.velocity * DT # writes back to all the underlying pools using numpy's rules
|
|
51
52
|
# Alternative for per-pool update. Less ergonomic, but maybe faster in extreme cases as it avoids the _Field obj
|
|
52
53
|
for pool in qr.pool_list:
|
|
@@ -56,7 +57,7 @@ def main():
|
|
|
56
57
|
render_system: list[Callable] = RenderSystem()
|
|
57
58
|
update_systems: list[Callable] = [MotionSystem()]
|
|
58
59
|
|
|
59
|
-
world = World(components=[HasPosition, HasColor, HasVelocity],
|
|
60
|
+
world = World(components=[HasPosition, HasColor, HasVelocity], extra_metadata=None) # extra_metadata is optional
|
|
60
61
|
for _ in range(n_objects):
|
|
61
62
|
# NOTE: world.{add/remove}_{entity/component} are lazy. They take effect after the first world.update() call.
|
|
62
63
|
world.add_entity(components=(HasPosition, HasVelocity, HasColor), # tuple of components (types)
|
|
@@ -75,3 +76,32 @@ def main():
|
|
|
75
76
|
render_system(world=world)
|
|
76
77
|
rl.EndDrawing()
|
|
77
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.
|
|
@@ -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,30 +14,32 @@ 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
|
-
self._cache: dict[PoolKey, QueryResult] = {}
|
|
42
|
+
self._cache: dict[tuple[PoolKey, PoolKey], QueryResult] = {} # include+exclude key
|
|
40
43
|
logger.debug(f"Created scene with components: {self.component_names}")
|
|
41
44
|
|
|
42
45
|
# public api
|
|
@@ -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,30 +79,44 @@ 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
|
|
|
88
|
-
def
|
|
89
|
-
"""
|
|
92
|
+
def query(self, *include: ComponentType, exclude: list[ComponentType] | None = None) -> QueryResult:
|
|
93
|
+
"""
|
|
94
|
+
Queries the world for entities that match the include set of components.
|
|
95
|
+
Syntax: `world.query(A, B, exclude=[C, D])` is, in logic form, a chain of 'ands': `A & B & ~C & ~D`.
|
|
96
|
+
Return: A `QueryResult` object with the entities that have all the requested components. Has `EntityIds` too.
|
|
97
|
+
"""
|
|
98
|
+
|
|
90
99
|
# Note: we can cache the queries. The only time it can get invalidated (via public API) is at update().
|
|
91
|
-
|
|
100
|
+
include_key = self._make_key(include)
|
|
101
|
+
exclude_key = self._make_key(exclude or [])
|
|
102
|
+
if (key := (include_key, exclude_key)) in self._cache:
|
|
92
103
|
return self._cache[key]
|
|
93
104
|
|
|
105
|
+
# archetype_key = (1 0 0 1 1) &
|
|
106
|
+
# key = (1 0 0 0 1)
|
|
107
|
+
# -> (1 0 0 0 1) OK
|
|
108
|
+
# but
|
|
109
|
+
# archetype_key = (1 0 0 1 1) &
|
|
110
|
+
# key = (1 0 1 0 0)
|
|
111
|
+
# -> (1 0 0 0 0) NOT OK
|
|
94
112
|
res = []
|
|
95
113
|
for archetype_key, archetype_pool in self.pools.items():
|
|
96
|
-
if (archetype_key &
|
|
114
|
+
if (archetype_key & include_key) == include_key and (archetype_key & exclude_key) == 0:
|
|
97
115
|
res.append(archetype_pool)
|
|
98
116
|
|
|
99
|
-
field_names
|
|
100
|
-
field_shapes = dict(zip(field_names, sum([self.component_to_shapes[c] for c in
|
|
101
|
-
field_dtypes = dict(zip(field_names, sum([self.component_to_dtypes[c] for c in
|
|
117
|
+
field_names = sum([self.component_to_field_names[c] for c in include], [])
|
|
118
|
+
field_shapes = dict(zip(field_names, sum([self.component_to_shapes[c] for c in include], [])))
|
|
119
|
+
field_dtypes = dict(zip(field_names, sum([self.component_to_dtypes[c] for c in include], [])))
|
|
102
120
|
entity_ids = np.array(sum((self._pool_ids[p] for p in res), []), dtype="int64")
|
|
103
121
|
|
|
104
122
|
self._cache[key] = QueryResult(res, field_shapes=field_shapes, field_dtypes=field_dtypes, entity_ids=entity_ids)
|
|
@@ -156,15 +174,15 @@ class World:
|
|
|
156
174
|
for component in components:
|
|
157
175
|
for _field in self.component_to_field_names[component]:
|
|
158
176
|
expected_fields.add(_field)
|
|
159
|
-
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}'"
|
|
160
178
|
assert (extra := set(entity_fields) - expected_fields) == set(), f"Extra fields: {extra}; {expected_fields=}"
|
|
161
179
|
|
|
162
180
|
def _get_entity_pool(self, components: list[ComponentType]) -> Pool:
|
|
163
181
|
if (key := self._make_key(components)) not in self.pools:
|
|
164
|
-
|
|
182
|
+
_fields = sum([self.component_to_field_names[component] for component in components], []) # merge fields
|
|
165
183
|
shapes = sum([self.component_to_shapes[component] for component in components], []) # merge shapes
|
|
166
184
|
dtypes = sum([self.component_to_dtypes[component] for component in components], []) # merge dtypes
|
|
167
|
-
self.pools[key] = Pool(
|
|
185
|
+
self.pools[key] = Pool(_fields, shapes, dtypes)
|
|
168
186
|
self.pool_to_components[self.pools[key]] = components
|
|
169
187
|
return self.pools[key]
|
|
170
188
|
|
|
@@ -176,21 +194,23 @@ class World:
|
|
|
176
194
|
return key
|
|
177
195
|
|
|
178
196
|
def _check_components(self, components: list[ComponentType]):
|
|
179
|
-
|
|
180
|
-
|
|
197
|
+
qr_reserved_names = _qres = sorted(vars(QueryResult([], {}, {}, [])))
|
|
198
|
+
dtypes = {"float32", "int32", "bool", "str", "object"}
|
|
199
|
+
expected_meta = {"shape", "dtype", *self.extra_metadata}
|
|
181
200
|
|
|
182
201
|
for component in components:
|
|
183
|
-
|
|
202
|
+
cn = component.__name__
|
|
203
|
+
assert hasattr(component, "__dataclass_fields__"), f"Component '{cn}' is not a dataclass"
|
|
184
204
|
hints = get_type_hints(component) # make it work with from __future__ import annotations
|
|
185
|
-
for
|
|
186
|
-
assert hints[
|
|
187
|
-
assert
|
|
188
|
-
assert
|
|
189
|
-
assert isinstance(
|
|
190
|
-
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}"
|
|
191
211
|
|
|
192
212
|
def __len__(self):
|
|
193
|
-
return len(self.
|
|
213
|
+
return len(self.live_ids)
|
|
194
214
|
|
|
195
215
|
def __repr__(self):
|
|
196
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
|
+
Version: 0.3.1
|
|
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,6 +43,7 @@ from microecs import World, Component
|
|
|
43
43
|
|
|
44
44
|
# components
|
|
45
45
|
class HasPosition(Component):
|
|
46
|
+
# 'shape' + 'dtype' are always required. For additional metadata (e.g. examples/03-serialization) use extra_metadata
|
|
46
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"})
|
|
@@ -51,14 +52,14 @@ class HasColor(Component):
|
|
|
51
52
|
|
|
52
53
|
# systems: Note they are a convention!
|
|
53
54
|
class RenderSystem:
|
|
54
|
-
def __call__(self, world: World):
|
|
55
|
-
query_result = world.
|
|
55
|
+
def __call__(self, world: World):
|
|
56
|
+
query_result = world.query(HasPosition, HasColor, exclude=[]) # contiguous-like view of all entities matching
|
|
56
57
|
for position, color in zip(query_result.position, query_result.color): # draw each entity
|
|
57
58
|
DrawEntity(position, color)
|
|
58
59
|
|
|
59
60
|
class MotionSystem:
|
|
60
|
-
def __call__(self, world: World):
|
|
61
|
-
qr = world.
|
|
61
|
+
def __call__(self, world: World):
|
|
62
|
+
qr = world.query(HasPosition, HasVelocity) # 'exclude' is optional
|
|
62
63
|
qr.position[:] = qr.position + qr.velocity * DT # writes back to all the underlying pools using numpy's rules
|
|
63
64
|
# Alternative for per-pool update. Less ergonomic, but maybe faster in extreme cases as it avoids the _Field obj
|
|
64
65
|
for pool in qr.pool_list:
|
|
@@ -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,32 @@ 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.
|
|
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
|
|
File without changes
|