Shapes

Automatic shape detection, the Shape component, compound shapes and heightfields.

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:

geometryshape
BoxGeometrybox (from the geometry's own parameters)
SphereGeometrysphere (bounding sphere radius)
CapsuleGeometrycapsule
CylinderGeometrycylinder
anything elseconvex 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:

valuebehaviour
'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.

proptype
typeShapeTypedefault 'box'
positionnumber[]offset within the parent compound. Default [0, 0, 0]
rotation[number, number, number]Euler, radians. Default [0, 0, 0]
scalenumber[] | numberwraps the generated shape in a ScaledShape
radiusnumbersphere, capsule, cylinder, tapered capsule
heightnumbercapsule, cylinder, tapered capsule, tapered cylinder
topRadiusnumbertapered capsule / tapered cylinder
bottomRadiusnumbertapered capsule / tapered cylinder; falls back to radius
convexRadiusnumberbox, cylinder, tapered cylinder, convex hull
sizenumber[] | numberbox extents; a number means a cube
geometryTHREE.BufferGeometryderive a convex or trimesh shape from geometry
points / vertices / vertsTHREE.Vector3[] | number[]hull points or mesh vertices
indices / indexesnumber[][] | number[]trimesh indices
object / meshTHREE.Object3D / THREE.Meshdescribe an object instead
blockSizenumberheightfield block size
dynamicbooleanbuild a mutable compound instead of a static one
refRef<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.

Warning

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).

proptypedefault
urlstring—heightmap image; also used as the display texture. Ignored once samples/generator is set
texturestring—use a different texture for display
width / heightnumber128world size of the plane — ignored when samples/generator set scale
sizenumber256samples per edge — see below
displacementScalenumber25.6maximum height, url path only
samplesFloat32Array | 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
frictionnumber—friction of the whole field (issue #46)
restitutionnumber—restitution (bounciness) of the whole field
materialsSurfaceMaterial[]—per-quad surface materials — see Surface materials
materialIndex(x, z) => number | Uint8Array | ArrayLike<number>—which material each quad uses; only meaningful with 2+ materials
blockSizenumber2Jolt's heightfield block size; size must be a multiple of it
colorTHREE.ColorRepresentation'#8F2D56'material colour for the built-in plane material
positionVector3Tuple—

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 × size samples;
  • a multiple of the block size (2 by default, the blockSize prop);
  • 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.

Caution

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.

Note

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
typeforce a shape type instead of inferring one
convexRadiusfor the box/cylinder paths; clamped to what Jolt accepts
blockSizeheightfield block size
applyObjectScalebake 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.

tagfields
boxsize (full extents), convexRadius?
sphereradius
capsuleradius, height (cylindrical section, excluding the caps)
taperedCapsuleheight, topRadius, bottomRadius
cylinderradius, height (full), convexRadius?
taperedCylinderheight, topRadius, bottomRadius, convexRadius?
convexpoints (flat [x, y, z, …]), convexRadius?
trimeshvertices, indices (both flat)
heightfieldheights, sampleCount, scale, blockSize?
staticCompoundchildren: ShapeDescriptor[]
mutableCompoundchildren: ShapeDescriptor[] — editable at runtime, see above
scaledchild, scale
offsetCenterOfMasschild, centerOfMass
Note

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.