DocsDecisions

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) #

  1. Jolt keeps its own thread pool. JPH_PhysicsSystem_Update runs on a JPH_JobSystemThreadPool created 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.
  2. 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.
  3. 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 in native/src/physics_shim.c / physics_events.c. Results are returned through plain structs or the event queue and read on the calling thread.
  4. 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), ExtendedUpdate walks stairs up to step_height and sticks to the floor. Pushing dynamic bodies uses Jolt's maxStrength.
  5. ECS sync keeps two poses. RigidBody stores the previous and current simulated pose; write_transforms(alpha) blends them for rendering between fixed steps. Kinematic bodies follow their entity's LocalTransform through MoveKinematic (so they carry riders); dynamic bodies drive the entity; static bodies are left alone.
  6. 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.
  7. 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) #

  1. Skeleton = joints plus ancestors. skeleton_from_gltf includes 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 is inverse(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.
  2. Clips stay glTF-shaped. Clip/ClipChannel (node, path, interpolation, components, times, values) are the cooked .cclip format; 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.
  3. 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).
  4. 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.
  5. 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.
  6. One job per animator. AnimationSystem.update submits each playing animator as a job; jobs touch only their own animator and the results are bit-identical to serial evaluation (tested). The system publishes JointPalette components that build_render_scene turns into skinning matrices and morph weights.

Audio (cinder/audio) #

  1. 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 use MA_SOUND_FLAG_STREAM, everything else is decoded up front.
  2. Headless engines for tests. noDevice engines are pulled with ma_engine_read_pcm_frames, which makes spatialisation, bus volumes and sound-end callbacks testable deterministically.
  3. 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.)
  4. The listener follows an entity. AudioListener on the camera entity drives position and orientation from its WorldTransform; AudioSource entities position their sounds.

Input (cinder/input) #

  1. Actions and axes over device codes. Bindings are (device, code, scale, dead zone); digital sources contribute scale while held, analog ones their value after a rescaled dead zone. Mouse deltas accumulate within a frame.
  2. 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.
  3. Text file, exact floats. The map saves as cinder-input 1 lines 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 + Animator root motion disabled (controller-driven) and a locomotion 1D blend tree.
  • JPH_Init is process-wide and never shut down while a world exists; worlds call it (it is idempotent in joltc).
View as MarkdownSource: docs/adr/0012-physics-animation-audio-input.md