Skip to content

object-grab.controller overview


Table of contents


utils

ObjectGrabController (class)

HL2/Portal-style "use key carries a physics prop" input controller: raycasts from camera's current position/forward direction to find a Grabbable3dEntity within maxGrabDistance, grabKey picks it up (and drops it again if already holding one - a second grabKey press is equivalent to the right mouse button), left mouse button throws it forward (throwSpeed), right mouse button drops it in place. Mirrors PlayerCharacterController in shape (an input-only entity driving a separate physics entity, reading camera for aim rather than owning it) - pair the two by passing the same keyboard/mouseInput/camera instances to both, rather than constructing a second MouseInput/KeyboardInput here, so pointer-lock/focus behavior stays single-sourced.

Held-object collision with the rest of the world (other dynamic bodies, static geometry) is intentionally left enabled while carried - see Grabbable3dEntity's own doc for why carrying is a velocity spring rather than a rigid attachment. This means a carried prop can be nudged out of position by something it bumps into, or pin against geometry it's pushed into, matching the feel of Source's own physgun rather than a perfectly rigid hold.

Collision with holder specifically is excluded outright, not left to the physics engine's ordinary group/mask filtering: whenever a holder is given, tryGrab() adds the held object's objectBody to holder.characterController.ignoredBodies (removed again by whichever of throwHeld/dropHeld/the tick loop's self-release handling ends the hold), making the object genuinely invisible to the holder's own collision queries for as long as it's held - see ICharacterController3dComponent.ignoredBodies's doc for why a real per-pair exclusion like this, rather than collision groups, is what the job actually needs: group/mask filtering is incapable of excluding just this one pair while both the holder and the object still need to collide with the rest of the world (almost always true), no matter how the groups are arranged - there used to be a holderCollisionGroups option here built on exactly that approach; it never actually worked for that reason and was removed once ignoredBodies shipped as the real fix. Grabbable3dEntity.grab() still takes its own ignoreCollisionGroups parameter directly, for whatever unrelated group exclusion an app might still want while a prop is held - just not as a way to exclude holder.

The hold point is additionally clamped away from holder's own capsule (see clampAwayFromHolder) when a holder is given, purely to keep a resting/thrown-and-recaught prop from visibly sitting inside the holder's own model at the moment it's picked up, before the spring has had a chance to move it - ignoredBodies above is what actually keeps the holder's movement from being blocked by (or resonating with) a held prop; this clamp is a cosmetic finishing touch on top of that, not a second line of defense against it.

holder is null for a holder with no capsule to exclude in the first place - e.g. this controller paired with a FreeCameraController (a free-flying spectator/debug camera, not a CharacterController3dEntity) rather than PlayerCharacterController. ignoredBodies and the clamp are simply skipped in that case - there's no kinematic capsule for a held prop to block or resonate with in the first place, and nothing here needs the holder to be a physics body at all when it isn't one.

Signature

export declare class ObjectGrabController<TypeDoc> {
  constructor(
    protected readonly keyboard: KeyboardInput,
    protected readonly mouseInput: MouseInput,
    protected readonly camera: Renderer3dEntity<TypeDoc['vTypeDoc']>,
    /** The character whose capsule the hold point is kept clear of - see this class's own doc. Pass
     * the same character driven by the paired `PlayerCharacterController`, or `null` if there's no
     * capsule to exclude (e.g. paired with a `FreeCameraController` instead). */
    protected readonly holder: CharacterController3dEntity<TypeDoc> | null,
    options: Partial<ObjectGrabControllerOptions> = {}
  )
}

onSpawned (method)

Signature

onSpawned(world: Gg3dWorld<TypeDoc>): void

onRemoved (method)

Signature

onRemoved(): void

holdPoint (method)

The world-space point the held object is currently driven towards - protected, not private, so a subclass can override it (e.g. to redirect the hold point through a portal pair once the held object is known to be on the far side of one - see findGrabTarget's own doc for the matching override point on the "what to grab" side of this same concern). An override should still route through super.holdPoint() for the ordinary, not-through-anything case rather than reimplementing clampAwayFromHolder itself.

Signature

protected holdPoint(): Point3

holderClearance (method)

Radius of a sphere around holder guaranteed to clear its capsule in any direction. Assumes holder is set. Used both by clampAwayFromHolder (the hold point's own exclusion radius) and by tryGrab() (as an upper-bound heuristic for "this first hit was probably my own capsule, not a real obstacle" - see that method's own doc).

Signature

protected holderClearance(): number

clampAwayFromHolder (method)

Signature

protected clampAwayFromHolder(target: Point3): Point3

findGrabTarget (method)

A first-person camera sits inside (or right at the surface of) holder's own capsule, so the very first thing a raycast from camera.position finds along almost any forward direction is that capsule itself, not whatever's actually being aimed at - holder.characterController isn't reliably resolvable back to holder from a RaycastResult on every adapter either (e.g. Rapier3dCharacterControllerComponent's own doc - its collider is never registered for that), so this can't be told apart from "some other real obstacle" by identity, and RaycastResult gives no distance-through-the-shape either: a ray whose origin is already inside a shape reports that shape as hit at distance 0 (Rapier's castRay(..., solid: true, ...), and Ammo's own rayTest finding nothing at all for the containing shape and falling back to a distance-0 overlap probe) rather than the point where it would actually exit - so "retry from where the self-hit exits" has no real value to read a retry start point from on any adapter, and the two adapters aren't even consistent with each other about what a self-hit is: Ammo's rayTest simply can't see the shape containing its own ray origin, so it transparently reports whatever real object is actually hit beyond it (the common case needs no retry on Ammo at all); Rapier's solid: true mode does the opposite and always reports the containing shape at distance 0, ahead of anything genuinely beyond it - so relying on the first cast to reliably find the real target only ever works by accident, and only on Ammo.

First tried starting the ray a fixed holderClearance() distance in front of the camera instead of at it - a worst-case guess at how big the holder's capsule can possibly be in any direction (it also accounts for centersDistance, the capsule's full half-height, not just its radius). That guess overshoots dramatically for a camera pitched steeply down at something close and small (the common case for a resting prop, which sits well below eye height) - real, reproduced bug: standing close enough to a small grabbable prop resting on a pedestal, the fixed skip flew straight past the prop and landed inside the pedestal underneath it, reporting a legitimate hit on the (non-grabbable) pedestal instead of ever reaching the prop.

Then tried a two-pass cast, retrying from the first hit's own point (whatever it was) plus a small fixed clearance - cast once from the actual camera.position first, and only if that missed (or hit something within holderClearance(), plausibly the holder's own capsule) retry from just past that hit. This reads right in isolation, but silently assumed the first hit's point marks where the ray exits the self-collision shape - which, per this doc's opening paragraph, is never true for a solid/fallback hit at distance 0: the reported point is just the ray's own origin again. On Rapier specifically (where the first cast always reports the containing capsule this way whenever the camera is inside it) the retry's tiny nudge landed the second cast still deep inside the same capsule, which reported the exact same self-hit again - pick-up silently stopped working on Rapier entirely, while appearing to work fine on Ammo (whose rayTest never needed the retry to begin with, for the reason given above).

Fixed by computing the escape point geometrically instead of reading it off any raycast result: cast once from the actual camera.position first - if that already lands on a Grabbable3dEntity, done, no retry needed (this is what lets a prop close enough to be found before any self-hit would even occur through on Ammo). Otherwise, when the hit is closer than holderClearance() could ever put a genuinely different object (given the camera sits on/within the capsule's own axis), retry from camera.position skipped forward by characterControllerSelfHitSkip() - see that function's own doc for why it sphere-traces the capsule's own exact geometry (rather than a flat radius guess, which broke down for a first-person camera pitched down at something close while sitting above the capsule's cylindrical midsection - a real, reproduced regression) or reading anything off the first cast's result. PlayerCharacterController's third-person cameraCollision raycast leans on the exact same helper for the identical self-hit problem. A hit farther than holderClearance() away is trusted as a real obstacle and left blocking the grab, exactly as before.

Split out of tryGrab() (which still owns actually committing to the grab - setting _heldObject, calling grab()/ignoreForHolder) and made protected so a subclass can extend what counts as reachable without touching any of that bookkeeping: override this method to try something extra first (e.g. a portal-aware cast that tunnels the ray through a placed portal pair to reach a Grabbable3dEntity sitting on the far side) and fall back to super.findGrabTarget() for the ordinary, not-through-anything case. holdPoint() is the matching override point for keeping such an object correctly positioned once held - see its own doc.

Signature

protected findGrabTarget(): Grabbable3dEntity<TypeDoc> | null

tryGrab (method)

Signature

private tryGrab(): void

ignoreForHolder (method)

Adds obj.objectBody to holder.characterController.ignoredBodies (see that property's own doc) so the holder's own collision queries never treat the object it's currently carrying as an obstacle - a no-op if there's no holder, or obj has no objectBody (impossible in practice - Grabbable3dEntity's constructor requires one - but objectBody's own type is nullable, see Entity3d). Always paired with unignoreForHolder on the same object before it stops being held, from every path that can end a hold (throwHeld/dropHeld, and the tick loop's own handling of updateHold's self-release) - not just this class's own drop/throw methods, since leaving a stale entry in ignoredBodies would keep a since-dropped, no-longer-special object permanently invisible to the holder's own collision queries.

Signature

private ignoreForHolder(obj: Grabbable3dEntity<TypeDoc>): void

unignoreForHolder (method)

Undoes ignoreForHolder - see its own doc.

Signature

private unignoreForHolder(obj: Grabbable3dEntity<TypeDoc>): void

throwHeld (method)

Signature

private throwHeld(): void

dropHeld (method)

Signature

private dropHeld(): void

tickOrder (property)

Signature

readonly tickOrder: number

options (property)

Signature

readonly options: ObjectGrabControllerOptions

ObjectGrabControllerOptions (type alias)

Options for an ObjectGrabController.

Signature

export type ObjectGrabControllerOptions = {
  /**
   * Key code that toggles carrying: while empty-handed, picks up whatever `Grabbable3dEntity` the
   * camera is looking at within `maxGrabDistance`; while already holding something, drops it (same
   * as the right mouse button - see `dropHeld`). Default 'KeyE'.
   */
  grabKey: string
  /** Max raycast distance from the camera that counts as "in reach" to pick something up, in meters. Default 3. */
  maxGrabDistance: number
  /** Distance in front of the camera the held object is carried at, in meters. Default 1.5. */
  holdDistance: number
  /** Speed imparted to a thrown object along the camera's forward direction, in m/s. Default 12. */
  throwSpeed: number
  /**
   * Extra clearance kept, in meters, beyond `holder`'s own capsule (radius and half-height alike)
   * when clamping the hold point away from it - see this class's own doc for why the hold point is
   * clamped there at all. `0` lets the target land right on the holder's surface (the carried object
   * still visibly touches the holder at that point); the default leaves a small visible gap instead.
   * Default 0.3.
   */
  holderExclusionMargin: number
}