Controllers
import { CharacterController } from '@react-three/jolt/controllers';
Characters, cameras and vehicles are things Jolt does well and physics wrappers usually don't.
They sit behind their own subpath because they are opinionated, they bring input handling with
them (via @react-three/jolt/addons), and they are the least settled part of the
library — but they ship inside @react-three/jolt, so there is nothing extra to install.
These components work, but they are the roughest corner of the API: several props are not wired up yet, and the components are thin wrappers over systems that are much more capable than the props suggest. Where a prop is missing, reach for the system.
CharacterController
A Jolt CharacterVirtual — a capsule that walks, runs, crouches, jumps, climbs stairs and
slides — driven by keyboard/gamepad commands out of the box.
import { Physics } from '@react-three/jolt';
import { CameraRig, CharacterController } from '@react-three/jolt/controllers';
export function Player() {
return (
<Physics>
<CharacterController radius={0.5} height={2}>
<mesh>
<capsuleGeometry args={[0.5, 1]} />
<meshStandardMaterial color="white" />
</mesh>
</CharacterController>
<CameraRig />
</Physics>
);
}
| prop | type | default | |
|---|---|---|---|
radius | number | 1 | capsule radius, applied at creation and on every change |
height | number | 2 | capsule height, same |
debug | boolean | true | draws the capsule. Note the default is on |
position | Vector3 | tuple | the character's real position — sets it at creation, teleports on change | |
innerBody | boolean | false | give the character a body the world collides with — see below |
innerBodyLayer | number | Layer.MOVING | object layer for that body |
ref | Ref<CharacterControllerSystem> | hands back the system itself |
Default bindings (from the commander): WASD/left stick to move, Space to
jump, Shift to run, C to crouch. The move direction is rotated by the camera's horizontal
rotation, so "forward" means forward on screen.
Being collided with
A CharacterVirtual is not part of the simulation. It collides against the world — it walks on
floors, it is stopped by walls — but the world does not collide against it. Push a crate into an
NPC built this way and the crate goes straight through.
innerBody fixes that. Jolt gives the character a real kinematic body, keeps it glued to the
capsule, and everything else in the world collides with that:
<CharacterController innerBody radius={0.5} height={2}>
<mesh>
<capsuleGeometry args={[0.5, 1]} />
<meshStandardMaterial color="white" />
</mesh>
</CharacterController>
This is what Jolt's standard (non-virtual) Character class is usually reached for. That class
isn't exposed by jolt-physics, and its author considers it inferior to CharacterVirtual — an
inner body gets you the property it was wanted for without giving up stair walking, crouching or
any of the rest.
It costs one extra body and the narrowphase work that implies, so it is off by default. Turn it on for characters other things bump into (NPCs, other players) and leave it off for a first-person player that nothing ever collides with.
innerBody and innerBodyLayer are read once, when the controller is built: Jolt creates
the inner body inside the CharacterVirtual constructor and has no API to add, remove or
re-layer one afterwards. Changing either prop remounts the controller. setCapsule does keep
the inner body's shape in step, so resizing works normally.
CharacterControllerSystem exposes hasInnerBody and innerBodyId (the Jolt BodyID, or
undefined). The body belongs to the character — don't remove it through BodySystem, and
don't hold the id past destroy().
CharacterControllerSystem
The component creates one and shares it through CharacterControllerContext:
import {
CharacterControllerContext,
type CharacterControllerSystem
} from '@react-three/jolt/controllers';
import { useContext } from 'react';
function Stamina() {
// the context is untyped today, so annotate it yourself
const { characterSystem } = useContext(CharacterControllerContext) as {
characterSystem?: CharacterControllerSystem;
};
// characterSystem?.isRunning, .isExhausted, .hangtime, ...
return null;
}
Movement and state:
| member | |
|---|---|
move(direction) | world-space direction; length is ignored, speed comes from the settings |
jump() | respects jumpLimit, allowJumpWhileFalling and exhaustion |
startRunning(speed?) / stopRunning() | |
setCrouched(crouched, forceUpdate?) | swaps to the crouching capsule |
position, rotation, linearVelocity | three.js types, get/set |
isMoving, isRunning, isCrouched, isSliding, isGrounded, isExhausted, isRotating, hangtime | state |
anchor | the BodyState other things (like the camera rig) attach to |
on(action, callback) | subscribe to one action; returns an unsubscribe |
addActionListener(fn) | subscribe to every action; returns an unsubscribe |
destroy() | idempotent; called for you when the component unmounts |
Actions, and what they carry:
| action | payload |
|---|---|
'falling' | true on the first frame of hangtime, then the hangtime in seconds |
'jump' | the jump counter |
'crouched' | boolean |
'running' | the new speed on start, 0 on stop |
'exhausted' | the exhaustion time limit |
'exausted' | false — the spelling is a bug in the source; it is a different string from 'exhausted' |
import type { CharacterControllerSystem } from '@react-three/jolt/controllers';
function watchJumps(characterSystem: CharacterControllerSystem) {
const off = characterSystem.on('jump', (_action, count) => console.log('jump', count));
off();
}
removeActionListener(fn) still exists and is deprecated — keep the unsubscribe instead.
Tuning (all plain properties with sensible defaults):
characterSpeed (6), characterSpeedCrouched (3), characterSpeedExhausted (2), jumpSpeed
(15), jumpLimit (2), jumpDegradeFactor (0.5), characterHeightStanding (2),
characterRadiusStanding (1), characterHeightCrouching (1), characterRadiusCrouching (0.8),
allowAirbornControl (true), enableCharacterInertia (true), enableCharacterRotation (true),
enableWalkStairs (true), enableStickToFloor (true), allowSliding (false), allowRunning
(true), enableExhaustion (true), allowJumpWhileFalling (true), runningTimeLimit (5000 ms),
exauhstionTimeLimit (7000 ms, spelled as in the source), maxRotationSpeed (0.1 rad/frame),
lerpFactor (0.4).
Stair and floor behaviour maps onto Jolt's own settings: walkStairsStepUp,
walkStairsMinStepForward, walkStairsStepForwardTest, walkStairsStepDownExtra,
stickToFloorStepDown. Ground state is readable through groundState, isSupported,
isFalling, groundNormal, groundPosition, groundVelocity, groundMaterial and
groundBodyHandle.
The character uses Layer.RIG, so it does not collide with other rigs — see
layers.
Head and ceiling collision
ExtendedUpdate's own ground/wall/ceiling classification only tells Jolt how to sweep the
character; it does nothing to the character's velocity. Without help, a character jumping into a
low overhang keeps its upward velocity until gravity alone brings it back down, which reads as
getting stuck under the ceiling for a beat too long (issue #88). headAngle and onHeadHit are
the fix:
import { CharacterController } from '@react-three/jolt/controllers';
import type { HeadHitInfo } from '@react-three/jolt/controllers';
<CharacterController
headAngle={Math.PI / 6} // default: 30 degrees
onHeadHit={(info: HeadHitInfo) => {
console.log('bonk', info.normal, info.previousVerticalSpeed);
}}
/>;
| prop / member | ||
|---|---|---|
headAngle | number (radians) | half-angle of the cone around straight-up within which a contact counts as a head/ceiling hit rather than walkable ground or a wall. Default 30°. A contact whose normal satisfies contactNormal · up < -cos(headAngle) cancels the character's upward velocity |
onHeadHit | (info: HeadHitInfo) => void | called once per new contact inside that cone while the character is moving upward — not every step the contact persists, and not for ground/wall contacts |
HeadHitInfo carries normal (world-space contact normal, pointing away from the obstacle
towards the character) and previousVerticalSpeed (the up-axis speed at the moment of the hit,
before it is cancelled). Both are set on the CharacterControllerSystem directly too
(characterSystem.headAngle, characterSystem.onHeadHit = ...) if you're driving the system
without the component.
Events
CharacterControllerSystem.events is an Emitter<CharacterEventMap> — the same primitive
bodyState.on() and the world's <Physics on*> use. Every movement event is an edge, derived
once per pre-step from ground state and velocity relative to whatever is carrying the character,
and every one of them is also emitted as an 'action' under the same name, so the older
characterSystem.on('jump', fn) (filtered-action) API and characterSystem.events.on('jump', fn)
see the same things.
import { useCharacterEvent } from '@react-three/jolt/controllers';
<CharacterController
onMove={(speed) => console.log('moving', speed)}
onStop={() => console.log('stopped')}
onSlide={(speed) => console.log('sliding', speed)}
onSlideEnd={() => console.log('slide ended')}
onJump={(count) => console.log('jump', count)}
onLand={(airtime) => console.log('landed after', airtime, 's')}
onGround={() => console.log('grounded')}
onAirborne={() => console.log('airborne')}
onContactAdded={(payload) => console.log('touched', payload.handle)}
/>;
useCharacterEvent(system, type, handler) is the imperative form the component's on* props are
built from — subscribe from your own component once you have a CharacterControllerSystem (from
CharacterControllerContext, or one you built yourself):
import { useCharacterEvent } from '@react-three/jolt/controllers';
import type { CharacterControllerSystem } from '@react-three/jolt/controllers';
function Footsteps({ characterSystem }: { characterSystem?: CharacterControllerSystem }) {
useCharacterEvent(characterSystem, 'move', (speed) => {
if (speed > 4) playRunSfx();
});
return null;
}
Like useBodyEvent, its dependency is whether a handler was passed rather than its identity, so
an inline arrow does not resubscribe every render.
CharacterEventMap:
| event | payload | |
|---|---|---|
action | (name, payload) | every action, including all the ones below |
move | speed: number | started moving under its own power; speed is m/s relative to the ground |
stop | — | stopped moving under its own power |
slide | speed: number | started sliding down something too steep to stand on |
slideEnd | — | stopped sliding |
jump | count: number | a jump was accepted; which jump of the allowed sequence it was |
land | airtime: number | touched down, after being unsupported for airtime seconds |
ground | — | became supported by something (always paired with land) |
airborne | — | stopped being supported by anything |
crouch / stand | — | |
contactAdded / contactPersisted / contactRemoved | CharacterContactPayload | a contact with a body, forwarded from Jolt's CharacterContactListener. Pooled — read it inside the handler, don't keep it. Dispatched after the character update returns, never from inside ExtendedUpdate |
CharacterContactPayload carries body/object (undefined for a Jolt body BodySystem never
registered), handle, subShapeId, position (zeroed for contactRemoved, which Jolt gives no
geometry) and normal (pointing from the character into the other body).
Component props: onMove, onStop, onSlide, onSlideEnd, onJump, onLand, onGround,
onAirborne, onCrouch, onStand, onContactAdded, onContactPersisted, onContactRemoved and
onAction mirror the event map one-for-one, plus moveThreshold (default 0.5 m/s — the relative
speed above which the character counts as moving) and slideThreshold (default 0.5 m/s, along the
surface). Nothing subscribes for a prop you don't pass, so an unused contact stream costs one &
per contact and nothing else.
Everything in CharacterEventMap is dispatched after ExtendedUpdate returns — Jolt's
CharacterContactListener fires from inside it, where adding or removing a body is illegal and
the pointers it hands the callback are into memory Jolt reuses. Never call anything that adds,
removes, or otherwise mutates bodies from inside a listener registered here — by the time your
handler runs the step has already finished, but the contract still matters for the payload
itself: it is pooled (one object reused for every contact in a step), so copy what you need and
return.
CameraRig
A physics-aware third-person camera: a boom arm on a chain of spaces (base → collar →
camera) that collides with the world instead of clipping through it, and follows an anchor.
<CharacterController>
<mesh>
<capsuleGeometry args={[0.5, 1]} />
</mesh>
</CharacterController>
<CameraRig />
Inside a <CharacterController>, <CameraRig> finds the character through context and attaches
itself automatically. Otherwise pass a body:
<CameraRig anchor={body.current ?? undefined} />
Mouse, touch, stick look and wheel zoom are bound through
useLookCommand; R resets the camera behind the anchor.
Options
Every option below is also a <CameraRig> prop. They reach the boom before the rig is first
stepped, so the camera opens already framed instead of snapping into place on frame one.
Changing one afterwards updates the live rig through setOptions() — the manager is memoised and
is never rebuilt for a prop change, and a new distance is eased into rather than snapped to.
| option | default | |
|---|---|---|
cameraPosition | (0, 0, 0) | where the rig's main camera starts, in rig space. A camera anywhere other than the origin defines the boom's length, pitch and yaw |
distance | 5 | boom length, metres. An explicit value wins over cameraPosition |
minDistance / maxDistance | 0.1 / 100 | zoom limits |
pitch | 0 | starting pitch, radians; negative looks down |
minPitch / maxPitch | -1.5 / 0.5 | what a look command may reach |
yaw | 0 | starting yaw about world up |
collisionRadius | — | radius of the camera's collision sphere; left to the shape collider's own default when unset |
smoothing | 0.5 | lerp factor while the boom changes length |
lookSpeed / zoomSpeed | 1 / 1 | input multipliers |
allowCameraClipping | false | skip the collision, obstruction and shapecast tests entirely |
obstructionBuffer | 0.01 | gap kept between the camera and whatever obstructs it |
target | (0, 0, 0) | point the boom frames, relative to the pivot |
updateMode | 'demand' | 'demand' recomputes on every command, 'additive' once per frame |
followTarget | — | BodyState to follow, before the first step |
anchorOffset | (0, 2, 0) | where on the body the boom hangs |
positionUpdateType | 'distance' | or 'fixed' |
debug | true | draw the rig's spaces |
<CameraRig cameraPosition={[4, 4, 4]} minPitch={-1.2} maxPitch={0.4} smoothing={0.35} />
Follow modes
A PC-style rig only translates with its anchor, so running off sideways leaves you looking at
the character's ear. followMode decides where the rig points:
| mode | |
|---|---|
'free' (default) | the boom only turns when the player turns it |
'movement' | the boom eases round to trail the character's horizontal velocity, so heading off in a new direction swings the camera in behind you |
'lookAt' | the boom eases round so lookAtTarget stays framed past the character |
<CameraRig followMode="movement" rotationSpeed={3} movementThreshold={0.5} />
| option | default | |
|---|---|---|
followMode | 'free' | |
rotationSpeed | 2 | how fast an automatic mode eases the yaw round, per second. Always takes the short way |
movementThreshold | 0.5 | ground speed, m/s, the character must beat before 'movement' steers |
manualOverrideTimeout | 1000 | ms a look command parks the automatic modes for |
lookAtTarget | — | THREE.Object3D | THREE.Vector3, for 'lookAt' |
characterSystem | — | whose velocity drives 'movement'; set for you inside a <CharacterController> |
Neither automatic mode fights the player: CameraBoom.move() / rotate() stamp lastLookTime,
so the existing look bindings need no changes. Inside a <CharacterController> the rig reads the
character's own linearVelocity — the anchor it follows is a kinematic stand-in for a
CharacterVirtual and does not carry one. Standalone, it falls back to the followed body's
velocity.
Whiskers
The boom already shape-casts backwards and pulls the camera in when a wall gets between it and the
player, which reads as a snap. With whiskers on it also fans short rays out either side of the
boom every step and rotates the yaw away from whatever they touch, so the camera slides around a
corner before the wall ever becomes a problem.
<CameraRig whiskers whiskerLength={3} whiskerStrength={2} />
| option | default | |
|---|---|---|
whiskers | false | |
whiskerCount | 5 | fanned evenly across the spread |
whiskerSpread | 60° | half-angle of the fan either side of the boom, radians |
whiskerLength | 3 | metres |
whiskerStrength | 2 | peak yaw rate a fully buried whisker asks for, rad/s |
whiskerDamping | 0.2 | how fast the steering rate catches up; lower is smoother |
boom.isWhiskerSteering says whether anything is currently in the way (it is a field on
CameraBoom, not an option), and boom.whiskerYawVelocity is the current rate. The steering is
spring damped, so it eases in and decays back to zero once the whiskers come clear.
The whisker raycaster is built lazily the first time whiskers are switched on and is freed by
destroy() with the boom's other queries. The per-step sweep allocates on neither side of the
WASM boundary.
CameraRigManager
useCameraRig(options) gives you the manager directly. It takes over r3f's default camera — and
restores it on unmount — and disables the r3f controls while it is mounted.
| member | |
|---|---|
setOptions(options) | change options on a live rig; the rig is not rebuilt |
attach(body, offset?) / detach() / reAttach() | follow a BodyState |
moveBoom(lookVector) / zoom(level) | what the look command drives |
createCamera(name, options?) / addCamera(name, camera, space?) / setActiveCamera(name) / getCamera(name) / removeCamera(name) | multiple cameras, positioned in rig space |
onCamera(fn) | subscribe to camera changes; returns an unsubscribe |
controls | the CameraBoom: collision, obstruction and whiskers |
debug | draw the rig's spaces |
destroy() | the public teardown |
destroy() stops the rig being stepped, frees the boom's raycaster, shapecaster and shape
collider, and removes every body and three.js object the rig created. It is idempotent.
useCameraRig and <CameraRig> call it on unmount, so mounting and unmounting a rig no longer
leaks. attachToLoop() / detachFromLoop() are private on the manager — destroy() is what
you call. (VehicleSystem.detachFromLoop() is the one public exception.)
On the boom itself, initialize(options) configures it and snaps to the resulting pose,
setOptions(options) eases into it and only touches the options you passed, and destroy() is
likewise idempotent.
Bodies marked allowObstruction let the boom pull the camera in (or
see through them) rather than getting stuck behind them.
Vehicle
One component for every kind of vehicle: a Jolt vehicle constraint with engine, transmission, differentials and suspension.
import { Vehicle } from '@react-three/jolt/controllers';
<Vehicle type="fourWheel" position={[0, 25, 0]} vehicleSettings={{ vehicleMass: 900 }} />;
| prop | type | default | |
|---|---|---|---|
type | 'fourWheel' | 'twoWheel' | 'fourWheel' | a car or a motorcycle |
name | string | 'car' / 'bike' | the key it is registered under in its VehicleSystem |
position | THREE.Vector3 | [number, number, number] | — | reactive |
vehicleSettings | VehicleSettings | per type | merged over the defaults — see below |
bodyObject | Object3DSource | — | your own chassis object |
childrenAsChassis | boolean | true when children are given and bodyObject is not | |
wheels | VehicleWheelOptions[] | — | per-wheel settings, in constraint order |
wheelObjects | Object3DSource[] | — | just the objects, in constraint order |
debug | boolean | true | show the generated stand-in meshes |
followCamera | boolean | true | translate the r3f camera by the chassis delta each step |
onVehicle | (vehicle: VehicleManager | null) => void | — | called when the manager appears and again with null when it goes |
Object3DSource is THREE.Object3D | RefObject<THREE.Object3D | null> | null | undefined, so a
ref is fine and is resolved after mount.
Driving is bound to the move command (stick/WASD) out of the box.
Injecting your own meshes
<Vehicle type="fourWheel" position={[0, 25, 0]} wheelObjects={[fl, fr, bl, br]}>
<primitive object={chassisGltf.scene} />
</Vehicle>
Children become the chassis by default. bodyObject does the same with an object or a ref, and
wheels / wheelObjects do it for the wheels — position and rotation, steering included. Wheel
order is fl, fr, bl, br for a four-wheeler and front, back for a two-wheeler
(wheelOrderByType). If both wheels and wheelObjects are given, wheels wins.
Ownership: the manager disposes only the meshes it generated itself. An object you injected is detached on teardown and never disposed.
useVehicle
import { useVehicle } from '@react-three/jolt/controllers';
import { useEffect } from 'react';
function Car() {
const vehicle = useVehicle({ type: 'fourWheel', position: [0, 25, 0] });
useEffect(() => vehicle?.onPostStep(() => console.log(vehicle.position)), [vehicle]);
return null;
}
useVehicle(options) returns the VehicleManager, or null on the first render — it is
created after mount. Options are name, type, settings, position, bodyObject, wheels,
wheelObjects, debug and addToScene (default true). Settings are read when the vehicle is
built: changing name or type rebuilds it, and live changes go through the manager's own
setters. The vehicle, its VehicleSystem and everything they own are destroyed on unmount.
VehicleManager
| member | |
|---|---|
move(direction) | a Vector3 or Vector2 of driver input |
setHandBrake(v) / setBrake(v) / triggerTurbo(extraTime?) | |
setPosition(position) | |
setBodyObject(object | null) | null puts the generated box back |
setWheelObject(index | name, object | null) / setWheelObjects(objects) | |
getWheel(index | name) / getWheelObject(index | name) | |
onPreStep(fn) / onPostCollide(fn) / onPostStep(fn) | each returns an unsubscribe |
onAction(type, fn) | ditto |
position, bodyObject, wheels, wheelOrder, settings, debug | |
destroy() | idempotent |
settings is a fully resolved, typed ResolvedVehicleSettings object rather than any.
FourWheelVehicleManager and TwoWheelVehicleManager are the two concrete classes;
VehicleSystem holds them by name (addVehicle, getVehicle, removeVehicle, setPosition,
detachFromLoop, destroy).
Settings
vehicleSettings is a discriminated union on type. Shared by both:
bodyPosition, castType ('ray' | 'sphere' | 'cylinder'), vehicleLength, vehicleWidth,
vehicleHeight, vehicleMass, maxPitchRollAngle, maxEngineTorque, clutchStrength,
bodyObject, wheelObjects.
fourWheel adds fourWheelDrive, frontBackLimitedSlipRatio, leftRightLimitedSlipRatio,
antiRollbar, splitEngineTorqueFront, splitEngineTorqueRear, frontRollBarStiffness,
rearRollBarStiffness and a wheels object with fl / fr / bl / br overrides. Defaults:
4.0 × 1.8 × 0.4 m, 1500 kg, cylinder cast, four-wheel drive, 500 N·m of engine torque, 30° of
steering, 0.5 m wheels.
twoWheel adds steerSpeed (radians per second the steering angle may change by),
casterAngle and a wheels object with front / back overrides. Defaults: 0.8 × 0.4 × 0.6 m,
250 kg, 30° caster, 0.31 m wheels.
Per-wheel settings cover radius, width, position (the attachment point; when given it
overrides the wheelOffsetHorizontal / wheelOffsetVertical layout helpers),
suspensionMinLength / suspensionMaxLength / suspensionPreloadLength, suspensionDirection,
suspensionForcePoint, suspensionSpring ({ frequency, damping }), steeringAxis, wheelUp,
wheelForward, inertia, angularDamping, plus maxSteerAngle / maxBrakeTorque /
maxHandBrakeTorque (four-wheel) or posZ / suspensionFreq / brakeTorque (two-wheel).
defaultFourWheelVehicleSettings, defaultTwoWheelVehicleSettings and
resolveVehicleSettings(settings) are exported if you want to inspect or pre-merge them. Your
settings are merged over the defaults — a motorcycle built with a custom mass or custom wheels
no longer silently gets the defaults back.
Secondary physics
Three presentational layers sit on top of the constraint (issue #41) — none of them touch the
simulation, they only read what Jolt solved and drive the three.js objects (and the readouts a
game needs for particles, skid marks and engine audio) from that. All three are on by default
and independently configurable through vehicleSettings, and each can be turned off with false:
<Vehicle
type="fourWheel"
vehicleSettings={{
bodyRoll: { maxAngle: 0.15, referenceAcceleration: 6 },
wheelSmoothing: false,
skid: { longitudinalSlip: 2 }
}}
/>;
bodyRoll — a spring-damped visual tilt of the chassis object (never the physics body),
driven by the chassis' own acceleration rotated into its local frame: a steady turn leans the body
outwards, exactly like a real sprung mass. Because the manager owns that object's local rotation
while this is on, rotate your model inside a wrapper if you need to orient it yourself.
| option | default | |
|---|---|---|
maxAngle | 0.1 rad (~5.7°) | the most the body may lean sideways |
maxPitchAngle | maxAngle / 2 | the most it may pitch under acceleration/braking |
referenceAcceleration | 9.81 m/s² | the acceleration that produces the full maxAngle — lower for floatier, raise for stiffer |
stiffness | 120 | spring constant pulling the tilt towards its target |
damping | 20 | damping of that spring; 2 * sqrt(stiffness) is critical |
wheelSmoothing — easing applied to what the wheels render, so a wheel doesn't snap between
two suspension lengths or steering angles the solver happens to land on between frames. Wheel spin
is Jolt's own, integrated from its angular velocity, and is never eased.
| option | default | |
|---|---|---|
suspension | 0.04 s | time constant for rendered suspension travel — ~63% of the way to Jolt's value every this many seconds. 0 renders it raw |
steering | 0.05 s | the same, for the rendered steering angle |
skid — when a wheel counts as skidding, compared against Jolt's own per-wheel slip. The
thresholds are deliberately generous: a longitudinal slip ratio of 3 (the wheel spinning at four
times the speed of the ground under it) is an ordinary standing start in a 500 N·m car.
| option | default | |
|---|---|---|
longitudinalSlip | 1.5 | the |slipRatio| a wheel has to exceed to be skidding |
lateralSlip | 0.25 rad (~14°) | the |lateralSlip| a wheel has to exceed |
minLateralSpeed | 0.5 m/s | below this speed, lateral slip never counts — Jolt's `atan2(lateral, |
release | 0.7 | the fraction of those thresholds a skidding wheel has to fall back under |
releaseTime | 0.12 s | how long it has to stay under release before skidEnd fires |
requireContact | true | wheels not touching anything never skid |
Change any of the three live, on the manager: vehicle.setBodyRoll(settings | false),
vehicle.setWheelSmoothing(settings | false), vehicle.setSkid(settings | false). Turning
bodyRoll off puts the chassis object back to level; turning skid off ends every skid currently
running, so a listener that started a particle effect still gets its skidEnd.
Readouts and events
import { useVehicle } from '@react-three/jolt/controllers';
import { useEffect } from 'react';
function Dashboard() {
const vehicle = useVehicle({ type: 'fourWheel' });
useEffect(() => {
if (!vehicle) return;
const offSkid = vehicle.onSkidStart((e) => spawnSkidMark(e.position, e.name));
const offEngine = vehicle.onEngine(({ rpm, gear, throttle }) => {
engineSound.playbackRate = 0.5 + rpm / 6000;
engineSound.volume = 0.2 + 0.8 * throttle;
});
return () => {
offSkid();
offEngine();
};
}, [vehicle]);
return null;
}
| member | ||
|---|---|---|
rpm | number | the engine's current RPM, 0 with no engine (a destroyed vehicle) |
gear | number | 0 in neutral, 1..n forward, negative in reverse |
shifting | boolean | true while the transmission is between gears |
clutch | number | clutch friction, 0..1 |
throttle / brakeInput | number | driver input, 0..1 |
speed / speedKmh | number | chassis' signed forward speed, m/s / km/h |
skidding | boolean | true while any wheel is over the skid thresholds |
onSkidStart(fn) / onSkidEnd(fn) | (event: VehicleSkidEvent) => void | a wheel crossed (or released) the skid thresholds |
onEngine(fn) | (state: VehicleEngineState) => void | the engine readout, once per physics step |
Every readout getter reads a Jolt static temporary or a number — none of it allocates. Both event
payloads are pooled (the same object every dispatch): VehicleSkidEvent carries wheel,
name ('fl' | 'fr' | 'bl' | 'br' or 'front' | 'back'), index, slipRatio, lateralSlip,
position (world space, also pooled — copy it if you keep it) and speedKmh. VehicleEngineState
carries rpm, gear, throttle, brake, speed, speedKmh, shifting, clutch and
skidding. onEngine's handler is skipped entirely (no state object is even filled) unless
something is subscribed, so an unused dashboard costs nothing per step.
VehicleFourWheel
Deprecated. <VehicleFourWheel /> is exactly <Vehicle type="fourWheel" /> and now forwards
to it; the type string it used to take is replaced by <Vehicle type="twoWheel">. It keeps
working for one release and warns once under setDebug(true).
VehicleFourWheelProps is likewise a deprecated alias of VehicleProps.
The managers were renamed to FourWheelVehicleManager / TwoWheelVehicleManager; the old
VehicleFourWheelManager and VehicleManagerTwoWheels names are exported as deprecated aliases.
<Vehicle followCamera> translates the r3f camera by the chassis delta on every pre-step (and
retargets the default controls if there are any). Pass followCamera={false} if you are
managing the camera yourself — with a <CameraRig>, for instance.