microecs 0.3.1__tar.gz → 0.3.3__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.1 → microecs-0.3.3}/PKG-INFO +79 -22
- microecs-0.3.3/README.md +156 -0
- {microecs-0.3.1 → microecs-0.3.3}/microecs/pool.py +2 -1
- {microecs-0.3.1 → microecs-0.3.3}/microecs/query_result.py +3 -0
- {microecs-0.3.1 → microecs-0.3.3}/microecs/world.py +6 -0
- {microecs-0.3.1 → microecs-0.3.3}/microecs.egg-info/PKG-INFO +79 -22
- {microecs-0.3.1 → microecs-0.3.3}/setup.py +1 -1
- microecs-0.3.1/README.md +0 -107
- {microecs-0.3.1 → microecs-0.3.3}/LICENSE.TXT +0 -0
- {microecs-0.3.1 → microecs-0.3.3}/microecs/__init__.py +0 -0
- {microecs-0.3.1 → microecs-0.3.3}/microecs/component.py +0 -0
- {microecs-0.3.1 → microecs-0.3.3}/microecs/utils.py +0 -0
- {microecs-0.3.1 → microecs-0.3.3}/microecs.egg-info/SOURCES.txt +0 -0
- {microecs-0.3.1 → microecs-0.3.3}/microecs.egg-info/dependency_links.txt +0 -0
- {microecs-0.3.1 → microecs-0.3.3}/microecs.egg-info/requires.txt +0 -0
- {microecs-0.3.1 → microecs-0.3.3}/microecs.egg-info/top_level.txt +0 -0
- {microecs-0.3.1 → microecs-0.3.3}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: microecs
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
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
|
|
@@ -9,6 +9,14 @@ Description-Content-Type: text/markdown
|
|
|
9
9
|
License-File: LICENSE.TXT
|
|
10
10
|
Requires-Dist: numpy>=2.2.0
|
|
11
11
|
Requires-Dist: loggez>=0.8
|
|
12
|
+
Dynamic: description
|
|
13
|
+
Dynamic: description-content-type
|
|
14
|
+
Dynamic: home-page
|
|
15
|
+
Dynamic: license
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
Dynamic: requires-dist
|
|
18
|
+
Dynamic: requires-python
|
|
19
|
+
Dynamic: summary
|
|
12
20
|
|
|
13
21
|
# MicroECS
|
|
14
22
|
|
|
@@ -16,24 +24,35 @@ Minimal (~300 LoC) Entity Component System in python and numpy. Examples also us
|
|
|
16
24
|
|
|
17
25
|
Usage:
|
|
18
26
|
|
|
19
|
-
- `pip install
|
|
20
|
-
-
|
|
21
|
-
|
|
27
|
+
- Via pip: `pip install microecs`
|
|
28
|
+
- From source code:
|
|
29
|
+
```bash
|
|
30
|
+
git clone https://gitlab.com/meehai/microecs # clone the source code
|
|
31
|
+
cd microecs # go in the cloned directory
|
|
32
|
+
python -m venv .venv && source .venv/bin/activate # make a virtual env, optional but useful
|
|
33
|
+
python -m pip install -e . # install micro ecs in this virtual env
|
|
34
|
+
python -m pytest test/ # run the unit & integration tests to verify installation
|
|
35
|
+
python examples/01-hello-world.py # run the basic hello world example (others in that dir)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Docs: [meehai.gitlab.io/microecs](https://meehai.gitlab.io/microecs/)
|
|
22
39
|
|
|
23
|
-
|
|
40
|
+
## Relevant primitives: `Component`, `Pool`, `QueryResult`, `World`
|
|
41
|
+
|
|
42
|
+
There are only 4 primitives (bottom up):
|
|
24
43
|
|
|
25
44
|
- `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
45
|
- `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
|
|
46
|
+
- `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
47
|
- `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
48
|
|
|
30
|
-
Few relevant concepts:
|
|
49
|
+
### Few relevant concepts:
|
|
31
50
|
|
|
32
51
|
- `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
52
|
- 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
53
|
- `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
54
|
|
|
36
|
-
Super simplified main loop structure
|
|
55
|
+
## Super simplified main loop structure
|
|
37
56
|
|
|
38
57
|
```python
|
|
39
58
|
from typing import Callable
|
|
@@ -89,29 +108,67 @@ def main():
|
|
|
89
108
|
rl.EndDrawing()
|
|
90
109
|
```
|
|
91
110
|
|
|
92
|
-
##
|
|
111
|
+
## Per-entity systems (e.g. rendering, foreign APIs)
|
|
112
|
+
|
|
113
|
+
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:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
qr = world.query(HasPosition, HasColor, HasRadius)
|
|
117
|
+
for pos, color, radius in zip(qr.position, qr.color, qr.radius): # one entity per step, fields aligned
|
|
118
|
+
rl.DrawCircle(int(pos[0]), int(pos[1]), float(radius[0]), color) # per-entity by necessity (no "draw all")
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
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.
|
|
122
|
+
|
|
123
|
+
<details>
|
|
124
|
+
<summary> Microbenchmark: ECS vs OOP on a simple physics step </summary>
|
|
125
|
+
|
|
126
|
+
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}`).
|
|
127
|
+
|
|
128
|
+
| pattern | ns/entity | vs OOP-scalar |
|
|
129
|
+
|---|---:|---:|
|
|
130
|
+
| `micro-ecs-pool-vectorized` — `for pool: pool.f[:] = pool.f + …` | 0.9 | **52× faster** |
|
|
131
|
+
| `micro-ecs-vectorized` — `qr.f[:] = qr.f + …` (the `_Field`) | 1.8 | **27× faster** |
|
|
132
|
+
| **`oop-scalar`** — `for o: o.x += o.vx*dt` (python floats) | 48 | 1× (baseline) |
|
|
133
|
+
| `oop-numpy` — objects holding `(2,)` numpy arrays | 605 | 13× slower |
|
|
134
|
+
| `micro-ecs-zip-rows` — `for p, v in zip(qr.pos, qr.vel)` | 744 | 15× slower |
|
|
135
|
+
| `micro-ecs-pool-loop` — `for pool: for i: pool.f[i]` | 870 | 18× slower |
|
|
136
|
+
| `micro-ecs-get-entity` — `world.get_entity(eid)` per entity | 1674 | 35× slower |
|
|
137
|
+
| `micro-ecs-index` — `qr.f[i]` per entity (an `np.searchsorted` each) | 4573 | 95× slower |
|
|
138
|
+
|
|
139
|
+
Three things to take from it:
|
|
140
|
+
|
|
141
|
+
1. **Vectorized wins big.** Batched ops (`_Field` or per-pool) run at 1–2 ns/entity — **27–52×
|
|
142
|
+
faster** than the *fastest* OOP loop. Same for data-parallel branches: an `np.where` clamp or
|
|
143
|
+
bounce is ~34× faster than a per-entity `if`.
|
|
144
|
+
2. **Per-entity loops are a cliff, not a tie.** Every per-entity microecs path is **15–95× slower**
|
|
145
|
+
than idiomatic float-based OOP — because microecs is numpy-backed, so a per-entity step pays
|
|
146
|
+
numpy's tiny-array overhead (`oop-numpy` shows the same ~13× tax). One unavoidable per-entity
|
|
147
|
+
pass (~750 ns/entity) costs ~500× a vectorized op (~1.5 ns) and will dominate the frame.
|
|
148
|
+
3. **If you must loop, loop right.** `zip`-rows (15×) < pool-loop (18×) < `get_entity` (35×) <
|
|
149
|
+
`qr.f[i]` (95× — never in a hot loop; `qr.f[i] += …` also raises, you must bind the row first).
|
|
150
|
+
|
|
151
|
+
**Rule of thumb:** keep systems vectorized and push branches into `np.where` / `np.clip`. If a
|
|
152
|
+
workload is *irreducibly* per-entity (data-dependent control flow), plain python objects beat
|
|
153
|
+
microecs ~15× — use them there. microecs is the right tool for **vectorizable** simulation.
|
|
154
|
+
|
|
155
|
+
</details>
|
|
93
156
|
|
|
94
|
-
|
|
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:
|
|
157
|
+
## How much are `Pool` and `QueryResult` numpy-like and corner cases
|
|
97
158
|
|
|
98
|
-
|
|
99
|
-
|
|
159
|
+
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)`
|
|
160
|
+
numpy array (e.g. `(N, 2)` for a `(2,)` field). It applies each op **per pool** and stitches result back.
|
|
100
161
|
|
|
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`.
|
|
162
|
+
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.
|
|
106
163
|
|
|
107
164
|
Edge cases worth knowing:
|
|
108
165
|
|
|
109
|
-
- **Not a full ndarray — these
|
|
166
|
+
- **Not a full ndarray — these raise, never lie.** Entity-axis indexing beyond a single
|
|
110
167
|
`qr.f[i]` (`qr.f[:]`, `qr.f[2:4]`, `qr.f[mask]`, fancy), partial entity writes, and ndarray
|
|
111
168
|
methods/attrs (`.sum()`, `.mean()`, `.dtype`, `.ndim`, `.T`). Need any of these? Materialize
|
|
112
169
|
first with `qr.f.numpy()`.
|
|
113
170
|
- **Axis-0 ops are per-pool, not global (footgun).** `np.sort` / `np.cumsum` / `np.sum` over
|
|
114
|
-
`axis=0` run
|
|
171
|
+
`axis=0` run within each pool and reset at pool boundaries — they do **not** see all entities
|
|
115
172
|
at once, so they differ from numpy. They're allowed, but if you want a global result, do
|
|
116
173
|
`qr.f.numpy()` first. A reduction that collapses the entity axis is rejected when its length no
|
|
117
174
|
longer matches the pool's row count.
|
microecs-0.3.3/README.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
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
|
+
- 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`
|
|
21
|
+
|
|
22
|
+
There are only 4 primitives (bottom up):
|
|
23
|
+
|
|
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`).
|
|
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.
|
|
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): ...`.
|
|
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.
|
|
28
|
+
|
|
29
|
+
### Few relevant concepts:
|
|
30
|
+
|
|
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.
|
|
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()`.
|
|
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.
|
|
34
|
+
|
|
35
|
+
## Super simplified main loop structure
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from typing import Callable
|
|
39
|
+
import numpy as np
|
|
40
|
+
import raylib as rl
|
|
41
|
+
from microecs import World, Component
|
|
42
|
+
|
|
43
|
+
# components
|
|
44
|
+
class HasPosition(Component):
|
|
45
|
+
# 'shape' + 'dtype' are always required. For additional metadata (e.g. examples/03-serialization) use extra_metadata
|
|
46
|
+
position: np.ndarray = field(metadata={"shape": (2, ), "dtype": "float32"})
|
|
47
|
+
class HasVelocity(Component):
|
|
48
|
+
velocity: np.ndarray = field(metadata={"shape": (2, ), "dtype": "float32"})
|
|
49
|
+
class HasColor(Component):
|
|
50
|
+
color: np.ndarray = field(metadata={"shape": (4, ), "dtype": "int32"})
|
|
51
|
+
|
|
52
|
+
# systems: Note they are a convention!
|
|
53
|
+
class RenderSystem:
|
|
54
|
+
def __call__(self, world: World):
|
|
55
|
+
query_result = world.query(HasPosition, HasColor, exclude=[]) # contiguous-like view of all entities matching
|
|
56
|
+
for position, color in zip(query_result.position, query_result.color): # draw each entity
|
|
57
|
+
DrawEntity(position, color)
|
|
58
|
+
|
|
59
|
+
class MotionSystem:
|
|
60
|
+
def __call__(self, world: World):
|
|
61
|
+
qr = world.query(HasPosition, HasVelocity) # 'exclude' is optional
|
|
62
|
+
qr.position[:] = qr.position + qr.velocity * DT # writes back to all the underlying pools using numpy's rules
|
|
63
|
+
# Alternative for per-pool update. Less ergonomic, but maybe faster in extreme cases as it avoids the _Field obj
|
|
64
|
+
for pool in qr.pool_list:
|
|
65
|
+
pool.position[:] = pool.position + pool.velocity * DT
|
|
66
|
+
|
|
67
|
+
def main():
|
|
68
|
+
render_system: list[Callable] = RenderSystem()
|
|
69
|
+
update_systems: list[Callable] = [MotionSystem()]
|
|
70
|
+
|
|
71
|
+
world = World(components=[HasPosition, HasColor, HasVelocity], extra_metadata=None) # extra_metadata is optional
|
|
72
|
+
for _ in range(n_objects):
|
|
73
|
+
# NOTE: world.{add/remove}_{entity/component} are lazy. They take effect after the first world.update() call.
|
|
74
|
+
world.add_entity(components=(HasPosition, HasVelocity, HasColor), # tuple of components (types)
|
|
75
|
+
position= np.array((x, y), "float32"), # data as kwargs
|
|
76
|
+
color= np.array("black", dtype="int32"),
|
|
77
|
+
velocity= np.array((vx, vy), "float32"))
|
|
78
|
+
|
|
79
|
+
while not rl.WindowShouldClose():
|
|
80
|
+
world.update() # must be called at each tick so the lazy methods are processed and entities are updated
|
|
81
|
+
# update stuff...
|
|
82
|
+
_ = [system(world=world) for system in update_systems]
|
|
83
|
+
# draw stuff, e.g. using raylib
|
|
84
|
+
rl.BeginDrawing()
|
|
85
|
+
rl.ClearBackground(rl.RAYWHITE)
|
|
86
|
+
rl.DrawFPS(rl.GetScreenWidth() - 100, 0)
|
|
87
|
+
render_system(world=world)
|
|
88
|
+
rl.EndDrawing()
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Per-entity systems (e.g. rendering, foreign APIs)
|
|
92
|
+
|
|
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
|
+
|
|
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
|
+
```
|
|
100
|
+
|
|
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.
|
|
102
|
+
|
|
103
|
+
<details>
|
|
104
|
+
<summary> Microbenchmark: ECS vs OOP on a simple physics step </summary>
|
|
105
|
+
|
|
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}`).
|
|
107
|
+
|
|
108
|
+
| pattern | ns/entity | vs OOP-scalar |
|
|
109
|
+
|---|---:|---:|
|
|
110
|
+
| `micro-ecs-pool-vectorized` — `for pool: pool.f[:] = pool.f + …` | 0.9 | **52× faster** |
|
|
111
|
+
| `micro-ecs-vectorized` — `qr.f[:] = qr.f + …` (the `_Field`) | 1.8 | **27× faster** |
|
|
112
|
+
| **`oop-scalar`** — `for o: o.x += o.vx*dt` (python floats) | 48 | 1× (baseline) |
|
|
113
|
+
| `oop-numpy` — objects holding `(2,)` numpy arrays | 605 | 13× slower |
|
|
114
|
+
| `micro-ecs-zip-rows` — `for p, v in zip(qr.pos, qr.vel)` | 744 | 15× slower |
|
|
115
|
+
| `micro-ecs-pool-loop` — `for pool: for i: pool.f[i]` | 870 | 18× slower |
|
|
116
|
+
| `micro-ecs-get-entity` — `world.get_entity(eid)` per entity | 1674 | 35× slower |
|
|
117
|
+
| `micro-ecs-index` — `qr.f[i]` per entity (an `np.searchsorted` each) | 4573 | 95× slower |
|
|
118
|
+
|
|
119
|
+
Three things to take from it:
|
|
120
|
+
|
|
121
|
+
1. **Vectorized wins big.** Batched ops (`_Field` or per-pool) run at 1–2 ns/entity — **27–52×
|
|
122
|
+
faster** than the *fastest* OOP loop. Same for data-parallel branches: an `np.where` clamp or
|
|
123
|
+
bounce is ~34× faster than a per-entity `if`.
|
|
124
|
+
2. **Per-entity loops are a cliff, not a tie.** Every per-entity microecs path is **15–95× slower**
|
|
125
|
+
than idiomatic float-based OOP — because microecs is numpy-backed, so a per-entity step pays
|
|
126
|
+
numpy's tiny-array overhead (`oop-numpy` shows the same ~13× tax). One unavoidable per-entity
|
|
127
|
+
pass (~750 ns/entity) costs ~500× a vectorized op (~1.5 ns) and will dominate the frame.
|
|
128
|
+
3. **If you must loop, loop right.** `zip`-rows (15×) < pool-loop (18×) < `get_entity` (35×) <
|
|
129
|
+
`qr.f[i]` (95× — never in a hot loop; `qr.f[i] += …` also raises, you must bind the row first).
|
|
130
|
+
|
|
131
|
+
**Rule of thumb:** keep systems vectorized and push branches into `np.where` / `np.clip`. If a
|
|
132
|
+
workload is *irreducibly* per-entity (data-dependent control flow), plain python objects beat
|
|
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
|
|
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):
|
|
@@ -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
|
|
@@ -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],
|
|
@@ -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=}"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: microecs
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
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
|
|
@@ -9,6 +9,14 @@ Description-Content-Type: text/markdown
|
|
|
9
9
|
License-File: LICENSE.TXT
|
|
10
10
|
Requires-Dist: numpy>=2.2.0
|
|
11
11
|
Requires-Dist: loggez>=0.8
|
|
12
|
+
Dynamic: description
|
|
13
|
+
Dynamic: description-content-type
|
|
14
|
+
Dynamic: home-page
|
|
15
|
+
Dynamic: license
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
Dynamic: requires-dist
|
|
18
|
+
Dynamic: requires-python
|
|
19
|
+
Dynamic: summary
|
|
12
20
|
|
|
13
21
|
# MicroECS
|
|
14
22
|
|
|
@@ -16,24 +24,35 @@ Minimal (~300 LoC) Entity Component System in python and numpy. Examples also us
|
|
|
16
24
|
|
|
17
25
|
Usage:
|
|
18
26
|
|
|
19
|
-
- `pip install
|
|
20
|
-
-
|
|
21
|
-
|
|
27
|
+
- Via pip: `pip install microecs`
|
|
28
|
+
- From source code:
|
|
29
|
+
```bash
|
|
30
|
+
git clone https://gitlab.com/meehai/microecs # clone the source code
|
|
31
|
+
cd microecs # go in the cloned directory
|
|
32
|
+
python -m venv .venv && source .venv/bin/activate # make a virtual env, optional but useful
|
|
33
|
+
python -m pip install -e . # install micro ecs in this virtual env
|
|
34
|
+
python -m pytest test/ # run the unit & integration tests to verify installation
|
|
35
|
+
python examples/01-hello-world.py # run the basic hello world example (others in that dir)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Docs: [meehai.gitlab.io/microecs](https://meehai.gitlab.io/microecs/)
|
|
22
39
|
|
|
23
|
-
|
|
40
|
+
## Relevant primitives: `Component`, `Pool`, `QueryResult`, `World`
|
|
41
|
+
|
|
42
|
+
There are only 4 primitives (bottom up):
|
|
24
43
|
|
|
25
44
|
- `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
45
|
- `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
|
|
46
|
+
- `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
47
|
- `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
48
|
|
|
30
|
-
Few relevant concepts:
|
|
49
|
+
### Few relevant concepts:
|
|
31
50
|
|
|
32
51
|
- `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
52
|
- 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
53
|
- `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
54
|
|
|
36
|
-
Super simplified main loop structure
|
|
55
|
+
## Super simplified main loop structure
|
|
37
56
|
|
|
38
57
|
```python
|
|
39
58
|
from typing import Callable
|
|
@@ -89,29 +108,67 @@ def main():
|
|
|
89
108
|
rl.EndDrawing()
|
|
90
109
|
```
|
|
91
110
|
|
|
92
|
-
##
|
|
111
|
+
## Per-entity systems (e.g. rendering, foreign APIs)
|
|
112
|
+
|
|
113
|
+
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:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
qr = world.query(HasPosition, HasColor, HasRadius)
|
|
117
|
+
for pos, color, radius in zip(qr.position, qr.color, qr.radius): # one entity per step, fields aligned
|
|
118
|
+
rl.DrawCircle(int(pos[0]), int(pos[1]), float(radius[0]), color) # per-entity by necessity (no "draw all")
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
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.
|
|
122
|
+
|
|
123
|
+
<details>
|
|
124
|
+
<summary> Microbenchmark: ECS vs OOP on a simple physics step </summary>
|
|
125
|
+
|
|
126
|
+
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}`).
|
|
127
|
+
|
|
128
|
+
| pattern | ns/entity | vs OOP-scalar |
|
|
129
|
+
|---|---:|---:|
|
|
130
|
+
| `micro-ecs-pool-vectorized` — `for pool: pool.f[:] = pool.f + …` | 0.9 | **52× faster** |
|
|
131
|
+
| `micro-ecs-vectorized` — `qr.f[:] = qr.f + …` (the `_Field`) | 1.8 | **27× faster** |
|
|
132
|
+
| **`oop-scalar`** — `for o: o.x += o.vx*dt` (python floats) | 48 | 1× (baseline) |
|
|
133
|
+
| `oop-numpy` — objects holding `(2,)` numpy arrays | 605 | 13× slower |
|
|
134
|
+
| `micro-ecs-zip-rows` — `for p, v in zip(qr.pos, qr.vel)` | 744 | 15× slower |
|
|
135
|
+
| `micro-ecs-pool-loop` — `for pool: for i: pool.f[i]` | 870 | 18× slower |
|
|
136
|
+
| `micro-ecs-get-entity` — `world.get_entity(eid)` per entity | 1674 | 35× slower |
|
|
137
|
+
| `micro-ecs-index` — `qr.f[i]` per entity (an `np.searchsorted` each) | 4573 | 95× slower |
|
|
138
|
+
|
|
139
|
+
Three things to take from it:
|
|
140
|
+
|
|
141
|
+
1. **Vectorized wins big.** Batched ops (`_Field` or per-pool) run at 1–2 ns/entity — **27–52×
|
|
142
|
+
faster** than the *fastest* OOP loop. Same for data-parallel branches: an `np.where` clamp or
|
|
143
|
+
bounce is ~34× faster than a per-entity `if`.
|
|
144
|
+
2. **Per-entity loops are a cliff, not a tie.** Every per-entity microecs path is **15–95× slower**
|
|
145
|
+
than idiomatic float-based OOP — because microecs is numpy-backed, so a per-entity step pays
|
|
146
|
+
numpy's tiny-array overhead (`oop-numpy` shows the same ~13× tax). One unavoidable per-entity
|
|
147
|
+
pass (~750 ns/entity) costs ~500× a vectorized op (~1.5 ns) and will dominate the frame.
|
|
148
|
+
3. **If you must loop, loop right.** `zip`-rows (15×) < pool-loop (18×) < `get_entity` (35×) <
|
|
149
|
+
`qr.f[i]` (95× — never in a hot loop; `qr.f[i] += …` also raises, you must bind the row first).
|
|
150
|
+
|
|
151
|
+
**Rule of thumb:** keep systems vectorized and push branches into `np.where` / `np.clip`. If a
|
|
152
|
+
workload is *irreducibly* per-entity (data-dependent control flow), plain python objects beat
|
|
153
|
+
microecs ~15× — use them there. microecs is the right tool for **vectorizable** simulation.
|
|
154
|
+
|
|
155
|
+
</details>
|
|
93
156
|
|
|
94
|
-
|
|
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:
|
|
157
|
+
## How much are `Pool` and `QueryResult` numpy-like and corner cases
|
|
97
158
|
|
|
98
|
-
|
|
99
|
-
|
|
159
|
+
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)`
|
|
160
|
+
numpy array (e.g. `(N, 2)` for a `(2,)` field). It applies each op **per pool** and stitches result back.
|
|
100
161
|
|
|
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`.
|
|
162
|
+
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.
|
|
106
163
|
|
|
107
164
|
Edge cases worth knowing:
|
|
108
165
|
|
|
109
|
-
- **Not a full ndarray — these
|
|
166
|
+
- **Not a full ndarray — these raise, never lie.** Entity-axis indexing beyond a single
|
|
110
167
|
`qr.f[i]` (`qr.f[:]`, `qr.f[2:4]`, `qr.f[mask]`, fancy), partial entity writes, and ndarray
|
|
111
168
|
methods/attrs (`.sum()`, `.mean()`, `.dtype`, `.ndim`, `.T`). Need any of these? Materialize
|
|
112
169
|
first with `qr.f.numpy()`.
|
|
113
170
|
- **Axis-0 ops are per-pool, not global (footgun).** `np.sort` / `np.cumsum` / `np.sum` over
|
|
114
|
-
`axis=0` run
|
|
171
|
+
`axis=0` run within each pool and reset at pool boundaries — they do **not** see all entities
|
|
115
172
|
at once, so they differ from numpy. They're allowed, but if you want a global result, do
|
|
116
173
|
`qr.f.numpy()` first. A reduction that collapses the entity axis is rejected when its length no
|
|
117
174
|
longer matches the pool's row count.
|
microecs-0.3.1/README.md
DELETED
|
@@ -1,107 +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()` (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.
|
|
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
|