You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>):
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)]pubstructRaycastHit{pubentity_id:Uuid,pubtime_of_impact:f32,pubpoint:Vec3,pubnormal:Vec3,}#[non_exhaustive]#[derive(Clone,Copy,Debug,PartialEq)]pubenumJointSpec{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)]pubstructJointId(/* 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.
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.
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)
Quick Summary
feat/rapier-clustervia feat(rapier-cluster): per-entity body kind / material / filtering / sensor hooks (#120)Β #125) and#117/#118. No external epic dependencies.RapierClusterTickContext: apply impulse / force / torque, set translation (teleport), raycasts, intersection queries, joint creation between entities.entity.velocitymutation; locally, Rapier exposes a much richer per-tick surface.entity_id, notRigidBodyHandle) β preserves Arcane's invariant that the entity map is the user's view into physics state.on_tickonly affects theBackend::Rapier(sim)path. The existingBackend::Cluster(sim)path keeps releasing the lock during user code (noPhysicsHandleis given to it). Document this so the implementer doesn't restructure both paths.Why This Matters
Without these:
entity.velocitymutation can fake some of this but can't compose with mass / momentum / constraints correctly.These are the basic verbs of action games. Local Rapier users have all of them; cluster Rapier users currently don't.
Scope
RapierClusterTickContext::physics: PhysicsHandle<'a>):apply_impulse(entity_id, impulse: Vec3) -> boolapply_force(entity_id, force: Vec3) -> boolapply_torque_impulse(entity_id, torque: Vec3) -> boolset_translation(entity_id, position: Vec3) -> boolβ teleportset_linvel(entity_id, linvel: Vec3) -> boolβ override velocity directly (bypasses the per-tickentity.velocitydeclarative path; see "set_linvel sync interaction" below)set_angvel(entity_id, angvel: Vec3) -> boollinvel(entity_id) -> Option<Vec3>,angvel(entity_id) -> Option<Vec3>β read-onlywake(entity_id) -> bool/sleep(entity_id) -> boolβ sleep state controlraycast(origin: Vec3, direction: Vec3, max_dist: f32) -> Option<RaycastHit>whereRaycastHit { entity_id, time_of_impact, point, normal }intersections_with_shape(shape: &RapierColliderShape, position: Vec3) -> Vec<Uuid>β entity ids overlapping a query shapecreate_joint(a: Uuid, b: Uuid, joint: JointSpec) -> Option<JointId>β fixed / revolute / spherical / prismatic between two entities in this clusterremove_joint(joint: JointId) -> boolapply_impulse_at_point/ off-center forces β additive; defer until needed.Public type sketches
JointSpecandRaycastHitneedpub 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
apply_impulse/apply_force/apply_torque_impulse/set_linvel/set_angvel): silently no-op and returnfalse. 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_translationfor 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.entity_id: returnfalse/Nonewithout panicking. Already in spec β .set_linvelβ entity.velocity sync interactionAfter user
on_tick, the existing spawn loop runsstate.set_linvel(*id, entry.velocity)for every existing body to honor the declarative "user mutates entity.velocity β Rapier integrates it" contract. If the user callsctx.physics.set_linvel(id, v)imperatively, that value gets overwritten before the physics step.Fix:
PhysicsHandletracks aHashSet<Uuid>of entities whoselinvel(andtranslation) 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::despawnalready callsbodies.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 manuallyremove_jointafter despawn.Action Items
PhysicsHandle<'a>struct on the tick context, internally lending mutable access to Rapier sets via the held lock.linvel,angvel).RaycastHitpublic type (#[non_exhaustive]) withpub const fn new(...)constructor.JointSpecpublic enum andJointIdnewtype (both#[non_exhaustive]where structurally appropriate).QueryPipelineintegrated intoRapierState, updated post-step so queries hit post-step state.Backend::Rapierpath only βBackend::Clusterkeeps releasing the lock duringon_tick(noPhysicsHandleis given to it).PhysicsHandleβHashSet<Uuid>per (set_linvel/set_translation); spawn-loop sync skips touched ids; sets clear at start of next tick.false.apply_impulseproduces expected linvel change (impulse / mass).apply_forceover multiple ticks produces expected acceleration.set_translationteleports, value propagates toentity.positionviasync_outputs.set_linvelimperative override is NOT clobbered by the per-tickentity.velocitysync.raycastagainst entity colliders returnsentity_id; misses return None.intersections_with_shapereturns the entities overlapping the query shape.entity_idreturnfalse/Nonewithout panicking.falseand don't panic; the body's state is unchanged.#120style β one block in# Exampleshowing several ops in use).arcane_infracrate root.Reference
#8β Cluster physics backends#117(minimum integration),#118(contact events + colliders),#120(per-entity body kinds/materials/filters/sensors β landed onfeat/rapier-clustervia#125)docs/architecture/entity-model.mdbrainy-bots/arcane-demos#6(clustering visualization demo) β explosions need radius query + apply_impulse; gun needs raycast or projectile-spawn; respawn needs set_translation