Box3D physics Plugin, Physics Interface, Rigidbody and Box_Collider Components, and a falling-cubes sample #67
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
crowmaki/cinnabar#67
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
RigidbodyandBox_ColliderComponent types in the ABI Surface, exactly as it already does for the Backend, Platform and Input roles; abox3dPlugin publishes the Interface, registers the Component Schemas with Core, and steps a Box3D world infixed_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 aRigidbodyand aBox_Colliderto 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_Rendererin the sample. Every piece runs Headless, which is how the tests run it.User Stories
RigidbodyComponent to a GameObject, so that the GameObject is simulated as a body.Box_ColliderComponent to a GameObject, so that the body has a shape other bodies collide with.Box_Collideron a GameObject with noRigidbodyto act as a static collider, so that a floor or wall needs one Component, not two.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.Mesh_Rendererand every other Component see where physics put it.Rigidbodyto be dynamic, kinematic, or static, so that I can choose whether physics moves it, I move it, or nothing moves it.Rigidbody's initial linear velocity and gravity scale, so that spawned objects can be thrown or float.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.Box_Colliders on one GameObject, so that a compound shape is several Components rather than a special type.Rigidbodyper GameObject, and a second one rejected on add, so that a body has one owner.RigidbodyorBox_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.Rigidbodywhose GameObject is not Active in Hierarchy to be left out of the simulation, so that disabling an object disables its physics.RigidbodyandBox_Colliderto 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.u64body user tag, box as a shape kind the Plugin translates), so that a second physics Plugin fits behind it unchanged.justrecipe into.tools/, so that a compiler upgrade cannot move the physics library under the game and a fresh clone needs no CMake.fetch-cimguiuses, so that the engine has one native toolchain requirement.BOX3D_DISABLE_SIMDandBOX3D_DOUBLE_PRECISIONoff and never toggle them per configuration, so that there is one determinism domain.just check,just buildandjust testto cover the new packages, so that the physics Plugin is held to the same bar as every other one.-seed:Nflag, so that a run can be reproduced.-frames:Nand-dump-frame:Nto keep working, and the removed-instancesand-degeneracyflags to be reported as unknown rather than crash the run.RigidbodyandBox_Collider, tick the Engine Headless, and read World Transforms back, so that physics behaviour is tested at the same seam every other Plugin is.Mesh_Rendererto carry a colour, so that the sample's random colours reach the Draw Stream where a test can see them.Implementation Decisions
Package layout
Physics_Interface, theRigidbodyandBox_Colliderstructs, and the enums they use, besideBackend_Interface,Platform_InterfaceandInput_Interface. The roles file reserves the Interface Idcinnabar.physics/1and the Schema namescinnabar.rigidbodyandcinnabar.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 withengine_acquireand the Component Types withengine_component_typeby reserved name, both of which report absence when no physics Plugin is listed, which is the Headless-style discipline ADR-0003 asks for.box3dpackage is the Plugin. It publishescinnabar.physics/1, registers the two Schemas with Core inregister(the ADR-0014 shape: register unconditionally, the Component simply does not exist when the Plugin is not listed), creates the Box3D world ininit, steps it infixed_update, and destroys it inshutdown. The Odin bindings are copied from Odin'svendor:box3dinto this package and adjusted to import the library from.tools/, the same way the cimgui bindings do. Itsforeign importpath spells the pinned version.just fetch-box3drecipe into.tools/box3d/<version>/lib/box3d.lib. The recipe uses clang and llvm-lib, asfetch-cimguidoes, 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.packageslist gains thebox3dpackage, and the sample andtests/corelink 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 byon_add), initial linear velocity, gravity scale (default 1), au64body id marked as a Transient Field, and au64static-body id used only when the GameObject has colliders and noRigidbody(see below). Exactly one per GameObject:on_addwalks the owner's Components and sets Wants Destroy on itself if anotherRigidbodyis present, the same mechanism the lifecycle tests use.Box_Collider: half extents (default 0.5 on each axis), friction, restitution, density (default 1), and au64shape id marked as a Transient Field. Many per GameObject.on_add, asMesh_Rendererdoes, because Core zero-fills storage and a zero-extent box is never what anyone meant.Rigidbody's Component Handle, stored in Box3D'su64-wide user data. A ray hit reports that Handle. This is the ADR-0016 constraint.Box_Colliderhalf extents become a Box3D box hull at shape-creation time. The Component never names a hull.Lifecycle
on_addofRigidbodycreates 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 hasBox_Colliders attached to an implicit static body, their shapes are recreated on the new body and the implicit body destroyed.on_addofBox_Colliderattaches a box shape to the owner'sRigidbodybody 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_removeof 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.The fixed step
fixed_updatedoes three things in order: copy every kinematicRigidbody's World Transform into its body; step the world once by Frame Timing's fixed delta with a fixed substep count; walk the dynamicRigidbodys through Core's dense slice and parallel owner array and write each body's transform back withgameobject_set_world_transform. Static bodies are set once at creation and never read again.Rigidbodywhose 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.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_gravityandgravity.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.drain_contactsandmovelater is an addition, not a redesign.selffirst and keep the Odin calling convention, per ADR-0008.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_Renderergains acolour: [4]f32field, 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.D32_FloatVirtual 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_scenebecomes a Scene of GameObjects. It declaresrequireson the mesh Interface and the physics Interface. Ininitit creates the shared cube vertex and index buffers, a plane GameObject with aBox_Collider(a wide, thin box) and aMesh_Renderer(the cube mesh scaled by the GameObject's local transform), and the camera View. It has nodrawhook and callsengine_submitnever: the mesh pipeline draws theMesh_Renderers, which is ADR-0014 in practice.fixed_updateon 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 attachesMesh_Renderer(random colour),Rigidbody(Dynamic) andBox_Collider(half extents 0.5).seedparameter, so a test and a-seed:Nrun reproduce. Random orientation is a uniform random unit quaternion, not three random Euler angles.Paramsbecomes: seed, spawn interval seconds (default 1), drop height, spawn radius, plane half extent.grid_instances,grid_spacing,degeneracyandrotation_hzgo. Theappoptions parser drops-instancesand-degeneracyand gains-seed.box3d, thensample_scene, then controls, becauserequiresdemands the two Interfaces come first and Core verifies that at startup.Documentation
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 fromapp.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,flushandteardownhelpers),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 intests/corebecause 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
RigidbodyandBox_Colliderabove a staticBox_Colliderplane has a lower Y after one second of fixed steps, and after several seconds its Y stops changing and sits within the settled range.Box_Colliderwith noRigidbodydoes not move across many fixed steps.Rigidbodymoved withgameobject_set_local_transformis where it was put after the step, and a dynamic cube resting on it is carried.Rigidbodymid-run, or destroying its GameObject, leavesbody_countone lower after the flush; the remaining bodies keep simulating.Rigidbodyon one GameObject has Wants Destroy set and is gone at the flush; the first survives.Rigidbodyto a GameObject that already carries colliders makes the object fall (the shapes migrated off the implicit static body).ray_caststraight down from above the plane reports the plane'sRigidbody-less collider as no Component Handle and the point at the plane's top; with a cube in the way it reports the cube'sRigidbodyHandle.engine_component_typefor the two Schema names reports absent andengine_acquireof the Physics Interface reports not found, the mirror of the existing "headless without the render plugin" test.core/abi.odinat compile time passes with the new types in place, which is what makes "declared in the ABI Surface" checked rather than asserted.Mesh_Renderer's colour arrives in the Draw Record payload; the mesh Pass has two attachments and the second isD32_Float.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.sample_scenebeforebox3dfailsengine_initwith the missing-Interface error, which the existing requires-order check already produces.Out of Scope
fetch-cimgui, which is Windows-only today.Further Notes
requiresputsmesh_pipelineandbox3dahead ofsample_scene. The order betweenbox3d'sfixed_updateandsample_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.u64body 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_transformfails on a parent chain that does not invert. ARigidbodyunder a zero-scaled ancestor will log that once per step; that is acceptable and not something the Plugin should paper over.vendor:box3dbindings on Windows export theforeign importand hard-codelib/box3d.libbeside the bindings. Copying them into the Plugin and pointing at.tools/is the change; the proc signatures stay as Odin wrote them.