Contents

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.