ADR-0012: Phase 6 subsystems: physics, animation, audio and input
Status: accepted, 2026-11-09.
Context #
Phase 6 adds the gameplay subsystems over the pinned dependencies: Jolt (joltc) for physics, miniaudio for audio, SDL3 events for input, and the engine's own animation evaluation (the brief asks for SIMD Mojo on the job system). Every decision below was taken against the rules of ADR-0003 (Mojo never runs inside a C callback) and section 10 (reuse a pinned dependency before writing a subsystem).
Decisions #
Physics (cinder/physics) #
- Jolt keeps its own thread pool.
JPH_PhysicsSystem_Updateruns on aJPH_JobSystemThreadPoolcreated per world (2 workers by default) instead of the engine's job system. joltc's job system cannot be driven by an external scheduler without callbacks into Mojo, which ADR-0003 forbids; Jolt's pool is idle outside the step so the two systems do not contend. Revisit if profiling (Phase 8) shows the step starving render jobs. - Object layers are bits. Up to 32 layers; query masks are 32-bit; two broad-phase layers
(static, moving) keep static geometry from being tested against itself. Collision pairs are
declared once in
LayerConfig. - All callbacks are C. Query filters (
cn_physics_*_filter_*), the closest-hit collectors for rays and shape casts (cn_physics_cast_ray,cn_physics_cast_shape) and the contact and activation listeners live innative/src/physics_shim.c/physics_events.c. Results are returned through plain structs or the event queue and read on the calling thread. - Character controller = CharacterVirtual. Position is at the feet (the capsule is offset
through
shapeOffset), only the lower hemisphere supports the character, ground velocity is inherited (moving platforms),ExtendedUpdatewalks stairs up tostep_heightand sticks to the floor. Pushing dynamic bodies uses Jolt'smaxStrength. - ECS sync keeps two poses.
RigidBodystores the previous and current simulated pose;write_transforms(alpha)blends them for rendering between fixed steps. Kinematic bodies follow their entity'sLocalTransformthroughMoveKinematic(so they carry riders); dynamic bodies drive the entity; static bodies are left alone. - Leak accounting without aborts. The world counts shapes, bodies and characters;
live_objects()must be zero before shutdown (tests assert it). The destructor does not abort, so a failing test reports its real assertion rather than the leak that follows it. - Determinism is Jolt's. Jolt's deterministic mode is on by default; the test replays a recorded input stream (impulses at given steps) into two fresh worlds and asserts bit-identical positions for 72 bodies, and that a different stream diverges.
Animation (cinder/anim) #
- Skeleton = joints plus ancestors.
skeleton_from_gltfincludes every skin joint and the ancestors needed to reach a common root, in parent-before-child order, so animated parents move their children. The palette isinverse(mesh node) * bone world * inverse bind, the same convention the importer uses at the bind pose (tested against it), so the render instance keeps the mesh node's transform. - Clips stay glTF-shaped.
Clip/ClipChannel(node, path, interpolation, components, times, values) are the cooked.cclipformat; sampling implements step, linear (slerp for rotations) and cubic spline exactly as the glTF specification describes. A cooked.cskel(CSKL v1) carries the skeleton; the cooker emits one per skin. - Blend trees run on a normalised phase. Leaves sample at
phase * duration, so blended cycles stay in step; the phase advances at the weighted average of the leaves' cycle rates. 1D nodes interpolate between the two nearest thresholds; 2D nodes use gradient-band interpolation (smooth, sums to one, exact at each child's point). - Crossfades blend whole poses.
play(state, fade)keeps the outgoing state running and blends pose to pose with a linear weight; translations and scales lerp, rotations nlerp along the shorter arc. - Root motion and events are animator features. Root motion removes the root bone's horizontal translation from the pose and accumulates it (loop wraps are unwrapped exactly, including several cycles in one step). Events are authored per state at a phase and fire once per crossing. glTF has neither, so they are not part of the cooked format.
- One job per animator.
AnimationSystem.updatesubmits each playing animator as a job; jobs touch only their own animator and the results are bit-identical to serial evaluation (tested). The system publishesJointPalettecomponents thatbuild_render_sceneturns into skinning matrices and morph weights.
Audio (cinder/audio) #
- miniaudio's engine, node graph and resource manager are used as they are. Buses are
ma_sound_groups (allocated in the shim because the struct is opaque to Mojo); streamed sounds useMA_SOUND_FLAG_STREAM, everything else is decoded up front. - Headless engines for tests.
noDeviceengines are pulled withma_engine_read_pcm_frames, which makes spatialisation, bus volumes and sound-end callbacks testable deterministically. - Sound-end and log callbacks post events.
update()drains them; one-shots release themselves on their end event. (The shim posted the sound id in the wrong event field before this phase; the audio test now covers the path.) - The listener follows an entity.
AudioListeneron the camera entity drives position and orientation from itsWorldTransform;AudioSourceentities position their sounds.
Input (cinder/input) #
- Actions and axes over device codes. Bindings are (device, code, scale, dead zone);
digital sources contribute
scalewhile held, analog ones their value after a rescaled dead zone. Mouse deltas accumulate within a frame. - Rebinding by listening. The next matching event replaces an action's bindings or adds an axis binding; the capturing press does not fire the action.
- Text file, exact floats. The map saves as
cinder-input 1lines using the scene format's exact float formatter, so a save/load round trip is byte-identical.
Consequences #
- Phase 7 tools read
PhysicsWorld.live_objects(),AudioEngine.live_sounds()and the renderer's resource counts for the shutdown leak report. - The sample game (Phase 8) builds its character on
CharacterController+Animatorroot motion disabled (controller-driven) and a locomotion 1D blend tree. JPH_Initis process-wide and never shut down while a world exists; worlds call it (it is idempotent in joltc).