Skip to content

Commit ae574a1

Browse files
feat: add Box2D physics2d plugin with CGO (#880)
1 parent f6997a0 commit ae574a1

111 files changed

Lines changed: 46650 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.prototools

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
go = "1.25.5"

pkg/plugin/physics2d/README.md

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
# physics2d
2+
3+
Box2D v3–backed 2D physics plugin for Cardinal. All simulation state lives
4+
on the C side; Cardinal drives it through three ECS components attached to
5+
the entities you want simulated.
6+
7+
Registration:
8+
9+
```go
10+
world := cardinal.NewWorld(cardinal.WorldOptions{TickRate: 60})
11+
cardinal.RegisterPlugin(world, physics2d.NewPlugin(physics2d.Config{
12+
Gravity: physics2d.Vec2{X: 0, Y: -9.8},
13+
TickRate: 60, // match WorldOptions.TickRate
14+
SubStepCount: 4,
15+
}))
16+
world.StartGame()
17+
```
18+
19+
## Enrolling an entity into physics
20+
21+
An entity participates in the simulation when it carries **all three** of
22+
these components. Missing any one → the reconciler skips the entity and no
23+
body is created on the C side.
24+
25+
| Component | Purpose |
26+
|---|---|
27+
| [`Transform2D`](component/spatial.go) | World-space position + rotation (authoritative pose) |
28+
| [`Velocity2D`](component/spatial.go) | Linear + angular velocity |
29+
| [`PhysicsBody2D`](component/physics_body.go) | Body kind, damping, flags, and the compound collider (`Shapes`) |
30+
31+
Use the `NewPhysicsBody2D` constructor — bare struct literals leave
32+
`Active`, `Awake`, `SleepingAllowed` at `false` and `GravityScale` at `0`,
33+
which produces an inactive, sleeping, gravity-less body.
34+
35+
### Example: a dynamic circle
36+
37+
```go
38+
import (
39+
"github.com/argus-labs/world-engine/pkg/cardinal"
40+
"github.com/argus-labs/world-engine/pkg/plugin/physics2d"
41+
)
42+
43+
func SpawnBallSystem(ctx cardinal.WorldContext) error {
44+
id, err := cardinal.Create(ctx,
45+
physics2d.Transform2D{
46+
Position: physics2d.Vec2{X: 0, Y: 10},
47+
Rotation: 0,
48+
},
49+
physics2d.Velocity2D{
50+
Linear: physics2d.Vec2{X: 0, Y: 0},
51+
Angular: 0,
52+
},
53+
physics2d.NewPhysicsBody2D(
54+
physics2d.BodyTypeDynamic,
55+
physics2d.ColliderShape{
56+
ShapeType: physics2d.ShapeTypeCircle,
57+
Radius: 0.5,
58+
Density: 1.0,
59+
Friction: 0.3,
60+
Restitution: 0.2,
61+
CategoryBits: 0x0001,
62+
MaskBits: 0xFFFF,
63+
},
64+
),
65+
)
66+
_ = id
67+
return err
68+
}
69+
```
70+
71+
### Example: a static box (world geometry)
72+
73+
```go
74+
cardinal.Create(ctx,
75+
physics2d.Transform2D{Position: physics2d.Vec2{X: 0, Y: 0}},
76+
physics2d.Velocity2D{},
77+
physics2d.NewPhysicsBody2D(
78+
physics2d.BodyTypeStatic,
79+
physics2d.ColliderShape{
80+
ShapeType: physics2d.ShapeTypeBox,
81+
HalfExtents: physics2d.Vec2{X: 25, Y: 1},
82+
Friction: 0.5,
83+
CategoryBits: 0x0002,
84+
MaskBits: 0xFFFF,
85+
},
86+
),
87+
)
88+
```
89+
90+
### Body-type cheat sheet
91+
92+
- **`BodyTypeStatic`** — immovable world geometry. No writeback.
93+
- **`BodyTypeDynamic`** — full simulation: forces, gravity, collisions. Writeback updates `Transform2D`/`Velocity2D` each tick.
94+
- **`BodyTypeKinematic`** — velocity-driven; Box2D integrates position. Writeback on.
95+
- **`BodyTypeManual`** — gameplay owns position/velocity; Box2D is used only for contact detection. No writeback; the reconciler pushes ECS → Box2D each tick. Use for characters/enemies driven by input or AI.
96+
97+
### Compound colliders
98+
99+
`PhysicsBody2D.Shapes` is a slice — each entry is a child fixture with its
100+
own `LocalOffset`, `LocalRotation`, material, and filter. Shape identity is
101+
by index (slot `i` in `Shapes` ↔ fixture slot `i`), so don't reorder shapes
102+
after creation if you care about per-shape references in contact events.
103+
104+
## Built-in queries
105+
106+
Use these first; they cover most needs and don't require CGO:
107+
108+
- `physics2d.Raycast(RaycastRequest) RaycastResult`
109+
- `physics2d.OverlapAABB(AABBOverlapRequest) AABBOverlapResult`
110+
- `physics2d.CircleSweep(CircleSweepRequest) CircleSweepResult`
111+
112+
All three return zero results when no C-side world exists yet (e.g. before
113+
the first reconcile, or right after `ResetRuntime`).
114+
115+
## Custom queries via CGO
116+
117+
If you need a Box2D feature the plugin doesn't expose (joints, shape casts,
118+
custom query filters, sensor-only overlap, etc.), call Box2D directly from
119+
your own CGO package. The plugin exposes the raw world handle via
120+
[`physics2d.WorldID()`](plugin.go), which returns the Box2D v3 `b2WorldId`
121+
packed as a `uint32`. Reconstruct it in C with `b2LoadWorldId`.
122+
123+
### Userdata encoding
124+
125+
The bridge stuffs identity into Box2D userdata pointers (see [bridge.c:214-217](internal/cbridge/bridge.c#L214-L217)):
126+
127+
- **Body userdata** = entity ID, packed as `(void*)(uintptr_t)entity_id` — unpack with `(uint32_t)(uintptr_t)b2Body_GetUserData(bodyId)`.
128+
- **Shape userdata** = shape slot index (the index into `PhysicsBody2D.Shapes`), packed the same way but as `int32_t`.
129+
130+
So inside any Box2D callback you can recover the ECS entity with one line.
131+
132+
### Example: custom AABB overlap that returns every hit, including sensors
133+
134+
```go
135+
package myphysics
136+
137+
/*
138+
#cgo CFLAGS: -I${SRCDIR}/../../vendor/world-engine/pkg/plugin/physics2d/third_party/box2d/include
139+
#include "box2d/box2d.h"
140+
#include <stdint.h>
141+
142+
static bool overlap_cb(b2ShapeId shapeId, void* ctx) {
143+
// Recover the ECS entity ID from body userdata.
144+
b2BodyId body = b2Shape_GetBody(shapeId);
145+
uint32_t* out = (uint32_t*)ctx;
146+
// ... append unpack_uint32(b2Body_GetUserData(body)) to your buffer ...
147+
(void)out;
148+
return true; // keep going
149+
}
150+
151+
static int my_overlap_all(
152+
uint32_t world_id_packed,
153+
float minX, float minY, float maxX, float maxY,
154+
uint32_t* out_entities, int32_t cap
155+
) {
156+
b2WorldId world = b2LoadWorldId(world_id_packed);
157+
if (!b2World_IsValid(world)) return 0;
158+
159+
b2AABB aabb = { {minX, minY}, {maxX, maxY} };
160+
b2QueryFilter filter = b2DefaultQueryFilter(); // matches everything
161+
b2World_OverlapAABB(world, aabb, filter, overlap_cb, out_entities);
162+
// ... return fill count ...
163+
return 0;
164+
}
165+
*/
166+
import "C"
167+
168+
import "github.com/argus-labs/world-engine/pkg/plugin/physics2d"
169+
170+
func OverlapAll(minX, minY, maxX, maxY float64) []uint32 {
171+
worldID := physics2d.WorldID()
172+
if worldID == 0 {
173+
return nil // no world yet: before init or after ResetRuntime
174+
}
175+
buf := make([]uint32, 256)
176+
n := C.my_overlap_all(
177+
C.uint32_t(worldID),
178+
C.float(minX), C.float(minY), C.float(maxX), C.float(maxY),
179+
(*C.uint32_t)(&buf[0]), C.int32_t(len(buf)),
180+
)
181+
return buf[:int(n)]
182+
}
183+
```
184+
185+
### Rules of engagement
186+
187+
- **Always null-check `WorldID()`** — it returns `0` before the first
188+
`PreUpdate` reconcile and after `ResetRuntime`. Treat `0` as "no world,
189+
skip the query."
190+
- **Never mutate world state from a system.** `WorldID()` gives you a raw
191+
Box2D handle; calling `b2Body_SetTransform` / `b2DestroyBody` / etc.
192+
directly will desync the bridge's entity→body map and the reconciler
193+
will fight you next tick. For mutations, go through ECS components — the
194+
reconciler pushes changes to Box2D before each step.
195+
- **Queries are fine.** Raycasts, overlaps, shape casts, sensor iteration,
196+
contact walks — read-only Box2D calls are safe to make any time after
197+
you've confirmed `WorldID() != 0`.
198+
- **Don't cache the handle across ticks.** `WorldID()` is cheap; call it
199+
at the top of each query. After `ResetRuntime` (e.g. snapshot restore)
200+
the old id is invalid.
201+
202+
## Contact events
203+
204+
Contacts and triggers flow through Cardinal's system-event bus. Register an
205+
emitter with `physics2d.SetStepContactEmitter` and the plugin flushes
206+
`ContactBeginEvent` / `ContactEndEvent` / `TriggerBeginEvent` /
207+
`TriggerEndEvent` each tick. The events carry both entity IDs and both
208+
shape indices, so you can look up the exact `ColliderShape` that produced
209+
the contact.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package component
2+
3+
// PhysicsSingletonTag marks the single entity that holds physics plugin state (ActiveContacts).
4+
type PhysicsSingletonTag struct{}
5+
6+
func (PhysicsSingletonTag) Name() string { return "physics_singleton_tag" }
7+
8+
// ContactPairEntry is one active contact pair tracked by the physics engine. Entries are
9+
// normalized: EntityA < EntityB (or if equal, ShapeIndexA <= ShapeIndexB).
10+
type ContactPairEntry struct {
11+
EntityA uint64 `json:"a"`
12+
ShapeIndexA int `json:"sa"`
13+
EntityB uint64 `json:"b"`
14+
ShapeIndexB int `json:"sb"`
15+
IsSensor bool `json:"sensor"`
16+
// Fixture filters for normalized EntityA/B (recovery End / trigger vs contact routing).
17+
// Omitempty keeps older snapshots valid.
18+
FilterACategoryBits uint64 `json:"fa_cat,omitempty"`
19+
FilterAMaskBits uint64 `json:"fa_mask,omitempty"`
20+
FilterAGroupIndex int32 `json:"fa_grp,omitempty"`
21+
FilterBCategoryBits uint64 `json:"fb_cat,omitempty"`
22+
FilterBMaskBits uint64 `json:"fb_mask,omitempty"`
23+
FilterBGroupIndex int32 `json:"fb_grp,omitempty"`
24+
}
25+
26+
// ActiveContacts persists which contact pairs have had Begin emitted (and not yet End).
27+
// After a rebuild, the physics step diffs this against Box2D's live contact list to emit
28+
// correct Begin/End events without duplicates or missed ends.
29+
type ActiveContacts struct {
30+
Pairs []ContactPairEntry `json:"pairs"`
31+
}
32+
33+
func (ActiveContacts) Name() string { return "active_contacts" }
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package component
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
)
7+
8+
// ShapeType selects which geometry fields in ColliderShape are valid.
9+
//
10+
// Callers must set ShapeType consistently with the populated geometry fields.
11+
// Box2D validates geometry internally (convexity, vertex count, welding) and will panic
12+
// on invalid input. This is caught during development.
13+
type ShapeType uint8
14+
15+
const (
16+
// ShapeTypeCircle uses Radius; fixture is a circle in the shape's local frame.
17+
ShapeTypeCircle ShapeType = iota + 1
18+
// ShapeTypeBox uses HalfExtents (half-width, half-height) for an axis-aligned box in the
19+
// shape's local frame before applying LocalOffset/LocalRotation.
20+
ShapeTypeBox
21+
// ShapeTypeConvexPolygon uses Vertices as a convex polygon in the shape's local frame.
22+
ShapeTypeConvexPolygon
23+
// ShapeTypeStaticChain uses ChainPoints for open chain segments (static or kinematic
24+
// bodies only; not for dynamic bodies which require mass).
25+
ShapeTypeStaticChain
26+
// ShapeTypeStaticChainLoop uses ChainPoints for closed chain loops (static or kinematic
27+
// bodies only; not for dynamic bodies). Unlike ShapeTypeStaticChain, the last vertex
28+
// automatically connects back to the first, creating a sealed boundary.
29+
ShapeTypeStaticChainLoop
30+
// ShapeTypeEdge uses EdgeVertices (exactly 2 points) for a single line segment
31+
// (static or kinematic bodies only). Lighter than a 2-point chain for isolated barriers
32+
// or triggers.
33+
ShapeTypeEdge
34+
// ShapeTypeCapsule uses CapsuleCenter1, CapsuleCenter2, and Radius; fixture is a capsule
35+
// (two semicircles connected by a rectangle) in the shape's local frame.
36+
ShapeTypeCapsule
37+
)
38+
39+
// ColliderShape is one child shape inside a compound PhysicsBody2D.
40+
//
41+
// Each entry has its own local transform, sensor flag, material, and collision filter (category, mask, group).
42+
// Geometry fields are a tagged-union style: only the fields that match ShapeType are used.
43+
// - ShapeTypeCircle → Radius
44+
// - ShapeTypeBox → HalfExtents (half-width on X, half-height on Y, axis-aligned before LocalOffset/LocalRotation)
45+
// - ShapeTypeConvexPolygon → Vertices (convex polygon, respect backend limits)
46+
// - ShapeTypeStaticChain → ChainPoints (open polyline in local space)
47+
// - ShapeTypeStaticChainLoop → ChainPoints (closed loop in local space)
48+
// - ShapeTypeEdge → EdgeVertices (exactly 2 points in local space)
49+
// - ShapeTypeCapsule → CapsuleCenter1, CapsuleCenter2, Radius (two semicircles connected by a rectangle)
50+
type ColliderShape struct {
51+
ShapeType ShapeType `json:"shape_type"`
52+
LocalOffset Vec2 `json:"local_offset"`
53+
LocalRotation float64 `json:"local_rotation"`
54+
IsSensor bool `json:"is_sensor"`
55+
56+
// Geometry (use fields matching ShapeType).
57+
Radius float64 `json:"radius,omitempty"`
58+
HalfExtents Vec2 `json:"half_extents,omitempty"`
59+
Vertices []Vec2 `json:"vertices,omitempty"`
60+
ChainPoints []Vec2 `json:"chain_points,omitempty"`
61+
EdgeVertices [2]Vec2 `json:"edge_vertices,omitempty"`
62+
CapsuleCenter1 Vec2 `json:"capsule_center1,omitempty"`
63+
CapsuleCenter2 Vec2 `json:"capsule_center2,omitempty"`
64+
65+
// Material and per-shape collision filtering (fixture-level in Box2D).
66+
Friction float64 `json:"friction"`
67+
Restitution float64 `json:"restitution"`
68+
Density float64 `json:"density"`
69+
CategoryBits uint64 `json:"category_bits"`
70+
MaskBits uint64 `json:"mask_bits"`
71+
GroupIndex int32 `json:"group_index,omitempty"`
72+
}
73+
74+
// Validate checks for NaN/Inf in all float fields and a valid ShapeType tag.
75+
func (s ColliderShape) Validate() error {
76+
if err := validateVec2("local_offset", s.LocalOffset); err != nil {
77+
return err
78+
}
79+
if !isFinite(s.LocalRotation) {
80+
return errors.New("local_rotation: must be finite")
81+
}
82+
if !isFinite(s.Friction) {
83+
return fmt.Errorf("friction: must be finite, got %v", s.Friction)
84+
}
85+
if !isFinite(s.Restitution) {
86+
return fmt.Errorf("restitution: must be finite, got %v", s.Restitution)
87+
}
88+
if !isFinite(s.Density) {
89+
return fmt.Errorf("density: must be finite, got %v", s.Density)
90+
}
91+
if !isFinite(s.Radius) {
92+
return fmt.Errorf("radius: must be finite, got %v", s.Radius)
93+
}
94+
if err := validateVec2("half_extents", s.HalfExtents); err != nil {
95+
return err
96+
}
97+
for i, v := range s.Vertices {
98+
if err := validateVec2(fmt.Sprintf("vertices[%d]", i), v); err != nil {
99+
return err
100+
}
101+
}
102+
for i, v := range s.ChainPoints {
103+
if err := validateVec2(fmt.Sprintf("chain_points[%d]", i), v); err != nil {
104+
return err
105+
}
106+
}
107+
for i, v := range s.EdgeVertices {
108+
if err := validateVec2(fmt.Sprintf("edge_vertices[%d]", i), v); err != nil {
109+
return err
110+
}
111+
}
112+
if err := validateVec2("capsule_center1", s.CapsuleCenter1); err != nil {
113+
return err
114+
}
115+
if err := validateVec2("capsule_center2", s.CapsuleCenter2); err != nil {
116+
return err
117+
}
118+
119+
switch s.ShapeType {
120+
case ShapeTypeCircle, ShapeTypeBox, ShapeTypeConvexPolygon, ShapeTypeStaticChain,
121+
ShapeTypeStaticChainLoop, ShapeTypeEdge, ShapeTypeCapsule:
122+
default:
123+
return fmt.Errorf("shape_type: unknown value %d", s.ShapeType)
124+
}
125+
return nil
126+
}

0 commit comments

Comments
 (0)