Shapes
A Jolt body needs a collision shape. Most of the time you don't build one: <RigidBody> reads
the geometry of the meshes inside it and picks a shape for you.
Automatic shapes
Every mesh under a <RigidBody> contributes a shape, chosen from its geometry type:
| geometry | shape |
|---|---|
BoxGeometry | box (from the geometry's own parameters) |
SphereGeometry | sphere (bounding sphere radius) |
CapsuleGeometry | capsule |
CylinderGeometry | cylinder |
| anything else | convex hull of the merged vertices |
Non-parametric geometry — an imported GLTF, a lathe, a custom BufferGeometry — becomes a
convex hull. That is a deliberate default: hulls are fast and always valid, where a triangle mesh
is neither.
Override per body, or for the whole world:
// one body
<RigidBody shape="sphere">
<mesh>
<sphereGeometry args={[1, 32, 32]} />
</mesh>
</RigidBody>
// every body that doesn't say otherwise
<Physics defaultShape="box">{children}</Physics>
ShapeType
type ShapeType =
| 'box'
| 'sphere'
| 'capsule'
| 'taperedCapsule'
| 'cylinder'
| 'taperedCylinder'
| 'convex'
| 'trimesh'
| 'heightfield'
| 'compound'
| 'staticCompound'
| 'mutableCompound'
| 'scaled'
| 'offsetCenterOfMass';
<Shape type>, <RigidBody shape> and <Physics defaultShape> all accept this one union
(issue #211 — it used to be two separate, slightly different unions called AutoShape and
ShapeType; AutoShape still exists as a deprecated alias of ShapeType so old imports keep
compiling). 'compound' is a documented alias of 'staticCompound', normalised before anything
else looks at it — type="compound" and type="staticCompound" always produce identical
descriptors. 'scaled' and 'offsetCenterOfMass' are decorator shapes: they wrap a child
descriptor rather than describing geometry directly (see the shape pipeline).
Scaled meshes
A mesh below a described object contributes its own scale: <mesh scale={2}> inside a
<RigidBody> produces a collider twice the size, not a collider at the drawn size. The root
object's scale is deliberately left out, because that belongs to the body
(BodyState.scale, which wraps the shape in a ScaledShape you can change again
later) and baking it in as well would apply it twice. Pass applyObjectScale to
describeShape if you want it baked in anyway.
Trimeshes
A triangle mesh is only ever used when you ask for it — shape="trimesh" — because it is the
most restricted shape Jolt has. Per Jolt's own docs, dynamic and kinematic trimeshes cannot
collide with each other or with heightfields, and trying throws. Use them for static level
geometry; use convex (or a compound of convex parts) for anything that moves.
Dynamic bodies cannot use a trimesh. A Jolt mesh shape is a one-sided triangle soup with no
inside, so mesh vs mesh does not collide at all and a dynamic mesh body sinks through the world
and ends up with a NaN position (issue #112,
Jolt docs). Asking for one warns and
builds a convex hull of the same points instead. Pick a different behaviour with
dynamicMeshStrategy:
| value | behaviour |
|---|---|
'convex' (default) | warn and use a convex hull of the mesh's points |
'error' | throw, so the mistake is loud |
'decompose' | reserved for a convex decomposition; throws with an explanation for now |
Static (and kinematic) bodies keep a real mesh shape. The converted body's mass and inertia come
from the hull's real GetMassProperties() rather than a fabricated solid box.
Set it per body with <RigidBody dynamicMeshStrategy>, or world wide with
<Physics defaultDynamicMeshStrategy> (issue #211 — a per-body value always wins over the
world's default):
// this one body throws instead of silently converting
<RigidBody type="dynamic" dynamicMeshStrategy="error">
<mesh geometry={importedTrimeshGeometry} />
</RigidBody>
// every dynamic body that doesn't say otherwise
<Physics defaultDynamicMeshStrategy="error">{children}</Physics>
It is also still a GenerateBodyOptions field for anyone building bodies directly:
const handle = bodySystem.addBody(mesh, { shapeType: 'trimesh', dynamicMeshStrategy: 'error' });
Note that the 'convex' warning goes through devWarn, so you only see it after
setDebug(true).
Compound shapes
Put several meshes in one <RigidBody> and you get a compound shape — positioned in the body's
local space, exactly as they render:
<RigidBody position={[-2, 15, 4]}>
<mesh>
<cylinderGeometry args={[0.5, 0.5, 3, 32]} />
<meshStandardMaterial color="yellow" />
</mesh>
<mesh position={[0, -2, 0]}>
<sphereGeometry args={[1, 32, 32]} />
<meshStandardMaterial color="yellow" />
</mesh>
<mesh position={[0, 2, 0]}>
<sphereGeometry args={[1, 32, 32]} />
<meshStandardMaterial color="yellow" />
</mesh>
</RigidBody>
<Shape>
When the collision shape should not match what is drawn — a simple hull around a detailed model,
an invisible trigger volume, a hand-tuned capsule for a character — declare it with <Shape>.
It renders nothing; it only describes geometry to Jolt.
import { RigidBody, Shape } from '@react-three/jolt';
<RigidBody position={[0, 5, 0]}>
<Shape type="capsule" radius={0.5} height={2}>
<Shape type="sphere" radius={1} position={[0, 2, 0]} />
<Shape type="sphere" radius={1} position={[0, -2, 0]} />
</Shape>
<mesh>
<capsuleGeometry args={[0.5, 2]} />
<meshStandardMaterial color="yellow" />
</mesh>
</RigidBody>;
A <RigidBody> containing a <Shape> waits for it before creating the body, and the top-level
<Shape> becomes the body's shape. Nesting <Shape> inside <Shape> builds a compound.
| prop | type | |
|---|---|---|
type | ShapeType | default 'box' |
position | number[] | offset within the parent compound. Default [0, 0, 0] |
rotation | [number, number, number] | Euler, radians. Default [0, 0, 0] |
scale | number[] | number | wraps the generated shape in a ScaledShape |
radius | number | sphere, capsule, cylinder, tapered capsule |
height | number | capsule, cylinder, tapered capsule, tapered cylinder |
topRadius | number | tapered capsule / tapered cylinder |
bottomRadius | number | tapered capsule / tapered cylinder; falls back to radius |
convexRadius | number | box, cylinder, tapered cylinder, convex hull |
size | number[] | number | box extents; a number means a cube |
geometry | THREE.BufferGeometry | derive a convex or trimesh shape from geometry |
points / vertices / verts | THREE.Vector3[] | number[] | hull points or mesh vertices |
indices / indexes | number[][] | number[] | trimesh indices |
object / mesh | THREE.Object3D / THREE.Mesh | describe an object instead |
blockSize | number | heightfield block size |
dynamic | boolean | build a mutable compound instead of a static one |
ref | Ref<ShapeHandle> | { descriptor, shape } |
Changing size, radius, height, scale or the children now replaces the shape exactly once
and releases the superseded one; unmounting releases everything the component owns.
dynamic cannot change after the first render — the component throws
Cannot change dynamic prop after initialization.
Mutable compounds
<Shape dynamic> builds a real Jolt MutableCompoundShape, so a child <Shape> mounting,
unmounting or moving edits the live compound in place instead of rebuilding it — and the body is
told, so its mass properties and broadphase bounds follow.
import { RigidBody, Shape } from '@react-three/jolt';
function Growing({ extra }: { extra: boolean }) {
return (
<RigidBody position={[0, 5, 0]}>
<Shape dynamic>
<Shape type="box" size={[1, 1, 1]} />
{extra && <Shape type="sphere" radius={0.5} position={[0, 1, 0]} />}
</Shape>
<mesh>
<boxGeometry args={[1, 1, 1]} />
</mesh>
</RigidBody>
);
}
Changing a child's geometry (its size, radius, type) still rebuilds the whole compound — only position and rotation changes take the in-place path.
The same thing imperatively, on a BodyState:
const index = body.addSubShape({ type: 'sphere', radius: 0.5, position: [0, 1, 0] });
body.modifySubShape(index, { position: [0, 1.5, 0] });
body.removeSubShape(index);
Each of those calls AdjustCenterOfMass() and BodyInterface.NotifyShapeChanged for you.
removeSubShape shifts every higher index down by one, so remove from the back if you are
holding several. Calling them on a body whose shape is a static compound throws a message
telling you to build it from a { type: 'mutableCompound' } descriptor (or a <Shape dynamic>)
instead.
The free functions addSubShape(compound, descriptor, index?), removeSubShape(compound, index)
and modifySubShape(compound, index, transform) do the same to a bare shape, without notifying a
body. isMutableCompoundShape(shape), asMutableCompoundShape(shape), subShapeCount(shape) and
getSubShapeTransform(compound, index) round the set out.
Heightfields
<Heightfield> builds a plane and creates a matching Jolt heightfield body. There are three ways
to say what the terrain looks like, in order of precedence — samples wins over generator,
which wins over url:
import { Heightfield } from '@react-three/jolt';
<Heightfield samples={samples} size={64} scale={[2, 20, 2]} />; // raw samples (#45)
<Heightfield generator={(x, z) => Math.sin(x * 0.1) * 4} size={64} />; // callback (#45)
<Heightfield url="/heightmap.png" size={128} width={256} height={256} />; // image, asynchronous
samples and generator are synchronous: the geometry and the body exist by the time the
component has mounted, so what is drawn and what is simulated are the same numbers. The url
path still loads (and can be superseded or cancelled) asynchronously — changing url cancels the
in-flight load and replaces the body, and at most one heightfield body exists per component at a
time (issue #152).
| prop | type | default | |
|---|---|---|---|
url | string | — | heightmap image; also used as the display texture. Ignored once samples/generator is set |
texture | string | — | use a different texture for display |
width / height | number | 128 | world size of the plane — ignored when samples/generator set scale |
size | number | 256 | samples per edge — see below |
displacementScale | number | 25.6 | maximum height, url path only |
samples | Float32Array | ArrayLike<number> | — | ready-made height samples, size * size numbers, row major (row * size + col) |
generator | (x: number, z: number) => number | — | build the heights from a callback instead of samples/image — see Generation |
scale | [x, y, z] | [1, 1, 1] | distance between samples on x/z and the height multiplier; used by samples/generator. The field is (size - 1) * scale across |
friction | number | — | friction of the whole field (issue #46) |
restitution | number | — | restitution (bounciness) of the whole field |
materials | SurfaceMaterial[] | — | per-quad surface materials — see Surface materials |
materialIndex | (x, z) => number | Uint8Array | ArrayLike<number> | — | which material each quad uses; only meaningful with 2+ materials |
blockSize | number | 2 | Jolt's heightfield block size; size must be a multiple of it |
color | THREE.ColorRepresentation | '#8F2D56' | material colour for the built-in plane material |
position | Vector3Tuple | — |
materialIndex (a callback) and generator are compared by identity, and materials/samples
by value (an inline array literal is fine for materials) — but a function has no value equality,
so a materialIndex/generator written inline creates a new function every render and rebuilds
the body every render. Define it outside the component, or memoize it.
The sample-size rule
size is the number of height samples along each edge, and Jolt is strict about it (this is the
same check describeShape's heightfield path and generateHeightfield use). The grid must be:
- square —
size × sizesamples; - a multiple of the block size (
2by default, theblockSizeprop); - at least two blocks wide, i.e.
size >= 2 * blockSize.
A power of two is the most efficient but is not required. Break the rule and you get a thrown
error naming the offending count rather than a silent failure — the plane is built with
size - 1 segments per edge so that it produces exactly size samples.
Heightfields are heavy. Keep them few and modest, and combine them with ordinary static bodies rather than making one enormous field. Contact events on heightfields are unreliable: they fire per triangle, which confuses the contact bookkeeping, so "stopped touching" may never arrive.
Generation
heightfield/heightfield.ts builds Jolt-ready sample grids without an image, from noise or from
your own callback:
import { generateHeightfield, Heightfield } from '@react-three/jolt';
// deterministic: the same options always produce the same samples, on any machine
const { samples } = generateHeightfield({ size: 64, noise: 'psrd', octaves: 4, seed: 7 });
<Heightfield samples={samples} size={64} scale={[2, 20, 2]} />;
| function | |
|---|---|
generateHeightfield(options) | → { samples, size, min, max }, from noise ('psrd', 'simplex', or your own (x, z) => value) |
samplesFromGenerator(size, generator, spacing?, blockSize?) | → { samples, size, min, max }, sampling any (x, z) => height callback — what <Heightfield generator> uses internally |
heightfieldToGeometry(samples, size, scale?) | → a THREE.PlaneGeometry with size - 1 segments per edge, already rotated flat (+y up), one vertex per sample |
validateHeightfieldSize(size, blockSize?) | the sample-size rule check, thrown or returned |
toSampleArray(samples) | normalise any ArrayLike<number> to the Float32Array Jolt wants |
Both generateHeightfield and generator see field-local world coordinates: (0, 0) is the
centre of the field, x grows with the column index and z with the row index, both scaled by
spacing/scale[0]. That is exactly where the corresponding vertex of heightfieldToGeometry
ends up, so (x, z) => Math.sin(x * 0.1) does what it looks like it does, and feeding that
geometry to bodySystem.addHeightfield (what <Heightfield> does under the hood) round-trips the
same numbers back out — render and physics can't drift apart.
Generation is synchronous, on the calling thread: a 512×512 field with 4 octaves is a handful
of milliseconds; a 2048×2048 one is not, and will drop frames. There is no built-in worker — pass
samples you generated wherever you like (a worker, the server, a previous session) to
<Heightfield samples={...}> and nothing here has to move off thread.
Surface materials
Jolt's HeightFieldShapeSettings carries a PhysicsMaterialList and one material index per
quad ((size - 1)^2 of them, row major), but its JS binding's PhysicsMaterial has no
properties of its own — friction always comes from the two bodies in contact, combined as
sqrt(f1 * f2). SurfaceMaterialTable (heightfield/materials.ts) bridges that gap: it creates
one bare PhysicsMaterial per entry for the shape to index into, and remembers which pointer
means which { friction, restitution, name } so the contact listener can resolve a sub-shape id
back to a friction value synchronously, inside Step(), and write it into that contact's
ContactSettings.mCombinedFriction/mCombinedRestitution — the same combine rules Jolt uses by
default (sqrt for friction, max for restitution), so a material only replaces its side of
the calculation.
import { Heightfield, heightfieldMaterialIndices } from '@react-three/jolt';
const materials = [
{ name: 'ice', friction: 0.01, restitution: 0 },
{ name: 'grip', friction: 2, restitution: 0 }
];
// low friction on -x, high friction on +x - called once per quad, at its centre
const materialIndex = (x: number) => (x < 0 ? 0 : 1);
<Heightfield generator={terrain} size={64} materials={materials} materialIndex={materialIndex} />;
With only one entry in materials, it applies to the whole field and no index map is needed.
heightfieldMaterialIndices(size, source, spacing?) is what <Heightfield> calls internally to
turn a materialIndex callback or array into the Uint8Array Jolt wants — use it directly when
building a body with bodySystem.addHeightfield() yourself. Jolt stores one uint8 per quad, so
at most MAX_SURFACE_MATERIALS (256) materials per heightfield.
Ownership: materials are freed by the shape, not by you. new PhysicsMaterial() starts at
ref-count 0; building the list and assigning it to the shape settings takes it to 2, and
destroying the settings/list drops it back to the shape's own reference. Releasing the shape is
what frees the materials — SurfaceMaterialTable.dispose() only drops JS-side bookkeeping (the
pointer map and a scratch SubShapeID), never the materials themselves. You never call this
yourself for <Heightfield> — BodyState.destroy() disposes the table for you.
friction/restitution passed directly to <Heightfield> (or addHeightfield's options) set
the whole body's values instead, same as any other body — they combine with materials only in
that a quad with no material falls back to the body's own friction/restitution.
Helper floors
import { MeshFloor } from '@react-three/jolt';
import { Floor } from '@react-three/jolt/addons';
<Floor size={40} position={[0, -1, 0]} />; // a static box RigidBody
<MeshFloor size={20} />; // a bumpy Jolt mesh floor
<Floor> (addons) is a plain static <RigidBody> with a box — what you want most of the time.
<MeshFloor> builds a Jolt mesh floor body directly and derives the three.js geometry from the
resulting shape; it is also a readable example of talking to Jolt by hand.
The shape pipeline
Everything above is built on one pipeline. Three overlapping entry points used to each have their
own idea of how a three.js geometry maps onto a Jolt shape; they are now thin wrappers over
describeShape → generateShape.
import { describeShape, generateShape, releaseShape } from '@react-three/jolt';
import * as THREE from 'three';
const geometry = new THREE.CapsuleGeometry(0.5, 2);
// 1. describe: a plain object. Allocates nothing on the WASM heap and survives JSON.stringify.
const descriptor = describeShape(geometry);
// { type: 'capsule', radius: 0.5, height: 2, offset: [0, 0, 0] }
// 2. generate: a Jolt.Shape you own, with a reference count of 1
const shape = generateShape(descriptor);
// 3. release when you're done
releaseShape(shape);
| function | |
|---|---|
describeShape(objectOrGeometry, options?) | → ShapeDescriptor |
describeGeometry(geometry, options?) / describeObject(object, options?) | the two halves of it |
describeShapeFromOptions(type?, options?) | build a descriptor from <Shape>-style options |
generateShape(descriptor) | → Jolt.Shape, caller owned, released with releaseShape |
createShapeSettings(descriptor) | → Jolt.ShapeSettings, for compound children and body creation |
createShapeFromSettings(settings, destroySettings?) | realise settings you already have |
releaseShape(shape) | drop your reference |
descriptorKey(descriptor) / stableKey(value) | a stable string identity (long vertex arrays are hashed) |
scaleShape(shape, scale) | wrap in a ScaledShape; AddRefs the inner shape and hands back one reference |
validScaleFor(shape, scale) | the nearest scale Jolt will accept for that shape |
descriptorKey is what <Shape>'s effects depend on, which is why passing equal props does not
rebuild anything.
describeShape options:
| option | |
|---|---|
type | force a shape type instead of inferring one |
convexRadius | for the box/cylinder paths; clamped to what Jolt accepts |
blockSize | heightfield block size |
applyObjectScale | bake the root object's own scale in too (default false — see Scaled meshes) |
ShapeDescriptor
A serialisable description: a type tag, its size parameters, an optional local
position/rotation (used when it is a compound child; a root descriptor is positioned by the
body), an informational offset, and an optional userData.
| tag | fields |
|---|---|
box | size (full extents), convexRadius? |
sphere | radius |
capsule | radius, height (cylindrical section, excluding the caps) |
taperedCapsule | height, topRadius, bottomRadius |
cylinder | radius, height (full), convexRadius? |
taperedCylinder | height, topRadius, bottomRadius, convexRadius? |
convex | points (flat [x, y, z, …]), convexRadius? |
trimesh | vertices, indices (both flat) |
heightfield | heights, sampleCount, scale, blockSize? |
staticCompound | children: ShapeDescriptor[] |
mutableCompound | children: ShapeDescriptor[] — editable at runtime, see above |
scaled | child, scale |
offsetCenterOfMass | child, centerOfMass |
The tags are convex and trimesh — there is no convexHull or mesh. compound is accepted
everywhere too, as a documented alias normalised to staticCompound (see ShapeType).
offsetCenterOfMass moves a shape's centre of mass without moving the shape: the classic
self-righting "weeble", and how you stop a vehicle or a character tipping over.
const weeble = generateShape({
type: 'offsetCenterOfMass',
child: { type: 'sphere', radius: 1 },
centerOfMass: [0, -0.7, 0]
});
An unknown tag throws naming the tag, rather than producing a NaN-sized shape.
Scaling
BodyState.scale takes a THREE.Vector3, an array or a plain number, and wraps the body's
shape in a ScaledShape:
body.scale = 2; // uniform
body.scale = [2, 1, 2]; // wherever Jolt allows it
Re-scaling replaces the wrapper rather than stacking another one, and setting the scale it already
has is a no-op. A sphere, capsule or tapered capsule has a single radius and cannot be scaled
non-uniformly; asking for it falls back to a uniform scale of the largest component with a
devWarn. <RigidBody scale> goes through the same path, applied
while the body is created.
Building shapes yourself
The older entry points still work unchanged and are the shortest route when you already know the type:
import { createShapeFromSettings, generateShapeSettings, releaseShape } from '@react-three/jolt';
const settings = generateShapeSettings('sphere', { radius: 0.5 });
const shape = createShapeFromSettings(settings); // frees `settings`, takes a reference for you
// ... later
releaseShape(shape);
createShapeFromSettings realises the shape, takes a reference on it, destroys the settings, and
throws with Jolt's own error message if creation failed — the manual settings.Create().Get()
dance leaks, and silently hands back an invalid shape on error. Bodies created through
bodySystem own their shape and release it when the body is removed; a shape you make yourself
is yours to releaseShape.
getShapeSettingsFromGeometry, getShapeSettingsFromObject, generateShapeSettings and
generateCompoundShapeSettings are all wrappers over the pipeline now, and keep their old
behaviour.
See Memory & lifecycle for the rules behind that.