Box3D physics Plugin, Physics Interface, Rigidbody and Box_Collider Components, and a falling-cubes sample #67

Closed
opened 2026-09-05 09:22:53 +00:00 by crowmaki · 0 comments
Owner

Problem Statement

Cinnabar has no physics. A game built on it today can place GameObjects and draw them through Mesh_Renderer, but nothing moves a GameObject in response to gravity or contact, and there is no Component a game author can attach to say "this thing is a body" or "this thing is a shape". ADR-0016 already decided the engine binds Box3D and sketched the shape of the physics Interface, but none of it exists in the tree: no vendored sources, no build recipe, no Plugin, no Components.

The sample is also the wrong demo for a physics engine. It stress-tests instancing with a 4096-cube grid and a hand-animated hero cube submitted directly, bypassing the Scene. It shows nothing about GameObjects, Components, or simulation.

Solution

Add physics as a Service Plugin the way ADR-0003 and ADR-0014 prescribe: Core declares the Physics Interface type, reserves its Id, and declares the Rigidbody and Box_Collider Component types in the ABI Surface, exactly as it already does for the Backend, Platform and Input roles; a box3d Plugin publishes the Interface, registers the Component Schemas with Core, and steps a Box3D world in fixed_update, writing World Transforms back to the GameObjects whose bodies moved. Any Plugin or Script reaches physics through Core's declaration and never learns which engine is behind it. A game author attaches a Rigidbody and a Box_Collider to a GameObject and it falls.

Replace the sample with a physics demo: a large static plane and, once every second, a new cube spawned above it with a random colour and a random orientation, which falls, lands, and settles. The scene is built from GameObjects and Components, so it also becomes the first end-to-end exercise of Mesh_Renderer in the sample. Every piece runs Headless, which is how the tests run it.

User Stories

  1. As a game developer, I want to list a Box3D Plugin in my Plugin List, so that my Scene has a physics world without writing any binding or build code.
  2. As a game developer, I want to attach a Rigidbody Component to a GameObject, so that the GameObject is simulated as a body.
  3. As a game developer, I want to attach a Box_Collider Component to a GameObject, so that the body has a shape other bodies collide with.
  4. As a game developer, I want a Box_Collider on a GameObject with no Rigidbody to act as a static collider, so that a floor or wall needs one Component, not two.
  5. As a game developer, I want a Rigidbody's body created at the owning GameObject's World Transform, so that where I place an object in the Scene is where it starts simulating.
  6. As a game developer, I want a dynamic body's World Transform written back to its GameObject after every fixed step, so that Mesh_Renderer and every other Component see where physics put it.
  7. As a game developer, I want a Rigidbody to be dynamic, kinematic, or static, so that I can choose whether physics moves it, I move it, or nothing moves it.
  8. As a game developer, I want a kinematic body to follow the GameObject's World Transform each fixed step, so that I can drive a platform from script and other bodies react to it.
  9. As a game developer, I want to set a Rigidbody's initial linear velocity and gravity scale, so that spawned objects can be thrown or float.
  10. As a game developer, I want a Box_Collider's half extents, friction, restitution and density as fields, so that a shape's behaviour is data in the Scene rather than code.
  11. As a game developer, I want several Box_Colliders on one GameObject, so that a compound shape is several Components rather than a special type.
  12. As a game developer, I want exactly one Rigidbody per GameObject, and a second one rejected on add, so that a body has one owner.
  13. As a game developer, I want removing a Rigidbody or Box_Collider, or destroying its GameObject, to destroy the body or shape at the next Structural Flush, so that nothing is simulated that the Scene no longer holds.
  14. As a game developer, I want a Rigidbody whose GameObject is not Active in Hierarchy to be left out of the simulation, so that disabling an object disables its physics.
  15. As a game developer, I want a Rigidbody and Box_Collider to survive a Scene Document save and load with their authored fields intact and their Plugin-side body ids left out, so that a saved Scene simulates identically after a load.
  16. As a game developer, I want to read and set the world's gravity through the Physics Interface, so that a game can have its own up.
  17. As a game developer, I want to cast a ray through the Physics Interface and get back the nearest hit's Component Handle, point, normal and distance, so that gameplay can ask what is under the cursor or under a foot.
  18. As a game developer, I want the Physics Interface to expose the fixed step it simulates at, so that gameplay code can reason about substeps.
  19. As a plugin author, I want the Physics Interface Id, Interface type, and Component types declared by Core, so that a Jolt Plugin publishes the same Interface and registers the same Schemas, and a gameplay Plugin depends on Core alone to use physics.
  20. As a plugin author, I want the Interface shaped as ADR-0016 requires (contacts drained after the step, a u64 body user tag, box as a shape kind the Plugin translates), so that a second physics Plugin fits behind it unchanged.
  21. As an engine maintainer, I want the Box3D C sources vendored into this repo and built by a just recipe into .tools/, so that a compiler upgrade cannot move the physics library under the game and a fresh clone needs no CMake.
  22. As an engine maintainer, I want the Box3D build to use the same clang and llvm-lib toolchain fetch-cimgui uses, so that the engine has one native toolchain requirement.
  23. As an engine maintainer, I want the Plugin to leave BOX3D_DISABLE_SIMD and BOX3D_DOUBLE_PRECISION off and never toggle them per configuration, so that there is one determinism domain.
  24. As an engine maintainer, I want two Engines given the same Plugin list, the same Scene, and the same sequence of fixed deltas to produce identical World Transforms, so that the determinism promise ADR-0016 makes is tested, not assumed.
  25. As an engine maintainer, I want the physics Plugin to run with no Backend and no Platform listed, so that a multiplayer server simulates the same Scene as the client.
  26. As an engine maintainer, I want just check, just build and just test to cover the new packages, so that the physics Plugin is held to the same bar as every other one.
  27. As a sample user, I want the windowed sample to show a large flat plane with cubes dropping onto it, so that I can see the engine simulate.
  28. As a sample user, I want one cube spawned per second, the first at start, so that the scene fills at a pace I can watch.
  29. As a sample user, I want each cube to have a random colour, so that I can tell them apart as they pile up.
  30. As a sample user, I want each cube spawned with a random orientation, so that it tumbles and settles on a face, edge or corner rather than landing flat.
  31. As a sample user, I want each cube spawned within a small horizontal radius rather than at one point, so that the pile spreads instead of forming a tower.
  32. As a sample user, I want cubes drawn with a depth buffer, so that a cube behind another is hidden rather than drawn over it.
  33. As a sample user, I want a fixed camera that frames the plane and the drop column, so that the demo is legible without controls.
  34. As a sample user, I want a -seed:N flag, so that a run can be reproduced.
  35. As a sample user, I want -frames:N and -dump-frame:N to keep working, and the removed -instances and -degeneracy flags to be reported as unknown rather than crash the run.
  36. As a sample user, I want cubes that leave the plane and fall far below it destroyed, so that a long run does not simulate bodies nobody can see.
  37. As a test author, I want to build a Scene of GameObjects with Rigidbody and Box_Collider, tick the Engine Headless, and read World Transforms back, so that physics behaviour is tested at the same seam every other Plugin is.
  38. As a test author, I want the sample's Draw Records to reflect the number of cubes spawned, so that the spawn cadence is tested through the Schedule the way the current sample tests are.
  39. As a test author, I want a Mesh_Renderer to carry a colour, so that the sample's random colours reach the Draw Stream where a test can see them.

Implementation Decisions

Package layout

  • Core declares the physics role. The ABI Surface gains Physics_Interface, the Rigidbody and Box_Collider structs, and the enums they use, beside Backend_Interface, Platform_Interface and Input_Interface. The roles file reserves the Interface Id cinnabar.physics/1 and the Schema names cinnabar.rigidbody and cinnabar.box_collider. Every field is a Boundary Type, so the closure ADR-0022 tests holds: fixed-size float arrays, f32, u64, explicitly sized enums. Core implements none of it and registers no Schema; it declares the shape, as ADR-0003 has it declare the Backend's. A consumer finds the Interface with engine_acquire and the Component Types with engine_component_type by reserved name, both of which report absence when no physics Plugin is listed, which is the Headless-style discipline ADR-0003 asks for.
  • A box3d package is the Plugin. It publishes cinnabar.physics/1, registers the two Schemas with Core in register (the ADR-0014 shape: register unconditionally, the Component simply does not exist when the Plugin is not listed), creates the Box3D world in init, steps it in fixed_update, and destroys it in shutdown. The Odin bindings are copied from Odin's vendor:box3d into this package and adjusted to import the library from .tools/, the same way the cimgui bindings do. Its foreign import path spells the pinned version.
  • The Box3D C sources are vendored into the repo beside the Plugin, pinned to one upstream commit recorded in the Plugin source, and built by a just fetch-box3d recipe into .tools/box3d/<version>/lib/box3d.lib. The recipe uses clang and llvm-lib, as fetch-cimgui does, compiling as C17 with optimisation and no SIMD or double-precision toggle. It verifies the output defines a known Box3D symbol. The library is not committed; .tools/ is already ignored.
  • The justfile's packages list gains the box3d package, and the sample and tests/core link the library through the Plugin, as they already do for cimgui.

Components

  • Rigidbody: body type (an enum: Static, Kinematic, Dynamic; Dynamic is the default set by on_add), initial linear velocity, gravity scale (default 1), a u64 body id marked as a Transient Field, and a u64 static-body id used only when the GameObject has colliders and no Rigidbody (see below). Exactly one per GameObject: on_add walks the owner's Components and sets Wants Destroy on itself if another Rigidbody is present, the same mechanism the lifecycle tests use.
  • Box_Collider: half extents (default 0.5 on each axis), friction, restitution, density (default 1), and a u64 shape id marked as a Transient Field. Many per GameObject.
  • Field defaults are set in on_add, as Mesh_Renderer does, because Core zero-fills storage and a zero-extent box is never what anyone meant.
  • Body user tag is the Rigidbody's Component Handle, stored in Box3D's u64-wide user data. A ray hit reports that Handle. This is the ADR-0016 constraint.
  • Box is a shape kind the Plugin translates: Box_Collider half extents become a Box3D box hull at shape-creation time. The Component never names a hull.

Lifecycle

  • on_add of Rigidbody creates the body at the owner's World Transform's rotation and translation, with the Component's type, velocity and gravity scale. If the owner already has Box_Colliders attached to an implicit static body, their shapes are recreated on the new body and the implicit body destroyed.
  • on_add of Box_Collider attaches a box shape to the owner's Rigidbody body if one exists, otherwise to an implicit static body the Plugin creates for that GameObject at its World Transform. A GameObject's implicit static body is shared by every collider on it and destroyed when the last collider leaves.
  • on_remove of either destroys the shape or body it owns. A body's destruction takes its shapes with it in Box3D, so the Plugin tracks which handles are already dead rather than double-freeing.
  • World Transform scale is not physics' concern. Box3D knows rotation and translation. The write-back composes the body's rotation and translation with the scale the GameObject's World Transform already had, and collider half extents are taken as authored, not multiplied by hierarchy scale.

The fixed step

  • fixed_update does three things in order: copy every kinematic Rigidbody's World Transform into its body; step the world once by Frame Timing's fixed delta with a fixed substep count; walk the dynamic Rigidbodys through Core's dense slice and parallel owner array and write each body's transform back with gameobject_set_world_transform. Static bodies are set once at creation and never read again.
  • Active in Hierarchy is tested on the write-back and the kinematic copy. A Rigidbody whose owner is inactive has its body disabled in Box3D on the step it is first seen inactive, and re-enabled when it is seen active again, so an inactive object neither moves nor blocks others.
  • No interpolation. The Interpolation Alpha exists and Core supplies it, but this iteration draws bodies at their last fixed-step transform. Interpolation needs a previous-transform store per body and is deferred.
  • Determinism is the same call order at the same fixed delta on the same binary, as ADR-0016 promises and no more. The Plugin steps only in fixed_update, so a frame that runs zero or several fixed steps still produces the same simulation for the same accumulated time.

The Physics Interface

  • set_gravity and gravity.
  • ray_cast(origin, direction, max_distance) -> (hit: bool, rigidbody: Component_Handle, point, normal, distance), nearest hit only.
  • fixed_delta() reports what the world is stepped by.
  • body_count() for diagnostics and tests.
  • The struct reserves no slot for contacts or the character controller, but its shape follows the ten ADR-0016 constraints so that adding drain_contacts and move later is an addition, not a redesign.
  • All procs take self first and keep the Odin calling convention, per ADR-0008.
  • The Interface's argument and result types are Boundary Types declared in the ABI Surface; a ray hit crosses as a plain struct of a Component_Handle, two float triples and a distance, so a Script host can call it through a Shim later without a redesign.

Rendering changes needed by the sample

  • Mesh_Renderer gains a colour: [4]f32 field, default white, which the mesh pipeline copies into the Push-Constant Prelude payload. The shader multiplies vertex colour by the payload colour and by the Pass tint. The per-draw grid offset logic leaves the shader with the grid.
  • The mesh pipeline gains a depth attachment: a D32_Float Virtual Resource sized from the swapchain description, wired as a second attachment cleared to 1.0, with depth test and depth write enabled on the pipeline. Core already carries the format and the Vulkan Backend already picks a depth attachment by format. Headless substitutes configured swapchain defaults, so the declaration runs unchanged there. Sort direction stays Front_To_Back.

The sample

  • sample_scene becomes a Scene of GameObjects. It declares requires on the mesh Interface and the physics Interface. In init it creates the shared cube vertex and index buffers, a plane GameObject with a Box_Collider (a wide, thin box) and a Mesh_Renderer (the cube mesh scaled by the GameObject's local transform), and the camera View. It has no draw hook and calls engine_submit never: the mesh pipeline draws the Mesh_Renderers, which is ADR-0014 in practice.
  • Spawning runs in fixed_update on an accumulator of fixed deltas, so the cadence is one per second of simulated time and identical Headless and windowed. The first cube spawns on the first fixed step. Each spawn creates a GameObject at the drop height with a random offset inside the spawn radius, a uniformly random orientation, and attaches Mesh_Renderer (random colour), Rigidbody (Dynamic) and Box_Collider (half extents 0.5).
  • Randomness comes from a generator seeded from a seed parameter, so a test and a -seed:N run reproduce. Random orientation is a uniform random unit quaternion, not three random Euler angles.
  • Cull-out: a cube whose World Transform falls below a floor height well under the plane has its GameObject destroyed.
  • Params becomes: seed, spawn interval seconds (default 1), drop height, spawn radius, plane half extent. grid_instances, grid_spacing, degeneracy and rotation_hz go. The app options parser drops -instances and -degeneracy and gains -seed.
  • Plugin List Order in both sample executables: mesh pipeline, then box3d, then sample_scene, then controls, because requires demands the two Interfaces come first and Core verifies that at startup.
  • The camera is fixed: eye offset back and up from the drop column, looking at a point just above the plane, with the existing perspective and look-at helpers and the existing Y-negation convention.

Documentation

  • The glossary gains entries for Rigidbody, Collider, Physics Interface, Implicit Static Body, and Body User Tag, using the terms above. The Interface entry's list of Core-declared roles ("Backend, Platform") grows to name Physics.
  • ADR-0003's decision is extended, not changed: a third role Core declares and does not implement. Record that in a short note on ADR-0003, as ADR-0017 already added one.
  • ADR-0016's file carries the heading "ADR-0018" and cites docs/research/physics-engine.md, which is not in the tree. Fix the heading; either commit the research note or reword the citation.

Testing Decisions

A good test runs an Engine Headless with a Plugin list, builds a Scene through the Core Scene API, ticks with engine_tick_with_delta, and asserts on what the Scene API and the compiled Schedule report: World Transforms, Component liveness, Draw Records. It does not read Plugin State, does not call Box3D, and does not depend on Backend or Platform. Numerical assertions use tolerances and ranges (a settled cube's height is between its half extent and its circumscribed radius), never exact floats, except for the determinism test, which compares two Engines bit for bit.

Prior art: tests/core/sample_test.odin (Plugin list from app.scene_plugins, ticking, reading Draw Records off the Schedule), tests/core/mesh_renderer_test.odin (a harness Plugin beside the Plugin under test, GameObjects and Components created from the test, flush and teardown helpers), tests/core/component_lifecycle_test.odin (on_add/on_remove, Wants Destroy, fixed-step counting), tests/core/scene_document_test.odin (save and load round trips). Tests live in tests/core because everything they need is Headless; the suite already links a native library for the imgui tests, so a second one follows the same precedent.

What is tested

  • Physics Plugin, through Components:
    • A GameObject with Rigidbody and Box_Collider above a static Box_Collider plane has a lower Y after one second of fixed steps, and after several seconds its Y stops changing and sits within the settled range.
    • A Box_Collider with no Rigidbody does not move across many fixed steps.
    • A kinematic Rigidbody moved with gameobject_set_local_transform is where it was put after the step, and a dynamic cube resting on it is carried.
    • Removing a Rigidbody mid-run, or destroying its GameObject, leaves body_count one lower after the flush; the remaining bodies keep simulating.
    • A second Rigidbody on one GameObject has Wants Destroy set and is gone at the flush; the first survives.
    • Adding a Rigidbody to a GameObject that already carries colliders makes the object fall (the shapes migrated off the implicit static body).
    • Deactivating a falling cube's GameObject freezes its World Transform; reactivating resumes the fall.
    • The write-back preserves the GameObject's scale.
    • ray_cast straight down from above the plane reports the plane's Rigidbody-less collider as no Component Handle and the point at the plane's top; with a cube in the way it reports the cube's Rigidbody Handle.
    • Two Engines with the same Plugin list and the same scripted Scene produce identical World Transforms after the same fixed-step sequence.
    • A Scene Document written after adding the Components contains the authored fields and no body or shape id; loading it into a fresh Engine and stepping produces a falling cube.
    • With the physics Plugin not listed, engine_component_type for the two Schema names reports absent and engine_acquire of the Physics Interface reports not found, the mirror of the existing "headless without the render plugin" test.
    • The ABI Surface test that reads core/abi.odin at compile time passes with the new types in place, which is what makes "declared in the ABI Surface" checked rather than asserted.
  • Mesh pipeline: a Mesh_Renderer's colour arrives in the Draw Record payload; the mesh Pass has two attachments and the second is D32_Float.
  • Sample, through app.scene_plugins: at the first frame the Schedule holds one cube draw plus the plane; after N seconds of fixed steps it holds N+1 cubes plus the plane; two cubes' payload colours differ; two cubes' transforms differ in rotation; a cube's transform descends across frames; the same seed reproduces the same Draw Records and a different seed does not; every spawned cube sits inside the View's clip volume at spawn; the vertex layout test against the generated Pass Interface is kept.
  • Startup: listing sample_scene before box3d fails engine_init with the missing-Interface error, which the existing requires-order check already produces.

Out of Scope

  • Contact events and the drain-after-step API. The Interface is shaped so they can be added; they are not added here.
  • The character controller.
  • Shapes other than box: sphere, capsule, mesh, compound as a type.
  • Joints and constraints.
  • Interpolation of body transforms by the Interpolation Alpha.
  • Collision layers and filtering.
  • Physics material assets; friction and restitution are per-collider fields.
  • Play Mode capture and restore of a running simulation; a restored Scene recreates bodies at rest.
  • Multithreaded stepping; the world is created with one worker.
  • Scripting access to physics from Luau.
  • Debug drawing of collider shapes.
  • Non-Windows builds of the Box3D library. The recipe follows fetch-cimgui, which is Windows-only today.

Further Notes

  • Plugin List Order matters twice here and Core enforces only one: requires puts mesh_pipeline and box3d ahead of sample_scene. The order between box3d's fixed_update and sample_scene's is not enforced and does not matter to the behaviour specified, because a cube spawned in one fixed step is first simulated in the next either way. Say so in the sample's list comment, as the windowed sample already does for imgui.
  • Core allocates Component storage and reflects the Schema from the Odin struct (ADR-0018). The u64 body and shape ids are the Transient Fields that ADR named as its motivating case; both must carry the tag or a saved Scene writes a dead handle.
  • gameobject_set_world_transform fails on a parent chain that does not invert. A Rigidbody under a zero-scaled ancestor will log that once per step; that is acceptable and not something the Plugin should paper over.
  • Box3D is alpha (v0.1.0). A bug we hit is an issue filed upstream or a patch carried in the vendored sources, which is why the sources are in this repo rather than pulled from Odin's moving snapshot.
  • Odin's vendor:box3d bindings on Windows export the foreign import and hard-code lib/box3d.lib beside the bindings. Copying them into the Plugin and pointing at .tools/ is the change; the proc signatures stay as Odin wrote them.
  • The existing sample tests for the grid, hero rotation, degeneracy and instance count are deleted with the features they test. The winding and projection tests survive because the cube mesh and the projection helpers survive.
## Problem Statement Cinnabar has no physics. A game built on it today can place GameObjects and draw them through `Mesh_Renderer`, but nothing moves a GameObject in response to gravity or contact, and there is no Component a game author can attach to say "this thing is a body" or "this thing is a shape". ADR-0016 already decided the engine binds Box3D and sketched the shape of the physics Interface, but none of it exists in the tree: no vendored sources, no build recipe, no Plugin, no Components. The sample is also the wrong demo for a physics engine. It stress-tests instancing with a 4096-cube grid and a hand-animated hero cube submitted directly, bypassing the Scene. It shows nothing about GameObjects, Components, or simulation. ## Solution Add physics as a Service Plugin the way ADR-0003 and ADR-0014 prescribe: Core declares the Physics Interface type, reserves its Id, and declares the `Rigidbody` and `Box_Collider` Component types in the ABI Surface, exactly as it already does for the Backend, Platform and Input roles; a `box3d` Plugin publishes the Interface, registers the Component Schemas with Core, and steps a Box3D world in `fixed_update`, writing World Transforms back to the GameObjects whose bodies moved. Any Plugin or Script reaches physics through Core's declaration and never learns which engine is behind it. A game author attaches a `Rigidbody` and a `Box_Collider` to a GameObject and it falls. Replace the sample with a physics demo: a large static plane and, once every second, a new cube spawned above it with a random colour and a random orientation, which falls, lands, and settles. The scene is built from GameObjects and Components, so it also becomes the first end-to-end exercise of `Mesh_Renderer` in the sample. Every piece runs Headless, which is how the tests run it. ## User Stories 1. As a game developer, I want to list a Box3D Plugin in my Plugin List, so that my Scene has a physics world without writing any binding or build code. 2. As a game developer, I want to attach a `Rigidbody` Component to a GameObject, so that the GameObject is simulated as a body. 3. As a game developer, I want to attach a `Box_Collider` Component to a GameObject, so that the body has a shape other bodies collide with. 4. As a game developer, I want a `Box_Collider` on a GameObject with no `Rigidbody` to act as a static collider, so that a floor or wall needs one Component, not two. 5. As a game developer, I want a `Rigidbody`'s body created at the owning GameObject's World Transform, so that where I place an object in the Scene is where it starts simulating. 6. As a game developer, I want a dynamic body's World Transform written back to its GameObject after every fixed step, so that `Mesh_Renderer` and every other Component see where physics put it. 7. As a game developer, I want a `Rigidbody` to be dynamic, kinematic, or static, so that I can choose whether physics moves it, I move it, or nothing moves it. 8. As a game developer, I want a kinematic body to follow the GameObject's World Transform each fixed step, so that I can drive a platform from script and other bodies react to it. 9. As a game developer, I want to set a `Rigidbody`'s initial linear velocity and gravity scale, so that spawned objects can be thrown or float. 10. As a game developer, I want a `Box_Collider`'s half extents, friction, restitution and density as fields, so that a shape's behaviour is data in the Scene rather than code. 11. As a game developer, I want several `Box_Collider`s on one GameObject, so that a compound shape is several Components rather than a special type. 12. As a game developer, I want exactly one `Rigidbody` per GameObject, and a second one rejected on add, so that a body has one owner. 13. As a game developer, I want removing a `Rigidbody` or `Box_Collider`, or destroying its GameObject, to destroy the body or shape at the next Structural Flush, so that nothing is simulated that the Scene no longer holds. 14. As a game developer, I want a `Rigidbody` whose GameObject is not Active in Hierarchy to be left out of the simulation, so that disabling an object disables its physics. 15. As a game developer, I want a `Rigidbody` and `Box_Collider` to survive a Scene Document save and load with their authored fields intact and their Plugin-side body ids left out, so that a saved Scene simulates identically after a load. 16. As a game developer, I want to read and set the world's gravity through the Physics Interface, so that a game can have its own up. 17. As a game developer, I want to cast a ray through the Physics Interface and get back the nearest hit's Component Handle, point, normal and distance, so that gameplay can ask what is under the cursor or under a foot. 18. As a game developer, I want the Physics Interface to expose the fixed step it simulates at, so that gameplay code can reason about substeps. 19. As a plugin author, I want the Physics Interface Id, Interface type, and Component types declared by Core, so that a Jolt Plugin publishes the same Interface and registers the same Schemas, and a gameplay Plugin depends on Core alone to use physics. 20. As a plugin author, I want the Interface shaped as ADR-0016 requires (contacts drained after the step, a `u64` body user tag, box as a shape kind the Plugin translates), so that a second physics Plugin fits behind it unchanged. 21. As an engine maintainer, I want the Box3D C sources vendored into this repo and built by a `just` recipe into `.tools/`, so that a compiler upgrade cannot move the physics library under the game and a fresh clone needs no CMake. 22. As an engine maintainer, I want the Box3D build to use the same clang and llvm-lib toolchain `fetch-cimgui` uses, so that the engine has one native toolchain requirement. 23. As an engine maintainer, I want the Plugin to leave `BOX3D_DISABLE_SIMD` and `BOX3D_DOUBLE_PRECISION` off and never toggle them per configuration, so that there is one determinism domain. 24. As an engine maintainer, I want two Engines given the same Plugin list, the same Scene, and the same sequence of fixed deltas to produce identical World Transforms, so that the determinism promise ADR-0016 makes is tested, not assumed. 25. As an engine maintainer, I want the physics Plugin to run with no Backend and no Platform listed, so that a multiplayer server simulates the same Scene as the client. 26. As an engine maintainer, I want `just check`, `just build` and `just test` to cover the new packages, so that the physics Plugin is held to the same bar as every other one. 27. As a sample user, I want the windowed sample to show a large flat plane with cubes dropping onto it, so that I can see the engine simulate. 28. As a sample user, I want one cube spawned per second, the first at start, so that the scene fills at a pace I can watch. 29. As a sample user, I want each cube to have a random colour, so that I can tell them apart as they pile up. 30. As a sample user, I want each cube spawned with a random orientation, so that it tumbles and settles on a face, edge or corner rather than landing flat. 31. As a sample user, I want each cube spawned within a small horizontal radius rather than at one point, so that the pile spreads instead of forming a tower. 32. As a sample user, I want cubes drawn with a depth buffer, so that a cube behind another is hidden rather than drawn over it. 33. As a sample user, I want a fixed camera that frames the plane and the drop column, so that the demo is legible without controls. 34. As a sample user, I want a `-seed:N` flag, so that a run can be reproduced. 35. As a sample user, I want `-frames:N` and `-dump-frame:N` to keep working, and the removed `-instances` and `-degeneracy` flags to be reported as unknown rather than crash the run. 36. As a sample user, I want cubes that leave the plane and fall far below it destroyed, so that a long run does not simulate bodies nobody can see. 37. As a test author, I want to build a Scene of GameObjects with `Rigidbody` and `Box_Collider`, tick the Engine Headless, and read World Transforms back, so that physics behaviour is tested at the same seam every other Plugin is. 38. As a test author, I want the sample's Draw Records to reflect the number of cubes spawned, so that the spawn cadence is tested through the Schedule the way the current sample tests are. 39. As a test author, I want a `Mesh_Renderer` to carry a colour, so that the sample's random colours reach the Draw Stream where a test can see them. ## Implementation Decisions ### Package layout - **Core declares the physics role.** The ABI Surface gains `Physics_Interface`, the `Rigidbody` and `Box_Collider` structs, and the enums they use, beside `Backend_Interface`, `Platform_Interface` and `Input_Interface`. The roles file reserves the Interface Id `cinnabar.physics/1` and the Schema names `cinnabar.rigidbody` and `cinnabar.box_collider`. Every field is a Boundary Type, so the closure ADR-0022 tests holds: fixed-size float arrays, `f32`, `u64`, explicitly sized enums. Core implements none of it and registers no Schema; it declares the shape, as ADR-0003 has it declare the Backend's. A consumer finds the Interface with `engine_acquire` and the Component Types with `engine_component_type` by reserved name, both of which report absence when no physics Plugin is listed, which is the Headless-style discipline ADR-0003 asks for. - **A `box3d` package** is the Plugin. It publishes `cinnabar.physics/1`, registers the two Schemas with Core in `register` (the ADR-0014 shape: register unconditionally, the Component simply does not exist when the Plugin is not listed), creates the Box3D world in `init`, steps it in `fixed_update`, and destroys it in `shutdown`. The Odin bindings are copied from Odin's `vendor:box3d` into this package and adjusted to import the library from `.tools/`, the same way the cimgui bindings do. Its `foreign import` path spells the pinned version. - **The Box3D C sources are vendored** into the repo beside the Plugin, pinned to one upstream commit recorded in the Plugin source, and built by a `just fetch-box3d` recipe into `.tools/box3d/<version>/lib/box3d.lib`. The recipe uses clang and llvm-lib, as `fetch-cimgui` does, compiling as C17 with optimisation and no SIMD or double-precision toggle. It verifies the output defines a known Box3D symbol. The library is not committed; `.tools/` is already ignored. - The justfile's `packages` list gains the `box3d` package, and the sample and `tests/core` link the library through the Plugin, as they already do for cimgui. ### Components - **`Rigidbody`**: body type (an enum: Static, Kinematic, Dynamic; Dynamic is the default set by `on_add`), initial linear velocity, gravity scale (default 1), a `u64` body id marked as a Transient Field, and a `u64` static-body id used only when the GameObject has colliders and no `Rigidbody` (see below). Exactly one per GameObject: `on_add` walks the owner's Components and sets Wants Destroy on itself if another `Rigidbody` is present, the same mechanism the lifecycle tests use. - **`Box_Collider`**: half extents (default 0.5 on each axis), friction, restitution, density (default 1), and a `u64` shape id marked as a Transient Field. Many per GameObject. - **Field defaults are set in `on_add`**, as `Mesh_Renderer` does, because Core zero-fills storage and a zero-extent box is never what anyone meant. - **Body user tag** is the `Rigidbody`'s Component Handle, stored in Box3D's `u64`-wide user data. A ray hit reports that Handle. This is the ADR-0016 constraint. - **Box is a shape kind the Plugin translates**: `Box_Collider` half extents become a Box3D box hull at shape-creation time. The Component never names a hull. ### Lifecycle - **`on_add` of `Rigidbody`** creates the body at the owner's World Transform's rotation and translation, with the Component's type, velocity and gravity scale. If the owner already has `Box_Collider`s attached to an implicit static body, their shapes are recreated on the new body and the implicit body destroyed. - **`on_add` of `Box_Collider`** attaches a box shape to the owner's `Rigidbody` body if one exists, otherwise to an implicit static body the Plugin creates for that GameObject at its World Transform. A GameObject's implicit static body is shared by every collider on it and destroyed when the last collider leaves. - **`on_remove`** of either destroys the shape or body it owns. A body's destruction takes its shapes with it in Box3D, so the Plugin tracks which handles are already dead rather than double-freeing. - **World Transform scale is not physics' concern.** Box3D knows rotation and translation. The write-back composes the body's rotation and translation with the scale the GameObject's World Transform already had, and collider half extents are taken as authored, not multiplied by hierarchy scale. ### The fixed step - **`fixed_update`** does three things in order: copy every kinematic `Rigidbody`'s World Transform into its body; step the world once by Frame Timing's fixed delta with a fixed substep count; walk the dynamic `Rigidbody`s through Core's dense slice and parallel owner array and write each body's transform back with `gameobject_set_world_transform`. Static bodies are set once at creation and never read again. - **Active in Hierarchy** is tested on the write-back and the kinematic copy. A `Rigidbody` whose owner is inactive has its body disabled in Box3D on the step it is first seen inactive, and re-enabled when it is seen active again, so an inactive object neither moves nor blocks others. - **No interpolation.** The Interpolation Alpha exists and Core supplies it, but this iteration draws bodies at their last fixed-step transform. Interpolation needs a previous-transform store per body and is deferred. - **Determinism** is the same call order at the same fixed delta on the same binary, as ADR-0016 promises and no more. The Plugin steps only in `fixed_update`, so a frame that runs zero or several fixed steps still produces the same simulation for the same accumulated time. ### The Physics Interface - `set_gravity` and `gravity`. - `ray_cast(origin, direction, max_distance) -> (hit: bool, rigidbody: Component_Handle, point, normal, distance)`, nearest hit only. - `fixed_delta()` reports what the world is stepped by. - `body_count()` for diagnostics and tests. - The struct reserves no slot for contacts or the character controller, but its shape follows the ten ADR-0016 constraints so that adding `drain_contacts` and `move` later is an addition, not a redesign. - All procs take `self` first and keep the Odin calling convention, per ADR-0008. - The Interface's argument and result types are Boundary Types declared in the ABI Surface; a ray hit crosses as a plain struct of a `Component_Handle`, two float triples and a distance, so a Script host can call it through a Shim later without a redesign. ### Rendering changes needed by the sample - **`Mesh_Renderer` gains a `colour: [4]f32` field**, default white, which the mesh pipeline copies into the Push-Constant Prelude payload. The shader multiplies vertex colour by the payload colour and by the Pass tint. The per-draw grid offset logic leaves the shader with the grid. - **The mesh pipeline gains a depth attachment**: a `D32_Float` Virtual Resource sized from the swapchain description, wired as a second attachment cleared to 1.0, with depth test and depth write enabled on the pipeline. Core already carries the format and the Vulkan Backend already picks a depth attachment by format. Headless substitutes configured swapchain defaults, so the declaration runs unchanged there. Sort direction stays Front_To_Back. ### The sample - **`sample_scene` becomes a Scene of GameObjects.** It declares `requires` on the mesh Interface and the physics Interface. In `init` it creates the shared cube vertex and index buffers, a plane GameObject with a `Box_Collider` (a wide, thin box) and a `Mesh_Renderer` (the cube mesh scaled by the GameObject's local transform), and the camera View. It has no `draw` hook and calls `engine_submit` never: the mesh pipeline draws the `Mesh_Renderer`s, which is ADR-0014 in practice. - **Spawning runs in `fixed_update`** on an accumulator of fixed deltas, so the cadence is one per second of simulated time and identical Headless and windowed. The first cube spawns on the first fixed step. Each spawn creates a GameObject at the drop height with a random offset inside the spawn radius, a uniformly random orientation, and attaches `Mesh_Renderer` (random colour), `Rigidbody` (Dynamic) and `Box_Collider` (half extents 0.5). - **Randomness** comes from a generator seeded from a `seed` parameter, so a test and a `-seed:N` run reproduce. Random orientation is a uniform random unit quaternion, not three random Euler angles. - **Cull-out**: a cube whose World Transform falls below a floor height well under the plane has its GameObject destroyed. - **`Params`** becomes: seed, spawn interval seconds (default 1), drop height, spawn radius, plane half extent. `grid_instances`, `grid_spacing`, `degeneracy` and `rotation_hz` go. The `app` options parser drops `-instances` and `-degeneracy` and gains `-seed`. - **Plugin List Order** in both sample executables: mesh pipeline, then `box3d`, then `sample_scene`, then controls, because `requires` demands the two Interfaces come first and Core verifies that at startup. - **The camera** is fixed: eye offset back and up from the drop column, looking at a point just above the plane, with the existing perspective and look-at helpers and the existing Y-negation convention. ### Documentation - The glossary gains entries for **Rigidbody**, **Collider**, **Physics Interface**, **Implicit Static Body**, and **Body User Tag**, using the terms above. The **Interface** entry's list of Core-declared roles ("Backend, Platform") grows to name Physics. - ADR-0003's decision is extended, not changed: a third role Core declares and does not implement. Record that in a short note on ADR-0003, as ADR-0017 already added one. - ADR-0016's file carries the heading "ADR-0018" and cites `docs/research/physics-engine.md`, which is not in the tree. Fix the heading; either commit the research note or reword the citation. ## Testing Decisions **A good test** runs an Engine Headless with a Plugin list, builds a Scene through the Core Scene API, ticks with `engine_tick_with_delta`, and asserts on what the Scene API and the compiled Schedule report: World Transforms, Component liveness, Draw Records. It does not read Plugin State, does not call Box3D, and does not depend on Backend or Platform. Numerical assertions use tolerances and ranges (a settled cube's height is between its half extent and its circumscribed radius), never exact floats, except for the determinism test, which compares two Engines bit for bit. **Prior art**: `tests/core/sample_test.odin` (Plugin list from `app.scene_plugins`, ticking, reading Draw Records off the Schedule), `tests/core/mesh_renderer_test.odin` (a harness Plugin beside the Plugin under test, GameObjects and Components created from the test, `flush` and `teardown` helpers), `tests/core/component_lifecycle_test.odin` (on_add/on_remove, Wants Destroy, fixed-step counting), `tests/core/scene_document_test.odin` (save and load round trips). Tests live in `tests/core` because everything they need is Headless; the suite already links a native library for the imgui tests, so a second one follows the same precedent. **What is tested** - Physics Plugin, through Components: - A GameObject with `Rigidbody` and `Box_Collider` above a static `Box_Collider` plane has a lower Y after one second of fixed steps, and after several seconds its Y stops changing and sits within the settled range. - A `Box_Collider` with no `Rigidbody` does not move across many fixed steps. - A kinematic `Rigidbody` moved with `gameobject_set_local_transform` is where it was put after the step, and a dynamic cube resting on it is carried. - Removing a `Rigidbody` mid-run, or destroying its GameObject, leaves `body_count` one lower after the flush; the remaining bodies keep simulating. - A second `Rigidbody` on one GameObject has Wants Destroy set and is gone at the flush; the first survives. - Adding a `Rigidbody` to a GameObject that already carries colliders makes the object fall (the shapes migrated off the implicit static body). - Deactivating a falling cube's GameObject freezes its World Transform; reactivating resumes the fall. - The write-back preserves the GameObject's scale. - `ray_cast` straight down from above the plane reports the plane's `Rigidbody`-less collider as no Component Handle and the point at the plane's top; with a cube in the way it reports the cube's `Rigidbody` Handle. - Two Engines with the same Plugin list and the same scripted Scene produce identical World Transforms after the same fixed-step sequence. - A Scene Document written after adding the Components contains the authored fields and no body or shape id; loading it into a fresh Engine and stepping produces a falling cube. - With the physics Plugin not listed, `engine_component_type` for the two Schema names reports absent and `engine_acquire` of the Physics Interface reports not found, the mirror of the existing "headless without the render plugin" test. - The ABI Surface test that reads `core/abi.odin` at compile time passes with the new types in place, which is what makes "declared in the ABI Surface" checked rather than asserted. - Mesh pipeline: a `Mesh_Renderer`'s colour arrives in the Draw Record payload; the mesh Pass has two attachments and the second is `D32_Float`. - Sample, through `app.scene_plugins`: at the first frame the Schedule holds one cube draw plus the plane; after N seconds of fixed steps it holds N+1 cubes plus the plane; two cubes' payload colours differ; two cubes' transforms differ in rotation; a cube's transform descends across frames; the same seed reproduces the same Draw Records and a different seed does not; every spawned cube sits inside the View's clip volume at spawn; the vertex layout test against the generated Pass Interface is kept. - Startup: listing `sample_scene` before `box3d` fails `engine_init` with the missing-Interface error, which the existing requires-order check already produces. ## Out of Scope - Contact events and the drain-after-step API. The Interface is shaped so they can be added; they are not added here. - The character controller. - Shapes other than box: sphere, capsule, mesh, compound as a type. - Joints and constraints. - Interpolation of body transforms by the Interpolation Alpha. - Collision layers and filtering. - Physics material assets; friction and restitution are per-collider fields. - Play Mode capture and restore of a running simulation; a restored Scene recreates bodies at rest. - Multithreaded stepping; the world is created with one worker. - Scripting access to physics from Luau. - Debug drawing of collider shapes. - Non-Windows builds of the Box3D library. The recipe follows `fetch-cimgui`, which is Windows-only today. ## Further Notes - Plugin List Order matters twice here and Core enforces only one: `requires` puts `mesh_pipeline` and `box3d` ahead of `sample_scene`. The order between `box3d`'s `fixed_update` and `sample_scene`'s is not enforced and does not matter to the behaviour specified, because a cube spawned in one fixed step is first simulated in the next either way. Say so in the sample's list comment, as the windowed sample already does for imgui. - Core allocates Component storage and reflects the Schema from the Odin struct (ADR-0018). The `u64` body and shape ids are the Transient Fields that ADR named as its motivating case; both must carry the tag or a saved Scene writes a dead handle. - `gameobject_set_world_transform` fails on a parent chain that does not invert. A `Rigidbody` under a zero-scaled ancestor will log that once per step; that is acceptable and not something the Plugin should paper over. - Box3D is alpha (v0.1.0). A bug we hit is an issue filed upstream or a patch carried in the vendored sources, which is why the sources are in this repo rather than pulled from Odin's moving snapshot. - Odin's `vendor:box3d` bindings on Windows export the `foreign import` and hard-code `lib/box3d.lib` beside the bindings. Copying them into the Plugin and pointing at `.tools/` is the change; the proc signatures stay as Odin wrote them. - The existing sample tests for the grid, hero rotation, degeneracy and instance count are deleted with the features they test. The winding and projection tests survive because the cube mesh and the projection helpers survive.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
crowmaki/cinnabar#67
No description provided.