Skip to content

Rapier physics: in-tick imperative ops (impulses, forces, raycasts, joints, teleport)Β #121

@martinjms

Description

@martinjms

Quick Summary

  • 🟑 Status: ready to start, not blocked. Builds on Rapier physics: per-entity body kind + material + collision filtering + sensor hooksΒ #120 (per-entity body kind / material / filtering / sensor β€” landed on feat/rapier-cluster via feat(rapier-cluster): per-entity body kind / material / filtering / sensor hooks (#120)Β #125) and #117 / #118. No external epic dependencies.
  • Add entity-keyed in-tick imperative operations to RapierClusterTickContext: apply impulse / force / torque, set translation (teleport), raycasts, intersection queries, joint creation between entities.
  • Closes the "developers can run any Rapier simulation in the cluster the same way they could locally" gap. Today the only way to influence physics in a tick is via entity.velocity mutation; locally, Rapier exposes a much richer per-tick surface.
  • All operations are entity-keyed (take entity_id, not RigidBodyHandle) β€” preserves Arcane's invariant that the entity map is the user's view into physics state.
  • ⚠️ Lock-window restructure scope: the change to hold the state lock during user on_tick only affects the Backend::Rapier(sim) path. The existing Backend::Cluster(sim) path keeps releasing the lock during user code (no PhysicsHandle is given to it). Document this so the implementer doesn't restructure both paths.

Why This Matters

Without these:

  • No jumps / knockback / explosions. All in-tick force application requires impulses on a body. entity.velocity mutation can fake some of this but can't compose with mass / momentum / constraints correctly.
  • No hitscan weapons. No raycast β†’ no shooting, no line-of-sight checks, no aim assist, no "is the ground under me" detection.
  • No teleport / respawn-in-place. No way to relocate an entity mid-tick without despawn + respawn.
  • No vehicles, ropes, ragdolls. No joints between entities.
  • No spatial queries. No "what entities are inside this radius?" β€” fundamental to AOE abilities.

These are the basic verbs of action games. Local Rapier users have all of them; cluster Rapier users currently don't.

Scope

  • In (entity-keyed methods on RapierClusterTickContext::physics: PhysicsHandle<'a>):
    • apply_impulse(entity_id, impulse: Vec3) -> bool
    • apply_force(entity_id, force: Vec3) -> bool
    • apply_torque_impulse(entity_id, torque: Vec3) -> bool
    • set_translation(entity_id, position: Vec3) -> bool β€” teleport
    • set_linvel(entity_id, linvel: Vec3) -> bool β€” override velocity directly (bypasses the per-tick entity.velocity declarative path; see "set_linvel sync interaction" below)
    • set_angvel(entity_id, angvel: Vec3) -> bool
    • linvel(entity_id) -> Option<Vec3>, angvel(entity_id) -> Option<Vec3> β€” read-only
    • wake(entity_id) -> bool / sleep(entity_id) -> bool β€” sleep state control
    • raycast(origin: Vec3, direction: Vec3, max_dist: f32) -> Option<RaycastHit> where RaycastHit { entity_id, time_of_impact, point, normal }
    • intersections_with_shape(shape: &RapierColliderShape, position: Vec3) -> Vec<Uuid> β€” entity ids overlapping a query shape
    • create_joint(a: Uuid, b: Uuid, joint: JointSpec) -> Option<JointId> β€” fixed / revolute / spherical / prismatic between two entities in this cluster
    • remove_joint(joint: JointId) -> bool
  • Out (separate issues, deliberately):
    • Off-spine bodies (bodies without an entity_id) β€” would violate the unified-entity model.
    • Cross-cluster joints β€” depends on cross-cluster physics work.
    • Multibody joints (articulated chains) β€” defer until needed.
    • Custom solver tweaks (substep count, contact-force-event thresholds) β€” defer.
    • Compound colliders (multiple shapes per body) β€” separate concern; needs design.
    • apply_impulse_at_point / off-center forces β€” additive; defer until needed.

Public type sketches

#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RaycastHit {
    pub entity_id: Uuid,
    pub time_of_impact: f32,
    pub point: Vec3,
    pub normal: Vec3,
}

#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum JointSpec {
    Fixed { local_anchor_a: Vec3, local_anchor_b: Vec3 },
    Revolute { local_anchor_a: Vec3, local_anchor_b: Vec3, axis: Vec3 },
    Spherical { local_anchor_a: Vec3, local_anchor_b: Vec3 },
    Prismatic { local_anchor_a: Vec3, local_anchor_b: Vec3, axis: Vec3 },
}

/// Opaque handle returned by `create_joint`; pass back to `remove_joint`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct JointId(/* private */);

JointSpec and RaycastHit need pub const fn new(...) constructors per the same #[non_exhaustive] rule we hit in #120 (struct-literal construction outside the defining crate is blocked).

Imperative-op semantics β€” call-out

  • Fixed-body imperative ops (apply_impulse / apply_force / apply_torque_impulse / set_linvel / set_angvel): silently no-op and return false. Gameplay code shouldn't have to query body kind before applying force; the no-op is the contract. (Rapier itself may panic or silently no-op depending on the call; the wrapper normalizes.)
  • set_translation for Dynamic bodies: teleports immediately. Can violate contact constraints (body lands inside another body); Rapier resolves new contacts at the next step. Document this as expected behavior.
  • Operations on missing entity_id: return false / None without panicking. Already in spec βœ….

set_linvel ↔ entity.velocity sync interaction

After user on_tick, the existing spawn loop runs state.set_linvel(*id, entry.velocity) for every existing body to honor the declarative "user mutates entity.velocity β†’ Rapier integrates it" contract. If the user calls ctx.physics.set_linvel(id, v) imperatively, that value gets overwritten before the physics step.

Fix: PhysicsHandle tracks a HashSet<Uuid> of entities whose linvel (and translation) were set imperatively this tick. The spawn loop's per-tick sync skips those entities. After the step + sync_outputs, the sets clear for the next tick.

Same pattern applies to set_translation β€” without skipping the per-tick sync, the imperative teleport would be a no-op as the spawn loop overwrites Rapier's input.

Joint cleanup on entity despawn

RapierState::despawn already calls bodies.remove(handle, ..., &mut self.impulse_joints, ...) which auto-removes joints attached to the body. Verify this in a test ("Despawning a joint-connected entity cleanly removes the joint" β€” already in spec βœ…). Document the invariant in the joint API docs so users don't try to manually remove_joint after despawn.

Action Items

  • PhysicsHandle<'a> struct on the tick context, internally lending mutable access to Rapier sets via the held lock.
  • Per-entity ops listed above (impulse / force / torque / set_*).
  • Read-only ops (linvel, angvel).
  • RaycastHit public type (#[non_exhaustive]) with pub const fn new(...) constructor.
  • JointSpec public enum and JointId newtype (both #[non_exhaustive] where structurally appropriate).
  • QueryPipeline integrated into RapierState, updated post-step so queries hit post-step state.
  • Lock-window restructure for the Backend::Rapier path only β€” Backend::Cluster keeps releasing the lock during on_tick (no PhysicsHandle is given to it).
  • Imperative-touched tracking in PhysicsHandle β€” HashSet<Uuid> per (set_linvel / set_translation); spawn-loop sync skips touched ids; sets clear at start of next tick.
  • Fixed-body normalization β€” imperative ops on Fixed bodies silently no-op and return false.
  • Tests:
    • apply_impulse produces expected linvel change (impulse / mass).
    • apply_force over multiple ticks produces expected acceleration.
    • set_translation teleports, value propagates to entity.position via sync_outputs.
    • set_linvel imperative override is NOT clobbered by the per-tick entity.velocity sync.
    • raycast against entity colliders returns entity_id; misses return None.
    • intersections_with_shape returns the entities overlapping the query shape.
    • Joint between two entities holds them at fixed distance; both entities in same cluster.
    • Despawning a joint-connected entity cleanly removes the joint (no leak; later despawn-and-respawn doesn't surface the joint).
    • Operations on missing entity_id return false / None without panicking.
    • Imperative ops on a Fixed body return false and don't panic; the body's state is unchanged.
  • Module docs updated with examples for each new op (mirror the #120 style β€” one block in # Example showing several ops in use).
  • Re-export new public types from arcane_infra crate root.
  • Clippy clean both feature configurations.

Reference

  • Parent EPIC: #8 β€” Cluster physics backends
  • Builds on: #117 (minimum integration), #118 (contact events + colliders), #120 (per-entity body kinds/materials/filters/sensors β€” landed on feat/rapier-cluster via #125)
  • Architecture doc: docs/architecture/entity-model.md
  • Downstream: brainy-bots/arcane-demos#6 (clustering visualization demo) β€” explosions need radius query + apply_impulse; gun needs raycast or projectile-spawn; respawn needs set_translation
  • Unblocks: any non-trivial action gameplay (combat, traversal, AI, world interactions)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions