threlte logo

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.