Physik: Kollisionsebenen und Masken (Layer, „Collides with“) #152

Closed
opened 2026-09-18 22:57:39 +02:00 by meleki · 3 comments
Owner

Aus #144 („Später“). Collider sollen sich gezielt ignorieren können, z. B. Spieler-Schüsse treffen keine Spieler, Gegner laufen durch Gegner.

Befund

  • Heute kollidiert jeder Collider mit jedem. Filtern kann man nur über Trigger (keine Blockade) oder im Graph nach dem Event. Es gibt keine Ebenen, keine Masken und auch keine Tags am Objekt.
  • Rapier hat dafür InteractionGroups: 16 Bit Mitgliedschaft + 16 Bit Filter pro Collider, getrennt für Erkennen (setCollisionGroups) und Blockieren (setSolverGroups).
  • Move und der Editor-Run laufen über unsere eigene Geometrie in Solids, Events ohne Dynamic Body über die eigene Überlappungsprüfung. Die Regel muss also an drei Stellen gleich wirken: Rapier, Solids, Event-Paarsuche.

Zu bauen

  • Projektweite Liste benannter Ebenen (max. 16), in den Projekt-Einstellungen.
  • Am Collider: Layer (eine Ebene) und Collides with (Mehrfachauswahl). Vorgabe: Ebene „Default“, kollidiert mit allen — bestehende Projekte ändern sich nicht.
  • Tilemap: eigene Ebene pro Tilemap.
  • Eine gemeinsame Funktion layersMeet(a, b), die Rapier-Gruppen, Solids und die Event-Paarsuche benutzen.
  • Tests: Paar filtert in beiden Systemen gleich; Dynamic Body fällt durch eine ausgeschlossene Ebene; Events bleiben aus.

Offen (mit Vorschlag)

  1. Symmetrisch oder einseitig? Vorschlag: symmetrisch (A trifft B nur, wenn beide einander zulassen) — so rechnet Rapier, und es gibt keine Paare, die nur halb kollidieren.
  2. Erkennen vs. Blockieren getrennt einstellbar? Vorschlag: nein, eine Einstellung für beides. Wer nur erkennen will, nimmt Trigger.
  3. Ebenen im Graph ändern (Node „Set layer“)? Vorschlag: ja, als eigener Node, aber erst nach dem Grundbau.
Aus #144 („Später“). Collider sollen sich gezielt ignorieren können, z. B. Spieler-Schüsse treffen keine Spieler, Gegner laufen durch Gegner. ## Befund - **Heute kollidiert jeder Collider mit jedem.** Filtern kann man nur über `Trigger` (keine Blockade) oder im Graph nach dem Event. Es gibt keine Ebenen, keine Masken und auch keine Tags am Objekt. - Rapier hat dafür `InteractionGroups`: 16 Bit Mitgliedschaft + 16 Bit Filter pro Collider, getrennt für Erkennen (`setCollisionGroups`) und Blockieren (`setSolverGroups`). - `Move` und der Editor-Run laufen über unsere eigene Geometrie in `Solids`, Events ohne Dynamic Body über die eigene Überlappungsprüfung. Die Regel muss also an **drei** Stellen gleich wirken: Rapier, `Solids`, Event-Paarsuche. ## Zu bauen - Projektweite Liste benannter Ebenen (max. 16), in den Projekt-Einstellungen. - Am Collider: **Layer** (eine Ebene) und **Collides with** (Mehrfachauswahl). Vorgabe: Ebene „Default“, kollidiert mit allen — bestehende Projekte ändern sich nicht. - Tilemap: eigene Ebene pro Tilemap. - Eine gemeinsame Funktion `layersMeet(a, b)`, die Rapier-Gruppen, `Solids` und die Event-Paarsuche benutzen. - Tests: Paar filtert in beiden Systemen gleich; Dynamic Body fällt durch eine ausgeschlossene Ebene; Events bleiben aus. ## Offen (mit Vorschlag) 1. **Symmetrisch oder einseitig?** Vorschlag: symmetrisch (A trifft B nur, wenn beide einander zulassen) — so rechnet Rapier, und es gibt keine Paare, die nur halb kollidieren. 2. **Erkennen vs. Blockieren getrennt einstellbar?** Vorschlag: nein, eine Einstellung für beides. Wer nur erkennen will, nimmt `Trigger`. 3. **Ebenen im Graph ändern (Node „Set layer“)?** Vorschlag: ja, als eigener Node, aber erst nach dem Grundbau.
admin added this to the Development project 2026-09-18 23:03:37 +02:00
admin added the low-priority label 2026-09-19 11:08:02 +02:00
admin moved this to Next in Development on 2026-09-19 12:50:17 +02:00
admin added the question label 2026-09-20 13:37:39 +02:00

vorschläge passen

vorschläge passen
admin removed the question label 2026-09-20 14:27:30 +02:00
meleki referenced this issue from a commit 2026-09-20 15:31:27 +02:00
Collision layers and masks: a collider carries a layer and a mask of the layers it meets, and the pair only meets when both masks name the other's layer - the rule is one pure module (src/core/model/collisionLayers.ts) with layerBit, layersMeet and the names a project starts with, so the three places that have to agree read the same function: the editor run through CollisionSystem, the Solids sweep per pair and Rapier, where the mask becomes the collider's interaction groups; the project file gained collisionLayers: ["Default"] (default one layer, no formatVersion bump), the collider schema two new kinds - layer as a dropdown of the project's layers and layerMask as a menu of tickboxes that stays open while it is ticked (Menu.ts learned rows that do not close on a pick) - and the names are edited through "Collision layers..." in the toolbar's Project menu, not a Project tab, which is the decision I made myself because the question ran into the MCP timeout: the dialog (src/editor/CollisionLayersDialog.ts) holds up to 16 names, Add layer appends "Layer <n>" and Apply writes all of them as one undo step (SetCollisionLayersCommand); a mask read from a file goes through a check that refuses anything that is not a finite number, because Number(null) is 0 and a collidesWith: null would silently have meant "meets nothing"; 9 new e2e checks in module 8 cover the menu entry, the dialog, Add layer, Apply and its undo, both inspector rows, the tickbox menu, and the mover walking straight through the crate that 8c blocked on once their layers no longer meet, and 21 new unit tests cover the rule, the schema and the command (#152)
Author
Owner

Gebaut und gepusht: 4f55f85 (dazu defd4a8, siehe unten), Wiki 0b7f0c2.

Was jetzt da ist

Ein Collider trägt eine Ebene (Layer) und eine Maske der Ebenen, die er trifft (Collides with). Ein Paar trifft sich nur, wenn beide Masken die Ebene des anderen nennen — symmetrisch, wie in den Rückfragen entschieden, und eine Einstellung für Erkennen und Blockieren zugleich.

Die Regel liegt als reines Modul src/core/model/collisionLayers.ts (layerBit, layersMeet, die Namen, mit denen ein Projekt startet). Die drei Stellen, die sich einig sein müssen, lesen dieselbe Funktion:

  • CollisionSystem — der Lauf im Editor,
  • solids.ts — der Filter pro Paar im Sweep,
  • PhysicsSystem — bei Rapier wird die Maske zu den Interaktionsgruppen des Colliders.

Projektdatei: neues Feld collisionLayers: ["Default"] (Default eine Ebene, kein formatVersion-Bump). Schema: zwei neue Feldarten — layer als Auswahl der Projektebenen, layerMask als Menü mit Häkchen, das beim Anklicken offen bleibt (Menu.ts kennt jetzt Zeilen, die nicht schließen).

Bearbeitet werden die Namen über „Collision layers…" im Project-Menü der Toolbar, nicht über einen neuen Project-Tab. Das habe ich selbst entschieden, weil die Rückfrage dazu ins Timeout lief — es ist der kleinste Eingriff; ein Project-Tab bleibt möglich, sobald es mehr Projekteinstellungen gibt. Der Dialog hält bis zu 16 Namen, „Add layer" hängt Layer <n> an, „Apply" schreibt alle als einen Undo-Schritt (SetCollisionLayersCommand).

Ein Set layer-Node ist wie besprochen nicht dabei, der kommt später.

Eine Falle, die ein Test gefangen hat

Number(null) ist 0 — eine collidesWith: null in einer Projektdatei hätte damit stillschweigend „trifft nichts" bedeutet. Die Maske geht deshalb durch eine Prüfung, die alles ablehnt, was keine endliche Zahl ist.

Tests

./check.sh                       → CHECK EXIT 0   (119 Files, 1739 Tests)
npm run build                    → BUILD EXIT 0
node tools/e2e.mjs (alle Module) → ALL PASSED – 1138 checks
  • 9 neue e2e-Checks in Modul 8: Menüeintrag, Dialog, „Add layer", „Apply" samt Undo, beide Inspector-Zeilen, das Häkchen-Menü, und zum Schluss läuft der Mover geradewegs durch die Kiste, an der er in 8c noch geblockt hat, sobald ihre Ebenen sich nicht mehr treffen.
  • 21 neue Unit-Tests zur Regel, zum Schema und zum Command.
  • Zwei Gegenproben mit eingebautem Fehler, beide greifen: einmal der Rapier-Pfad, einmal Solids.
  • Dialog und Häkchen-Menü im Screenshot angesehen.
  • Check-Summe 1138 in README und Wiki, lokal gemessen.

Doku

Wiki: Physics und Colliders (Ebene, Maske, wo die Namen bearbeitet werden), API (das Projektfeld), Architecture (collisionLayers, CollisionLayersDialog), Concepts und Tilemaps (die zwei neuen Inspector-Zeilen), Development (Check-Summe).

Zwei Nachträge

  • defd4a8: Der CI-Lauf zum vorigen Commit 5ba796a (#119) war rot — Details stehen dort.
  • Der CI-Lauf zu diesem Stand läuft noch; er bestätigt dann auch die 1138.
Gebaut und gepusht: **`4f55f85`** (dazu `defd4a8`, siehe unten), Wiki **`0b7f0c2`**. ## Was jetzt da ist Ein Collider trägt eine **Ebene** (`Layer`) und eine **Maske** der Ebenen, die er trifft (`Collides with`). Ein Paar trifft sich nur, wenn **beide** Masken die Ebene des anderen nennen — symmetrisch, wie in den Rückfragen entschieden, und eine Einstellung für Erkennen und Blockieren zugleich. Die Regel liegt als reines Modul `src/core/model/collisionLayers.ts` (`layerBit`, `layersMeet`, die Namen, mit denen ein Projekt startet). Die drei Stellen, die sich einig sein müssen, lesen dieselbe Funktion: - `CollisionSystem` — der Lauf im Editor, - `solids.ts` — der Filter pro Paar im Sweep, - `PhysicsSystem` — bei Rapier wird die Maske zu den Interaktionsgruppen des Colliders. **Projektdatei:** neues Feld `collisionLayers: ["Default"]` (Default eine Ebene, kein `formatVersion`-Bump). **Schema:** zwei neue Feldarten — `layer` als Auswahl der Projektebenen, `layerMask` als Menü mit Häkchen, das beim Anklicken offen bleibt (`Menu.ts` kennt jetzt Zeilen, die nicht schließen). **Bearbeitet werden die Namen über „Collision layers…" im Project-Menü der Toolbar**, nicht über einen neuen Project-Tab. Das habe ich selbst entschieden, weil die Rückfrage dazu ins Timeout lief — es ist der kleinste Eingriff; ein Project-Tab bleibt möglich, sobald es mehr Projekteinstellungen gibt. Der Dialog hält bis zu 16 Namen, „Add layer" hängt `Layer <n>` an, „Apply" schreibt alle als **einen** Undo-Schritt (`SetCollisionLayersCommand`). Ein `Set layer`-Node ist wie besprochen **nicht** dabei, der kommt später. ## Eine Falle, die ein Test gefangen hat `Number(null)` ist `0` — eine `collidesWith: null` in einer Projektdatei hätte damit stillschweigend „trifft nichts" bedeutet. Die Maske geht deshalb durch eine Prüfung, die alles ablehnt, was keine endliche Zahl ist. ## Tests ``` ./check.sh → CHECK EXIT 0 (119 Files, 1739 Tests) npm run build → BUILD EXIT 0 node tools/e2e.mjs (alle Module) → ALL PASSED – 1138 checks ``` - **9 neue e2e-Checks in Modul 8:** Menüeintrag, Dialog, „Add layer", „Apply" samt Undo, beide Inspector-Zeilen, das Häkchen-Menü, und zum Schluss läuft der Mover geradewegs durch die Kiste, an der er in 8c noch geblockt hat, sobald ihre Ebenen sich nicht mehr treffen. - **21 neue Unit-Tests** zur Regel, zum Schema und zum Command. - **Zwei Gegenproben mit eingebautem Fehler**, beide greifen: einmal der Rapier-Pfad, einmal `Solids`. - Dialog und Häkchen-Menü im Screenshot angesehen. - **Check-Summe 1138** in README und Wiki, lokal gemessen. ## Doku Wiki: Physics und Colliders (Ebene, Maske, wo die Namen bearbeitet werden), API (das Projektfeld), Architecture (`collisionLayers`, `CollisionLayersDialog`), Concepts und Tilemaps (die zwei neuen Inspector-Zeilen), Development (Check-Summe). ## Zwei Nachträge - **`defd4a8`:** Der CI-Lauf zum vorigen Commit `5ba796a` (#119) war rot — Details stehen dort. - Der CI-Lauf zu diesem Stand läuft noch; er bestätigt dann auch die 1138.
meleki referenced this issue from a commit 2026-09-20 16:51:43 +02:00
The topbar is icons only: the leading Project dropdown, the Scene picker and the labelled Play/Pause buttons are gone and in their place stand five icon buttons - menu, undo, redo on the left, play and pause in the middle - while Notice, the save state, fps and the Account menu keep the right side; the Play button swaps its icon for a stop square while the project runs and Pause swaps for a second play sign while it is held (Button.setIcon()), and iconSpace() drops the gap between icon and label when a button has no label, which is what kept a 24 px icon button 3 px off its own centre; the menu carries New project, Open project..., a separator and Project settings..., where the picker (src/editor/OpenProjectDialog.ts) preselects the open project and marks it "open", opens on a double click and puts Delete on the left of Cancel and Open - it closes itself before it asks whether to delete, because two modals on top of each other would have left the question standing over a list it no longer speaks about - and the settings dialog (src/editor/ProjectSettingsDialog.ts) holds the name, the "Play starts" switch (Start scene / Active scene, a preference of this browser and so no command) and the collision layers from #152, which retires CollisionLayersDialog.ts and the "Collision layers..." entry; what Apply actually writes is the pure function settingsChanges(), so renaming and the layers are each their own undo step and each drop out when nothing differs; the Scene dropdown falls away without replacement, the active scene is switched by double clicking it in the Assets panel, which is the way that was there anyway; new module 46 runs last with 9 checks (no label left in the bar, the run buttons in edit mode, the four menu entries, the picker with the open project preselected, Cancel leaving the project standing, the settings dialog on the state of the project, renaming with undo and redo through the icon buttons) and it neither opens nor deletes a project, because that would pull the project out from under every earlier module; modules 8, 18 and 35 and the harness follow the new bar, 6 unit tests cover settingsChanges and 3 the icons (#183)
admin moved this to Done in Development on 2026-09-20 16:52:35 +02:00
Author
Owner

Nachtrag: die beiden CI-Läufe nach diesem Issue waren rot — beide Male am Rand, nicht an der Regel.

Lauf 112 (4f55f85, Task 176/177): Gate grün, die Editor-Suite grün mit ALL PASSED – 1138 checks, der Actor-Smoke aber mit einem FAIL:

FAIL  walking diagonally covers the same distance as walking straight – {"straight":40,"diagonal":58}

58 ist rund 40·√2, die Diagonale wird also nicht normalisiert gemessen. Lokal ist derselbe Smoke grün (21 Checks). Ich habe den Check nicht angefasst — er gehört nicht zu diesem Issue, und ob er wirklich flattert oder etwas Echtes zeigt, ist noch nicht gemessen. Wenn er wiederkommt, braucht er ein eigenes Issue.

Lauf 113 (defd4a8, Task 178/179): Gate grün, die Suite abgebrochen — kein FAIL, sondern eine Exception in genau dem Block, den dieses Issue angelegt hat:

TypeError: Cannot read properties of null (reading 'includes')
    at colliders (tools/e2e/08-colliders.mjs:142:37)

Ursache: clickText('Project') hat in CI das Project-Menü nicht geöffnet, danach gab __gm.menuRows() null zurück und .includes(...) lief darauf. Lokal ging der Klick immer auf. Der Stand ist inzwischen überholt — mit #183 öffnet das Modul das Menü über clickToolbar('menu') und liest die Zeilen erst danach; ob das in CI hält, zeigt der Lauf zu da35f10.

Was beide gemeinsam haben: die Regel selbst (Ebenen und Masken) war in beiden Läufen grün.

**Nachtrag: die beiden CI-Läufe nach diesem Issue waren rot — beide Male am Rand, nicht an der Regel.** **Lauf 112 (`4f55f85`, Task 176/177):** Gate grün, die Editor-Suite grün mit `ALL PASSED – 1138 checks`, der **Actor-Smoke** aber mit einem FAIL: ``` FAIL walking diagonally covers the same distance as walking straight – {"straight":40,"diagonal":58} ``` 58 ist rund 40·√2, die Diagonale wird also nicht normalisiert gemessen. Lokal ist derselbe Smoke grün (21 Checks). Ich habe den Check **nicht** angefasst — er gehört nicht zu diesem Issue, und ob er wirklich flattert oder etwas Echtes zeigt, ist noch nicht gemessen. Wenn er wiederkommt, braucht er ein eigenes Issue. **Lauf 113 (`defd4a8`, Task 178/179):** Gate grün, die Suite **abgebrochen** — kein FAIL, sondern eine Exception in genau dem Block, den dieses Issue angelegt hat: ``` TypeError: Cannot read properties of null (reading 'includes') at colliders (tools/e2e/08-colliders.mjs:142:37) ``` Ursache: `clickText('Project')` hat in CI das Project-Menü nicht geöffnet, danach gab `__gm.menuRows()` `null` zurück und `.includes(...)` lief darauf. Lokal ging der Klick immer auf. Der Stand ist inzwischen überholt — mit #183 öffnet das Modul das Menü über `clickToolbar('menu')` und liest die Zeilen erst danach; ob das in CI hält, zeigt der Lauf zu `da35f10`. Was beide gemeinsam haben: **die Regel selbst (Ebenen und Masken) war in beiden Läufen grün.**
meleki referenced this issue from a commit 2026-09-20 17:24:47 +02:00
Refactoring round 5 covers the code since round 4 without changing behaviour: four identical copies of dot() and three of "nearest point on a segment" stood in the collision math, in the hit area of the collider outlines and in the polygon handles, so they moved into the new module src/core/util/vec.ts; the warning line, the busy state, info() and the caret reset were written out line by line in both the sign-in screen and the password dialog and are now FormStatus; the closing row "Cancel plus a confirming button, optionally one on the left" was built by hand in FormDialog, FilePicker, OpenProjectDialog and PasswordDialog and is now dialogButtons(); the header bar was drawn by the toolbar, the TabHeader and the Breadcrumb each on its own and is now drawHeaderBar(); AddColliderCommand and AddRigidbodyCommand share a base, each subclass only builds its own asset; Editor.ts derives the panes from assetEditors instead of repeating eight entries and clamps its splitters with clamp(); jscpd over src falls from 0.19 % to 0.14 %; dead code is gone - listScenes() has been orphaned since the Scene picker fell away (#183), DEFAULT_FILTER (#152) was never used, the two test hooks projectPickerRowAt/rowPosition and particleCompiles are read by no tool, and four functions lost an export they did not need; seven comments that told history ("used to", "no longer", "since #...") describe the state of things now, and that this changed nothing was proved by a rebuild - the bundle is md5 identical, b4ad0dfe... before and after; the README said Logout sits at the far right of the toolbar, which has been the Account menu since #182, and wiki/Architecture.md was missing src/core/util/ altogether; 4 new unit tests cover vec.ts, and switching the clamp on the segment off makes exactly one of them fail while none of the 76 neighbouring tests notice, so that clamping was covered nowhere before (#85)
meleki referenced this issue from a commit 2026-09-20 18:15:41 +02:00
The Raycast node filters by collision layers: the node gained a "Layers" setting, a tickbox per collision layer of the project - the same field kind the collider uses for "Collides with" - and the ray only hits what sits on a ticked layer, tilemaps counting as colliders; the default is every layer, and a graph without the param answers exactly as before, which is its own unit test; the check is one-sided on purpose, unlike the symmetric rule of #152: a ray is not a collider, so it never reads the collidesWith of the thing it hits - a collider that meets nothing is still seen by a ray - because who a ray may look at is the ray's business, and a unit test holds that case; the filter sits in Solids.raycast in front of the intersection test, which is the one path the editor run and Play both take, so every unit test runs through describe.each against both systems; NodeView did not know the field kinds layer/layers, so the node would have read "Layers: 65533" - it now shows the name of the layer, or "All layers"; 5 new e2e checks in the new block 43d (a shield on its own layer between the caster and the floor stops the ray, the "Layers" row on the node, the tickbox menu with the project's layers, the ray reaching 280 instead of 145 units once the shield's layer is unticked, and the mask read as a name) and 6 new unit tests, three against each system; two counter-checks hold: disarming the filter line drops 6 unit tests and the unticking check in module 43, and switching the formatting off makes the node check fail with the raw bits; two findings from measuring came out of it and stand in the wiki and the notes - __gm.rays() only measures while the Scene tab is in front, so a node selection blinds it and the block measures through a Log node on distance instead, and a running behavior instance keeps the graph it started with, so the editor run casts with the old mask until the instance is built anew (#158)
meleki referenced this issue from a commit 2026-09-20 19:26:03 +02:00
Moving against a tilemap whose cells block with an outline no longer costs more than a whole frame: an outlined cell is its own polygon, split into convex parts, so a map that is a handful of merged rectangles when built from plain solid tiles is hundreds of solids under one entity - and Move brought every one of them into world coordinates and tested it exactly, including the ones on the far side of the map, which measured 148 ms per Move call on a 20x20 map with 16-corner outlines (34 ms at 8 corners, 0.4 ms with plain solid tiles), matching the report that standing still holds 60 fps while anything that moves stutters; the fix is a broad phase in two stages that decides nothing and only leaves out what cannot be reached - a move never ends further from its start than it is long, because sliding always shortens the rest, so every collider of the mover gets a rectangle grown by the move length and an obstacle whose rectangle does not meet it is never tested, a ray taking the rectangle around itself, and that rectangle is carried back into the map's own units with invert(m) and turned into cell coordinates (cellsWithin), a rotated map through its own axes, so a far cell is not brought into world form in the first place - solidShapesOf was O(cells) per call, which was the second cost block and the reason the 60x60 case still grew; CollisionSystem.overlaps takes the same range, so the editor run asks a map only for the cells within reach of the other side each frame, two maps narrowing nothing, and a Move obstacle now falls away at build time when its layer does not meet the mover's (#152) instead of inside the loop; the measurements after: 148 -> 0.26 ms, 34 -> 0.27 ms, 60x60 20 -> 3.5 ms, plain tiles unchanged; this is no regression of the recent commits - the same benchmark in worktrees against 947a6b1 (#141), 6a31f5e, 4f55f85 and ae3aa7d gives the same 85-148 ms, the cost has been there since outlined cells arrived with #141; new pure functions shapeBounds/grownBounds/boundsMeet in collider.ts and cellsWithin/cellsMeet in tilemapCollision.ts, 11 new unit tests in tests/core/broadphase.test.ts (the rectangles of all four shapes including a rotated box measuring its diagonal reach, touching counts as meeting, cellsWithin straight and turned by 90 degrees, a map handing out 400 cells without a range and under 10 with one, an 800 unit move from far away still stopping at the first cell, a rotated map blocking where its one painted cell was turned to, a ray across the whole map, and the editor run seeing a collider on a far cell), and four counter-checks hold: dropping the cell filter fails the counting test, padding without the move length fails 4 move tests, ignoring the rotation in cellsWithin fails 3, and a wrong range in the detection fails the touching test; one blunt test on the way - the first rotated map was painted on every cell, where even a wrongly computed cell range blocks and the counter-check stayed green, so exactly one cell is painted now and the test measures where it turned to; no e2e check and the check sum stays 1157, because a timing check in the browser would flutter under CI load while modules 40 and 42 cover the correctness anyway, the speed evidence being the node benchmark (#184)
Sign in to join this conversation.
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: meleki/feuerwerk#152