Terrain with Rapier physics
This example shows how to include user-generated random terrain as a fixed <RigidBody>
, within a Rapier world.
This is an adaption of Rapier’s own demo (select “Demo: triangle mesh”).
<script lang="ts">
import Scene from './Scene.svelte'
import { Button, Pane } from 'svelte-tweakpane-ui'
import { Canvas } from '@threlte/core'
import { World } from '@threlte/rapier'
let resetCounter = $state(0)
let showDebug = $state(false)
</script>
<Pane
title=""
position="fixed"
>
<Button
title="Reset"
on:click={() => {
resetCounter += 1
}}
/>
<Button
title="Toggle Debug"
on:click={() => {
showDebug = !showDebug
}}
/>
</Pane>
<div>
<Canvas>
<World>
<Scene
{resetCounter}
{showDebug}
/>
</World>
</Canvas>
</div>
<style>
div {
height: 100%;
}
</style>
<script
lang="ts"
module
>
const radius = 0.25
const cuboid: Shape = {
autoCollider: 'cuboid',
color: 'hotpink',
geometry: new BoxGeometry(radius, radius, radius)
}
const shapes: Shape[] = [
cuboid,
{
autoCollider: 'ball',
color: 'cyan',
geometry: new SphereGeometry(radius)
},
{
autoCollider: 'convexHull',
color: 'green',
geometry: new CylinderGeometry(radius, radius, radius * 2)
},
{
autoCollider: 'convexHull',
color: 'orange',
geometry: new ConeGeometry(radius, radius * 3, 10)
}
]
const getRandomShape = (defaultShape = cuboid): Shape => {
return shapes[Math.floor(Math.random() * shapes.length)] ?? defaultShape
}
</script>
<script lang="ts">
import type { AutoCollidersShapes } from '@threlte/rapier'
import type { BufferGeometry, ColorRepresentation } from 'three'
import type { Snippet } from 'svelte'
import { AutoColliders, RigidBody } from '@threlte/rapier'
import { BoxGeometry, ConeGeometry, CylinderGeometry, SphereGeometry, Vector3 } from 'three'
import { T } from '@threlte/core'
type Shape = {
autoCollider: AutoCollidersShapes
color: ColorRepresentation
geometry: BufferGeometry
}
let { children }: { children?: Snippet<[{ shape: Shape }]> } = $props()
const offset = new Vector3(-2.5, 2.5, -2.5)
const createPosition = (scalar = 5): Vector3 => {
return new Vector3().random().multiplyScalar(scalar).add(offset)
}
const createRotation = (scalar = 10): Vector3 => {
return new Vector3().random().multiplyScalar(scalar)
}
type Body = {
position: Vector3
rotation: Vector3
}
const bodies: Body[] = []
const count = 50
for (let i = 0; i < count; i += 1) {
const position = createPosition()
const rotation = createRotation()
bodies.push({
position,
rotation
})
}
</script>
{#each bodies as body}
{@const shape = getRandomShape()}
<T.Group
position={body.position.toArray()}
rotation={body.rotation.toArray()}
>
<RigidBody type="dynamic">
<AutoColliders shape={shape.autoCollider}>
{@render children?.({ shape })}
</AutoColliders>
</RigidBody>
</T.Group>
{/each}
<script lang="ts">
import FallingShapes from './FallingShapes.svelte'
import RAPIER from '@dimforge/rapier3d-compat'
import { Collider, Debug, RigidBody } from '@threlte/rapier'
import { DEG2RAD } from 'three/src/math/MathUtils.js'
import { DoubleSide, PlaneGeometry } from 'three'
import { Environment, OrbitControls } from '@threlte/extras'
import { SimplexNoise } from 'three/examples/jsm/Addons.js'
import { T } from '@threlte/core'
let { resetCounter = 0, showDebug = false }: { resetCounter?: number; showDebug?: boolean } =
$props()
const heights: number[] = []
const nsubdivs = 10
const size = 10
const geometry = new PlaneGeometry(size, size, nsubdivs, nsubdivs)
const noise = new SimplexNoise()
const positions = geometry.getAttribute('position').array
for (let x = 0; x <= nsubdivs; x++) {
for (let y = 0; y <= nsubdivs; y++) {
const height = noise.noise(x / 4, y / 4)
const vertIndex = (x + (nsubdivs + 1) * y) * 3
positions[vertIndex + 2] = height
const heightIndex = y + (nsubdivs + 1) * x
heights[heightIndex] = height
}
}
// needed for lighting
geometry.computeVertexNormals()
const scale = new RAPIER.Vector3(size, 1, size)
</script>
<T.PerspectiveCamera
makeDefault
position.y={10}
position.z={10}
>
<OrbitControls />
</T.PerspectiveCamera>
{#key resetCounter}
<FallingShapes>
{#snippet children({ shape })}
<T.Mesh
castShadow
receiveShadow
geometry={shape.geometry}
>
<T.MeshStandardMaterial color={shape.color} />
</T.Mesh>
{/snippet}
</FallingShapes>
{/key}
<Environment url="/textures/equirectangular/hdr/shanghai_riverside_1k.hdr" />
<T.Mesh
receiveShadow
{geometry}
rotation.x={DEG2RAD * -90}
>
<T.MeshStandardMaterial
color="teal"
opacity={0.8}
transparent
side={DoubleSide}
/>
</T.Mesh>
<RigidBody type="fixed">
<Collider
shape="heightfield"
args={[nsubdivs, nsubdivs, heights, scale]}
/>
</RigidBody>
{#if showDebug}
<Debug />
{/if}
How Does It Work
A heightmap is generated by looping over the vertices of a plane geometry. The height information is passed to a <Collider>
and wrapped in a <RigidBody>
.
<RigidBody type="fixed">
<Collider
shape="heightfield"
args={[nsubdivs, nsubdivs, heights, scale]}
/>
</RigidBody>
Giving the <RigidBody>
a "fixed"
type prevents it from being affected by gravity.
In the <FallingShapes>
component, a bunch of random geometries are positioned above the plane and dropped. Each geometry is wrapped in a <RigidBody>
with a type of "dynamic"
. This will cause it to be affected by gravity.