Skip to content

grabbable-3d.entity overview


Table of contents


utils

Grabbable3dEntity (class)

A dynamic-body prop that can be picked up and carried, HL2/Portal-+use-key style - pair with ObjectGrabController for the input side (raycasting to find one in reach, binding grab/throw/ drop to keys/mouse buttons), or drive it manually by calling grab()/updateHold()/release()/ throw() yourself.

Carrying is implemented as a velocity spring, not a position teleport: updateHold() sets objectBody.linearVelocity towards the target hold point every tick rather than overwriting position directly, so the physics engine's own collision resolution still applies while carried (the object gets stopped/deflected by geometry in the way, instead of tunnelling through it and then exploding back out from deep penetration the instant it's released or bumps something - the classic failure mode of teleport-style carrying). This does mean a carried object can visibly lag behind the target hold point when pressed against an obstruction, and can still be knocked around by other dynamic bodies while held (both deliberate - see ObjectGrabController's doc for why that's the desired feel, matching Source's own physgun rather than a rigid attachment).

There is deliberately no core-level "disable gravity for this body" primitive backing this class (no physics adapter exposes one) - updateHold() instead compensates for gravity algebraically, by subtracting one frame's worth of the world's gravity vector from the velocity it sets. This only approximately cancels out: the physics engine's own simulate() call may run several native substeps internally for one updateHold() call (this entity only gets to set velocity once per frame, before simulate() runs), each re-integrating gravity on top of whatever velocity was set, so a held object can sag very slightly between updates at high substep counts - imperceptible in practice, and no worse an approximation than CharacterController3dEntity's own per-tick gravity integration.

objectBody should be created with ccd: true. The hold spring can drive a body at up to maxFollowSpeed (20 m/s by default) directly at whatever the camera is aimed through, including thin static geometry - exactly the fast-moving-dynamic-body scenario BodyOptions.ccd exists for (see its own doc), and without it a hard enough push can cross a wall's thickness within a single physics step. A high restitution on objectBody is also worth avoiding for the same underlying reason from the other direction: pinned against geometry it can't get through, the spring re-asserts full into-the-obstacle velocity every tick regardless of what the solver did the tick before, so a bouncy restitution fights that every contact tick and reads as a visible jitter (how pronounced this looks is adapter-dependent - some engines' discrete collision detection resolves the resulting shallow penetration into a visible bounce loop every tick, where ccd alone doesn't help since the body was never moving fast enough per step to actually tunnel) rather than the object settling flush against the surface.

Signature

export declare class Grabbable3dEntity<TypeDoc> {
  constructor(
    options: {
      object3D?: TypeDoc['vTypeDoc']['displayObject'] | null
      objectBody?: TypeDoc['pTypeDoc']['rigidBody'] | null
    },
    grabOptions: Partial<Grabbable3dEntityOptions> = {}
  )
}

grab (method)

Starts carrying this object: temporarily removes ignoreCollisionGroups from objectBody.interactWithCollisionGroups (restored by release()/throw()), and zeroes the object's current velocity so updateHold()'s spring starts clean instead of fighting whatever motion it had the instant before being grabbed. A no-op if already held.

ignoreCollisionGroups is a generic, low-level knob - whatever groups it names are simply removed from this object's own mask for as long as it's held, for any reason an app might want that. It is not how to stop this object from colliding with whoever is holding it: excluding a specific holder this way only works if the object and the holder don't otherwise share a group both need for ordinary world collision, which in practice they almost always do (both usually need to keep colliding with the level's static geometry) - see ICharacterController3dComponent.ignoredBodies's doc for the actual mechanism that handles that case (already wired up automatically by ObjectGrabController when constructed with a holder). Passing every group the intended holder's own ownCollisionGroups happens to report (e.g. a character controller left at its default ownCollisionGroups: 'all', which reports every registered group, not just "this character's own") filters all of them out of interactWithCollisionGroups, leaving this object colliding with nothing at all - not just the intended target - for as long as it's held.

Signature

grab(ignoreCollisionGroups: ReadonlyArray<CollisionGroup> = []): void

release (method)

Stops carrying this object, restoring the collision groups grab() removed. Leaves whatever velocity updateHold() last set on it (a gentle "let go in place") - use throw() instead to impart a deliberate outward velocity on release. A no-op if not currently held.

Signature

release(): void

throw (method)

Stops carrying this object (same bookkeeping as release()) and immediately imparts velocity to it - used for a forward "punt" instead of a gentle drop.

Signature

throw(velocity: Point3): void

updateHold (method)

Drives the held object towards targetPosition this tick via a velocity spring (see this class's own doc for why a velocity, not a position teleport) and damps its angular velocity per grabOptions.angularDamping. Force-release()s instead if targetPosition is currently farther than grabOptions.maxHoldDistance from the object - see that option's doc.

Never slows the object down below whatever speed it already has towards targetPosition - only ever raises its speed in that direction up to the spring's own value, never lowers it (the spring's perpendicular component is still applied as normal, correcting sideways drift). Mirrors AmmoCharacterControllerComponent.pushDynamicBody's own "only ever adds, never removes" rule: a body already moving towards the hold point faster than the spring would carry it - e.g. bumped or shoved there by something else entirely (another dynamic body, not the holder - the holder's own movement never contests a held object's position in the first place, see ObjectGrabController's ignoredBodies doc) - is left alone instead of having that speed immediately overwritten with the spring's own, smaller one purely because the object happens to already be close to targetPosition. It keeps that speed (redirected exactly at targetPosition, not left along whatever direction it was originally pushed in) until it either arrives or drifts past the target, at which point ordinary spring behavior resumes from the other side.

Once UNBLOCK_STREAK consecutive ticks pass without the previous tick's commanded velocity actually being achieved, this tick's commanded velocity is rate-limited to grabOptions.maxAcceleration - see that constant's and that option's own doc for why this is conditional (an unconditional cap makes ordinary fast turns feel sluggish), why it's a streak and not a single-tick check (a lone noisy "achieved" tick while still genuinely pinned against a wall re-arms a full-power push right when the object is already close to it - worse than either an always-on or a naive single-tick-gated cap), and why it exists at all (a real, reproduced tunneling/jitter bug otherwise: a blocked object gets its full-speed into-the-obstacle command re-issued outright every single tick regardless of what the solver did the tick before). Applied last, after both the maxFollowSpeed clamp and the "never slows down" rule above.

Must be called once per tick, before IPhysicsWorld3dComponent.simulate() runs that same tick - i.e. from a driver with tickOrder < TickOrder.PHYSICS_SIMULATION (e.g. ObjectGrabController, or your own equivalent) - for the velocity set here to actually be integrated this frame. Deliberately not called from this entity's own tick$: Entity3d's inherited tickOrder (OBJECTS_BINDING) runs after physics simulation, to sync the mesh from the just-simulated body - the opposite direction from what the hold spring needs - so this class does not add a second, earlier tick$ subscription of its own. A no-op if not currently held, or if this entity hasn't been spawned into a world with a physics world yet.

Signature

updateHold(targetPosition: Point3, dt: number): void

onRemoved (method)

Signature

onRemoved(): void

grabOptions (property)

Signature

readonly grabOptions: Grabbable3dEntityOptions

Grabbable3dEntityOptions (type alias)

Tuning for a Grabbable3dEntity's hold behavior - how it's driven towards the carrier's hold point while held, and when it gives up rather than fighting geometry it got squeezed into.

Signature

export type Grabbable3dEntityOptions = {
  /**
   * How strongly (per second) the held object's velocity is driven towards the target hold
   * point - effectively a spring constant. Higher snaps to the hold point faster/stiffer, lower
   * feels heavier/springier. Default 12.
   */
  followStrength: number
  /** Hard cap on the linear speed used to chase the hold point, in m/s. Default 20. */
  maxFollowSpeed: number
  /**
   * Cap on how fast the held object's *commanded* velocity is allowed to change per second, in
   * m/s² - but **only while `updateHold()` detects the previous tick's commanded velocity wasn't
   * actually achieved** (see this class's own doc for why: pushed hard against a wall, the object's
   * actual velocity gets stopped/bounced back by the solver each tick, but with no cap the very next
   * tick immediately re-commands the *same* full-speed push straight back into the wall, over and
   * over - depending on the physics engine this either reads as visible jitter or, worse, eventually
   * breaks through entirely once repeated small residual penetrations add up enough that most
   * engines' CCD/TOI sweep can no longer find a valid time-of-impact from an already-overlapping
   * start).
   *
   * Applying this cap *unconditionally* (every tick, blocked or not) was tried first and reverted -
   * a real, reported regression, not a hypothetical: an unobstructed carry needs to swing its
   * commanded velocity by up to `2 × maxFollowSpeed` in a single tick on an ordinary fast turn (the
   * target point can reverse direction almost outright - see `maxHoldDistance`'s own doc on how far
   * it can jump), and an unconditional cap throttles that exactly as hard as it throttles a genuinely
   * blocked push, adding a sluggish, unintended "inertia" to every turn instead of just the
   * once-in-a-while wall-pinned case. Gating the cap on "was last tick's command actually achieved"
   * tells these two cases apart: a free turn's new command gets achieved essentially in full the very
   * next tick (nothing resists it), so the cap never engages; a wall-pinned push keeps failing to be
   * achieved tick after tick, so the cap stays engaged for as long as that persists. Default 60 -
   * only needs to be low enough to stop the creep once blocked is actually detected (confirmed safe
   * well below the ~700-800 m/s² point where a standalone wall-push repro at this class's own
   * default spring constants started tunnelling again), not to feel snappy on its own, since once
   * blocked is detected it's no longer trying to feel snappy - it's trying to stop.
   */
  maxAcceleration: number
  /**
   * How strongly the held object's own angular velocity is damped back towards zero each tick -
   * `0` leaves it entirely alone (spins freely off whatever momentum it had when grabbed), `1`
   * zeroes it outright every tick (rigid, non-spinning while carried, closest to Source's
   * physcannon feel). Default 1.
   */
  angularDamping: number
  /**
   * If the hold target ever ends up farther than this from the object's actual position (e.g. it
   * got squeezed into a wall/corner it can't be dragged through), it is force-`release()`d instead
   * of being fought back out with an ever-growing spring velocity. In meters. Default 8.
   *
   * Set this comfortably larger than twice whatever `holdDistance` the driving controller uses
   * (e.g. `ObjectGrabController.holdDistance`) - the hold point itself can jump by up to `2 ×
   * holdDistance` from an ordinary, deliberate fast look-around alone (the camera spinning in
   * place, not moving), even with nothing wrong or stuck: a target hold point directly in front of
   * the camera traces a sphere of that radius around the camera position as it turns, so a ~180°
   * flick moves the target by that full diameter in one or two ticks. `maxHoldDistance` set too
   * close to `2 × holdDistance` (e.g. equal to it) force-releases the object on an ordinary fast
   * turn, not just when it's genuinely stuck - reserve this threshold for the latter.
   */
  maxHoldDistance: number
}