Physics
This file covers the physics domain: the graph-coloured constraint solver, the XPBD
generalization built on it, colliders and contacts, and the editor authoring surface that reaches
them. docs/design/MATTER_SYSTEM.md owns the design for every material that is not rigid.
1. The physics constraint solver
Physics is a domain layer on top of the runtime, the same way the ECS is: it expresses itself as ordinary read/write sets and lets the dependency tracker do the ordering. The solver here is Projected Gauss-Seidel (PGS), the sequential constraint method, parallelised by graph colouring.
A sequential Gauss-Seidel sweep cannot run all constraints at once: two constraints that share a
body would race. So the constraints are edge-coloured over the bodies (color_constraints) —
each colour is a batch in which no two constraints share a body. The ConstraintSolver then
emits one task per colour: a parallel projection over that colour’s constraints, which is
race-free because the bodies are disjoint. Every colour reads and writes the shared position
array, so the runtime orders the colours into a sequential sweep — colour k+1 after colour k —
while parallelising fully within a colour. That ordering is exactly Gauss-Seidel across
colours, and because a colour’s constraints are independent, the parallel result equals the
sequential one. The sweep is repeated for the iteration count, and the whole solve is one graph
compiled once and replayed every frame.
The solver owns no engine concept beyond bodies and constraints — it takes a position array, an
inverse-mass array, and a projection functor — so a new constraint type (contacts, angular
joints) is added by providing its POD and its device projection; the colouring and the graph
structure are reused unchanged. DistanceConstraint with DistanceProjection is the first
concrete type, exercised by samples/physics/pgs_demo.cpp (a hanging chain checked against a
scalar reference).
1.1. XPBD: the rigid-body generalization (SushiLoop M2)
engine/domain/physics/include/SushiEngine/physics/core/rigid_body.hpp,
engine/domain/physics/include/SushiEngine/physics/constraints/xpbd_constraint.hpp, and
engine/domain/physics/include/SushiEngine/physics/solver/xpbd_solver.hpp add the unified XPBD
(position-based dynamics) solver docs/archive/design/SUSHILOOP.md calls for: one
compliant-constraint framework meant to grow into more constraint and body kinds under one solver
seam, rather than a family of special-cased solvers living side by side.
RigidBody extends the PGS solver’s bare position + inverse mass with an orientation
(Quaternion) and a diagonal, body-local inverse inertia tensor, plus the predicted pre-solve
pose (previous_position/previous_orientation) XPBD’s velocity update needs.
predict()/update_velocity() are the two halves of one XPBD sub-step: integrate external
forces into a predicted pose, solve constraints against that prediction, then recover velocity
and angular velocity from how far the solve moved it — never from an explicit force/torque
integration.
XPBDDistanceConstraint generalizes DistanceConstraint to two attachment points (offsets in
each body’s own local frame, so an anchor at the origin recovers a plain rigid link) and adds
compliance — XPBD’s defining feature over plain PBD: a constraint’s stiffness is a physical
unit (inverse stiffness, really) instead of an artifact of iteration count or step size, so
compliance == 0 is a fully rigid constraint and identical PGS behaviour (verified in
tests/integration/test_xpbd_solver.cpp: with zero inverse inertia and zero-offset anchors, no
angular coupling can occur, so the two solvers’ linear terms are the same arithmetic).
XPBDSolver<Constraint> reuses color_constraints/ColorBatches unchanged — same
graph-colouring, same compile-once-replay-every-frame structure as ConstraintSolver — but the
shared resource is one RigidBody buffer instead of separate position/mass buffers, and each
constraint carries a per-step Lagrange multiplier (lambda) that solve() resets to zero before
every step, because the compliance term is only meaningful accumulated within a single step.
samples/physics/xpbd_demo.cpp ports samples/physics/pgs_demo.cpp’s hanging chain onto
RigidBody/XPBDSolver, checked against a byte-for-byte host mirror of the projection.
engine/domain/physics/include/SushiEngine/physics/scene/physics_world.hpp’s
PhysicsWorld<Constraint> is the layer above XPBDSolver that turns a one-shot solve into an
actual loop: register bodies and constraints, finalize() once (uploads the bodies, compiles the
graph — mirrors XPBDSolver’s own build-once-replay-every-frame split), then step() every
frame runs predict → solve → derive-velocity for each requested sub-step. It takes no dependency
on engine/foundation/ecs/ on purpose, keeping the layering direction in
the layer table intact; the ECS-facing half (mapping entities to body
indices, syncing Transform/Orientation each frame) is an engine/world/simulation/-level
concern that builds on this seam rather than being folded into it.
The editor’s “Rigid Body” toggle (engine/world/simulation/source/runtime_simulation.cpp) is
the first consumer of that seam, and takes a different route from the generic
engine/world/simulation/include/SushiEngine/simulation/physics_bridge.hpp below: Renderer/Camera
need an ECS component migration (migrate_components) because their data — colour, lens — lives
in a component only present when attached, but a Rigid Body’s data (position, orientation) is
already Transform/Orientation, always present. So attaching/detaching physics is plain host
bookkeeping in RuntimeSimulation::Record (has_physics_body, physics_parameters).
The physics itself lives behind the Simulation::IPhysicsScene seam
(engine/world/simulation/include/SushiEngine/simulation/physics_services.hpp), not in
RuntimeSimulation: whenever the physics-driven entity set changes, tick() gathers one
RigidBodyDescription per Rigid Body entity and calls set_rigid_bodies, which diffs that set
against the single Physics::RuntimeGraphBuilder solver the seam owns
(engine/domain/physics/include/SushiEngine/physics/solver/runtime_graph_builder.hpp). An entity
that was here last frame keeps its body, its handle and its velocity; one that has gone is removed
with its constraints; one that is new is admitted at its descriptor pose, at rest. So toggling
physics on one entity never disturbs another already-falling body.
RuntimeSimulation only marshals poses across the seam; it owns no solver of its own. tick()
steps the scene under a gravity sampler and writes the solved pose back before the ECS schedule
runs. .scene
(engine/world/serialization/source/scene_serializer.cpp) carries
has_physics_body/physics_body as an independent field pair (not mutually exclusive with
camera/renderer, unlike those two).
RuntimeSimulation owns a Loop::FixedTimestepClock (see
SushiLoop core, and
engine/world/loop/include/SushiEngine/loop/fixed_timestep.hpp):
ISimulation::tick() takes the host’s measured real elapsed time (real_delta_seconds),
accumulates it into the clock, and runs one full step — physics, then the ECS schedule — once per
whole fixed step the clock reports: zero on a fast host frame, and up to the clock’s catch-up
bound after a hitch, with any backlog past that bound dropped (see
the catch-up bound). The render snapshot extract runs once
after the whole loop rather than per step, because a snapshot describes a host frame: extracting
per step wrote a snapshot every later step of the same frame overwrote, and none at all on a
frame that stepped zero times.
The physics sub-step duration is derived from
the clock’s fixed step (fixed_dt() / PHYSICS_SUBSTEPS_PER_TICK) rather than a second,
separately hardcoded constant, so there is one source of truth for tick duration. Each host
measures its own real frame time and hands it to Frame::compose, whose step 2 is the one caller
of tick(); a “Step” request reaches it as TickPolicy::Step, which calls
tick(fixed_dt_seconds()) to force exactly one step regardless of elapsed time. The clock’s
leftover interpolation fraction is computed and stored on RuntimeSimulation after each tick()
but has no consumer yet — render interpolation is a later milestone.
engine/world/simulation/include/SushiEngine/simulation/physics_bridge.hpp is that
simulation-level half. Simulation::PhysicsBody is an ordinary component naming which
PhysicsWorld body an entity owns (INVALID until registered — an entity can carry the
component before it has one); this keeps the mapping in the ECS itself rather than a side table.
Simulation::initial_rigid_body() reads an entity’s current Transform/Orientation once, at
PhysicsWorld::add_body() time, to seed the body’s starting pose.
Simulation::sync_transforms_from_physics() is the one direction wired up so far: every tick,
after PhysicsWorld::step(), it walks every archetype matching {PhysicsBody, Transform, Orientation} (the same World::query() + per-chunk-column walk Schedule::each uses
internally, but as a plain host loop — there is no parallel work here worth a graph node) and
copies each registered body’s solved position/orientation into the entity’s
Transform/Orientation. It has no reverse (ECS -> physics) direction and no wiring into
RuntimeSimulation’s tick loop or the editor; it is a seam beside the live path rather than the
one the live path takes, and placing a physics-driven entity is IRigidBodyService::set_rigid_pose
on IPhysicsScene.
1.2. Primitive shapes, colliders, and Terrain
Three concerns separate cleanly: what an entity looks like, what it collides as, and what drives
its motion. What it looks like is a Simulation::MeshFilter naming the mesh asset it draws plus a
MeshRenderer holding the materials that paint it
(engine/world/simulation/include/SushiEngine/simulation/mesh_filter.hpp,
engine/world/simulation/include/SushiEngine/simulation/mesh_renderer.hpp). What it collides as is
ColliderParameters, a {PrimitiveKind kind; Vector3 parameters;} pair
(engine/world/simulation/include/SushiEngine/simulation/simulation.hpp). All of them are
editor-facing archetype columns (MeshFilter, MeshRendererStorage, ColliderParameters), read
and written through IWorldEditor’s component seam.
The two halves no longer share a vocabulary. A collider is still named by kind and parameters,
because a collider has no imported-geometry path; PrimitiveKind (Box, Sphere, Cylinder,
Plane) is declared in engine/world/simulation/include/SushiEngine/simulation/components.hpp for
it. A visual is named by one always-valid Database::AssetId — a reserved id for one of the three
built-in primitives, or an imported model’s — with its size folded into the entity’s
Transform::scale, so the box-versus-imported fork the renderer used to carry is gone
(docs/design/MESH_MATERIAL_COMPONENT_SPLIT.md).
The recipes create_box/create_sphere/create_cylinder
(engine/world/simulation/include/SushiEngine/simulation/entity_recipes.hpp) each spawn an entity
with a Mesh Filter, a Mesh Renderer and a Collider defaulted to the matching kind and dimensions —
a created Box is collidable out of the box, and either half can be edited or removed independently
afterward. create_terrain spawns a large, thin flat Box visual paired with a Plane Collider,
and — critically — no Rigid Body/PhysicsBody: nothing integrates Terrain’s pose, which is what
makes it immune to gravity, while extract_static_planes
(engine/world/simulation/include/SushiEngine/simulation/physics_extract.hpp) turns its Collider
into the standing plane every body rests on. Collider is what the contact pass and the scene
queries read: the extract copies it onto each RigidBodyDescription, PhysicsSimulation keys it by
body slot, and shape_for_slot turns it into the CollisionShape the broadphase bounds, the
narrowphase generates a manifold from, and a raycast tests against.
Simulation::RenderInstance carries one geometry handle, mirrored on Render::MeshInstance, which
keeps the render seam free of any dependency on Simulation. extract() gates drawing on a
MeshFilter and a MeshRendererStorage column together: an entity draws only when it both
names a mesh and says how to paint it. create() makes a truly empty entity — a plain
Transform/Orientation with neither — so a bare “Create Entity” draws nothing, matching Unity’s
empty GameObject.
engine/presentation/render/source/geometry/mesh_registry.hpp is the device’s mesh store: a unit
cube, a unit sphere and a unit cylinder, plus every imported mesh. Every one of them draws through
the same path — meshes_.mesh(instance.mesh) — because a primitive is a mesh asset like any
other. Its authored dimensions reach the shader only as the entity’s Transform::scale, folded in
once at authoring time by Simulation::shape_scale_fold
(engine/world/simulation/include/SushiEngine/simulation/shape_migration.hpp), which is the one
place those factors are written down: each unit mesh spans half-extent 0.5, so an authored
half-extent doubles. A Box authored as {0.5,0.5,0.5} therefore carries scale {1,1,1} and draws
as the unit cube.
Entity creation (“Create Empty Entity”, Camera, the eight-item Objects submenu — Box, Imported
Mesh, Sphere, Cylinder, Terrain, Particle System, Light, Decal — and the UI
submenu) lives in one place, draw_create_object_menu_items in
applications/editor/source/scene/scene_commands.cpp, which loops over the fifteen
EntityTemplate entries builtin_entity_templates() returns
(applications/editor/source/scene/entity_templates.hpp) instead of naming each factory by
hand, called by the Entity menu and every Hierarchy context menu (row, filtered-search row,
empty space) so they can never drift apart.
Copy/Cut/Paste follow the same pattern via draw_clipboard_menu_items: Copy captures the
selection’s subtrees as a Scene::SceneFragment through Scene::capture_entities, the
serializer’s own entity records, held in EditorContext::clipboard as a ClipboardContents
(applications/editor/source/core/clipboard_contents.hpp) beside the live parent of each root;
Paste creates the records again through Scene::instantiate_entities and puts each root back
under its own parent when that parent still lives; Cut is Copy immediately followed by
destroy on the originals.
1.3. Collision
Two additions extend the XPBD physics without touching the graph-coloured solver.
engine/domain/physics/include/SushiEngine/physics/collision/narrowphase.hpp is the narrowphase:
element-parametric collider shapes (SphereCollider<T>, PlaneCollider<T>, BoxCollider<T>,
and the oriented OrientedBox<T>) and pure functions that return a Contact (unit normal from
the first shape to the second, positive penetration depth, contact point) for each shape pair —
including a full 15-axis SAT collide_obb_obb for oriented-box vs. oriented-box. They are
geometry only — no runtime, ECS, or solver dependency — so they are unit-tested directly
(Unit_Collision).
engine/domain/physics/include/SushiEngine/physics/collision/contact_solver.hpp consumes them for
PhysicsWorld: non-penetration is an inequality constraint that only pushes bodies apart, so
rather than living in the compile-once XPBDSolver (whose constraint set is fixed) it is a
positional projection pass regenerated from the narrowphase each sub-step, run between predict
and update_velocity. Because update_velocity derives velocity from the post-projection
position, a body that lands on a surface loses its downward velocity with no explicit restitution
term. That path is the one Unit_Collision drives; the live scene takes a different one, below.
Contacts are wired into the live tick. PhysicsWorld::step takes an optional post-solve
callback — run each sub-step between the constraint solve and the velocity derivation — so that
world stays collider-agnostic while a caller injects a narrowphase.
Simulation::PhysicsSimulation does it in one solver instead, and needs no injection seam.
Each tick it keeps a Physics::BVHBroadphase over one proxy per rigid body — the oriented box or
sphere its Collider names — plus the scene’s static Plane colliders (Terrain, supplied every
tick via set_static_planes) — generates a manifold per candidate pair, and submits each one to
the solver as a contact constraint.
So a body dropped on terrain comes to rest. Friction and restitution come from the two bodies’
PhysicsMaterials, combined per pair by make_contact_parameters; a manifold carries up to four
points, so a resting box does not rock. Coupling has no special case: any two rigid proxies that
overlap are pushed apart by their own generalized inverse mass, and a pair is excluded only by its
own CollisionFilter rather than by a test in the manifold pass.
1.4. Editor authoring: UI and custom components
Every capability XPBD through
collision added is now authorable in the editor, all through the same plain-C++
IWorldEditor seam and all as attach/detach components.
UI (Canvas + elements). UI is a host-side record on the entity — UIElementKind
(Canvas/Panel/Image/Text/Button) plus a UIElementParameters that is a uGUI RectTransform (anchors,
pivot, anchored position, size, colour, opacity, text) — plain host-side bookkeeping, since nothing
in the Schedule reads it. create_canvas/create_ui_element add them from
Entity ▸ UI (elements parent to the selected UI entity so they lay out inside it). The editor draws
the tree as a 2D overlay: each frame applications/editor/source/main.cpp flattens every UI entity
into UIOverlayElements (params + the index of the UI parent) and both viewports paint them with
ImGui’s draw list (paint_ui_overlay), resolving each rect against the panel rect via a top-left,
y-down variant of the uGUI formula and tinting buttons on hover/press. This is a deliberate shortcut
over a dedicated Vulkan 2D pass — it makes canvases and buttons visible and editable now; the
engine-side SushiEngine::UI module (docs/architecture/DOMAIN_UI.md) remains the
runtime path.
The overlay is also a RectTransform manipulator in the Scene view: it is drawn translucent
with outlines (a full-screen canvas therefore no longer hides the 3D scene, and a canvas is never
picked by its body — clicks fall through to the scene or a child), clicking an element selects
it, dragging its body moves it, and dragging a corner handle resizes it. Each drag inverts the
layout formula (ui_apply_screen_rect) to write the new screen rect back as
position/size_delta, and is one undo step (begin/end mirroring the transform gizmo). The Game
view draws the same overlay solid and non-interactive. This is why ViewportPanel::draw takes a
mutable UIOverlay (elements plus edit-mode and pick/edit outputs) rather than a const element
array.
Custom (script) components. The engine has no scripting VM, so a “custom component” is
authoring data: ScriptComponent (a type_name and a list of ScriptFields, each a tagged
float/int/bool/vec3/colour/text value). Instances live per entity on
RuntimeSimulation::Record::scripts; the catalog of definitions lives in
EditorContext::script_catalog and is repopulated from any script found while a scene loads, so
the Add Component ▸ Scripts menu survives a round-trip. “New Script…” scaffolds a <Name>.hpp C++
system stub in the project (a comment header, a struct, and a commented app.system<…>().each(…)
registration), opens it in the Text Editor, and registers + attaches the new type. Both UI params
and script components serialize with the scene and travel through the copy/paste clipboard
alongside the other optional components.
Enabled/disabled lifecycle. RuntimeSimulation::Record::enabled (default true) is Unity’s
activeSelf: a real, hierarchical on/off switch. visible was narrowed by the same change and is
now a local, non-cascading, render-only flag (Unity’s Renderer.enabled) — it used to cascade
through visible_in_hierarchy, and no longer does; enabled is the only flag that cascades.
A scene authored with a visible=false parent therefore draws the children that cascade used to
hide, which is the one behavior change to existing content this carries.
enabled_in_hierarchy walks an entity’s ancestor chain the same way visible_in_hierarchy
used to, over the new flag instead. Everything that gates on it uses that one walk: extract()’s
mesh/particle/light/decal/skinned instancing loops (combined with the local visible check), and
the physics gather point — PhysicsBridgeHost::gather_sources in
engine/world/simulation/source/runtime/physics_bridge_host.cpp (feeding
extract_rigid_bodies/append_static_planes) — plus the editor’s live audio poll in
AudioEditorSystem::update through the IWorldEditor seam, both its emitters and its reverb
zones. A character controller needs no gate of its own: it is has_character plus a kinematic
rigid body, so it is gathered by extract_rigid_bodies and is already covered by the one on
gather_sources.
set_enabled() and set_parent() both mark the body set stale
(PhysicsBridgeHost::invalidate_bodies) on a real
change, unconditionally, so a toggle on an entity with no physics component of its own still
invalidates a descendant’s gathered state — and so does reparenting a body into or out of a
disabled subtree, which changes gather membership without touching a single flag on the body
itself.
enabled serializes beside visible in write_entity_record/read_entity_record, which the
clipboard reads too, so it survives Save/Load, Undo/Redo, Play→Stop, prefab capture/apply and
copy/paste alike. A disabled entity’s rigid body is removed from the physics world the same way
set_rigid_bodies’ existing diff already removes a genuinely-destroyed one, and comes back at rest
on re-enable — there is no velocity field to preserve across the gap, and preserving one was
deliberately scoped out (see the entity lifecycle
design §4.2 for the full audit).
Runtime instantiate/destroy. IWorldEditor::request_instantiate/request_destroy queue a
spawn or removal instead of applying it immediately — safe to call from code that runs mid-tick,
where the synchronous create/destroy are not. Both are applied once per tick, inside
RuntimeSimulation::step_once, immediately after the ECS schedule runs and before that tick’s
extract(). request_destroy disables its target immediately (see the entry above) even though
the actual removal is deferred; a request_destroy naming a not-yet-flushed
request_instantiate’s id cancels that spawn outright rather than creating and immediately
destroying it.
Native lifecycle hooks. IEntityBehavior
(engine/world/simulation/include/SushiEngine/simulation/entity_behavior.hpp) is the native
counterpart of a ScriptComponent: BehaviorRegistry resolves its type_name to a factory,
registered by SE_REGISTER_BEHAVIOR at static-init time. ISimulation::begin_play()/end_play() —
called by the editor’s Play/Stop toggle, or once each at startup/shutdown by the standalone player,
never by RuntimeSimulation itself — wake and sleep every behavior already in the scene, firing
on_spawn/ on_enable and on_disable/on_destroy respectively; nothing fires outside that
boundary, so authoring a scripted prefab in the Scene view never runs gameplay code.
During Play, destroy and add_script_component/remove_script_component construct or tear down
individual instances the same way — add_script_component is the only path by which a behavior’s
first hook fires for an entity that already exists (an entity is always created bare; nothing
attaches a script before it exists). All of it lives in the EntityLifecycle brick
(engine/world/simulation/source/runtime/entity_lifecycle.hpp), whose side-index of
behavior-bearing entities lets set_enabled/set_parent re-check only those entities’ hierarchy
state, firing on_enable/on_disable on an actual transition rather than on every call.
IWorldEditor::send_message broadcasts a named message to every behavior on a target entity, firing
IEntityBehavior::on_message on each — synchronous, gated on the same play-state boundary, no
reflection: a message name is a string a behavior recognizes by convention, matched the same way
ScriptComponent::type_name already is. See the entity lifecycle
design §6-§7 for the full design.
Reentrancy-safe dispatch. The hook/message dispatch machinery is reentrancy-safe for same-entity
calls: a hook or on_message body that destroys, adds a script component to, or removes a script
component from its own entity no longer risks corrupting the firing loop currently iterating that
entity’s behaviors — see the entity lifecycle design
§8.

