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 { Canvas } from '@threlte/core'
  import { World } from '@threlte/rapier'
  import Scene from './Scene.svelte'
  import { Pane, Button } from 'svelte-tweakpane-ui'

  let reset: () => any | undefined
  let toggleDebug: () => any | undefined
</script>

<Pane
  title=""
  position="fixed"
>
  <Button
    title="Reset"
    on:click={reset}
  />
  <Button
    title="Toggle Debug"
    on:click={toggleDebug}
  />
</Pane>

<div>
  <Canvas>
    <World>
      <Scene
        bind:reset
        bind:toggleDebug
      />
    </World>
  </Canvas>
</div>

<style>
  div {
    height: 100%;
  }
</style>
<script lang="ts">
  import { T } from '@threlte/core'
  import { Collider, RigidBody, AutoColliders } from '@threlte/rapier'
  import { BoxGeometry, SphereGeometry, CylinderGeometry, ConeGeometry } from 'three'

  const radius = 0.25
  const shapes = [
    {
      geometry: new BoxGeometry(radius, radius, radius),
      autoCollider: 'cuboid',
      color: 'hotpink'
    },
    {
      geometry: new SphereGeometry(radius),
      autoCollider: 'ball',
      color: 'cyan'
    },
    {
      geometry: new CylinderGeometry(radius, radius, radius * 2),
      autoCollider: 'convexHull',
      color: 'green'
    },
    {
      geometry: new ConeGeometry(radius, radius * 3, 10),
      autoCollider: 'convexHull',
      color: 'orange'
    }
  ]

  const bodies = new Array(50).fill(0).map((_, index) => {
    const position: Parameters<Vector3['set']> = [
      Math.random() * 5 - 2.5,
      Math.random() * 5,
      Math.random() * 5 - 2.5
    ]
    const rotation: Parameters<Euler['set']> = [
      Math.random() * 10,
      Math.random() * 10,
      Math.random() * 10
    ]
    const shape = shapes[Math.floor(Math.random() * shapes.length)]

    return {
      id: index,
      position,
      rotation,
      ...shape
    }
  })
</script>

{#each bodies as body (body.id)}
  <T.Group
    position={body.position}
    rotation={body.rotation}
  >
    <RigidBody type={'dynamic'}>
      <AutoColliders shape={body.autoCollider}>
        <T.Mesh
          castShadow
          receiveShadow
          geometry={body.geometry}
        >
          <T.MeshStandardMaterial color={body.color} />
        </T.Mesh>
      </AutoColliders>
    </RigidBody>
  </T.Group>
{/each}
<script lang="ts">
  import { PlaneGeometry, MeshStandardMaterial } from 'three'
  import { DEG2RAD } from 'three/src/math/MathUtils.js'
  import { createNoise2D } from 'simplex-noise'
  import { T } from '@threlte/core'
  import { OrbitControls } from '@threlte/extras'
  import RAPIER from '@dimforge/rapier3d-compat'
  import { Collider, Debug, RigidBody } from '@threlte/rapier'
  import FallingShapes from './FallingShapes.svelte'

  let nsubdivs = 10
  let heights = []

  const geometry = new PlaneGeometry(10, 10, nsubdivs, nsubdivs)

  const noise = createNoise2D()
  const vertices = geometry.getAttribute('position').array

  for (let x = 0; x <= nsubdivs; x++) {
    for (let y = 0; y <= nsubdivs; y++) {
      let height = noise(x, y)
      const vertIndex = (x + (nsubdivs + 1) * y) * 3

      //@ts-ignore
      vertices[vertIndex + 2] = height
      const heightIndex = y + (nsubdivs + 1) * x
      heights[heightIndex] = height
    }
  }

  // needed for lighting
  geometry.computeVertexNormals()

  const scale = new RAPIER.Vector3(10.0, 1, 10)

  let resetCounter = 0
  export const reset = () => {
    resetCounter += 1
  }

  let debugEnabled = false
  export const toggleDebug = () => {
    debugEnabled = !debugEnabled
  }
</script>

<T.PerspectiveCamera
  makeDefault
  position.y={10}
  position.z={10}
  lookAt.y={0}
>
  <OrbitControls enableZoom={true} />
</T.PerspectiveCamera>

<T.DirectionalLight position={[3, 10, 10]} />
<T.HemisphereLight intensity={0.2} />

{#key resetCounter}
  <FallingShapes />
{/key}

<T.Mesh
  receiveShadow
  {geometry}
  rotation.x={DEG2RAD * -90}
  rotation.z={DEG2RAD * 0}
>
  <T.MeshStandardMaterial
    color="teal"
    opacity="0.8"
    transparent
  />
</T.Mesh>
<RigidBody type={'fixed'}>
  <Collider
    shape={'heightfield'}
    args={[nsubdivs, nsubdivs, heights, scale]}
  />
</RigidBody>

{#if debugEnabled === true}
  <Debug />
{/if}

How does it work

  1. Similar to the 3D noise example, loop over the vertices of a PlaneGeometry, and use a noise map to create a heightfield array heights.
  2. Attach the heightfield to a rapier <Collider>
<Collider
  shape={'heightfield'}
  args={[nsubdivs, nsubdivs, heights, scale]}
/>
  1. Wrap in a fixed <RigidBody>. We don’t want this terrain to respond to gravity and fall downwards when rapier physics simulation begins.
<RigidBody type={'fixed'}>
  <Collider ... />
</RigidBody>
  1. Add some falling random shapes to the scene to prove that the terrain is functioning properly (see “FallingShapes.svelte”)
  • define some shapes in an array

    • geometry
    • <AutoCollider> type
    • color
  • fill an array of 50 items with a random shape, with a random position and rotation, and loop over it in markup

  • add a <T.Mesh> to the scene, provide it with the chosen geometry, and a material with the chosen color

  • wrap that in an <AutoColliders> with the chosen AutoCollider

  • wrap in a 'dynamic' <RigidBody>. Dynamic because we want these shapes to fall in the scene according to gravity.

  • finally, wrap in a <T.Group> to set our random position and rotation