threlte logo

Animating a Spaceship

This tutorial demonstrates how to load and animate a spaceship model, as well as using Threlte’s InstancedMesh to efficiently animate hundreds of stars. We’ll also cover raycaster intersections, post-processing effects, and dynamically generated reflection maps.

<script>
  import { Canvas } from '@threlte/core'
  import Scene from './Scene.svelte'
</script>

<div>
  <Canvas>
    <Scene />
  </Canvas>
</div>

<style>
  div {
    width: 100%;
    height: 100%;
  }
</style>
<script>
  import { T, useRender, useThrelte } from '@threlte/core'
  import { OrbitControls } from '@threlte/extras'
  import Spaceship from './models/spaceship.svelte'
  import { Color, Mesh, PMREMGenerator, PlaneGeometry, Raycaster, Vector2, Vector3 } from 'three'
  import { onMount } from 'svelte'
  import Stars from './Stars.svelte'
  import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'
  import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'
  import { OutputPass } from 'three/addons/postprocessing/OutputPass.js'
  import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'

  const { scene, camera, renderer } = useThrelte()
  let spaceShipRef
  let intersectionPoint
  let translY = 0
  let translAccelleration = 0
  let angleZ = 0
  let angleAccelleration = 0
  let pmrem = new PMREMGenerator(renderer)
  let envMapRT

  const composer = new EffectComposer(renderer)
  composer.setSize(innerWidth, innerHeight)

  const setupEffectComposer = () => {
    const renderPass = new RenderPass(scene, camera.current)
    composer.addPass(renderPass)

    const bloomPass = new UnrealBloomPass(new Vector2(innerWidth, innerHeight), 0.275, 1, 0)
    composer.addPass(bloomPass)

    const outputPass = new OutputPass()
    composer.addPass(outputPass)
  }

  // takes control of the render loop, unlike useFrame
  // https://threlte.xyz/docs/reference/core/use-render
  useRender(({ scene }) => {
    if (intersectionPoint) {
      const targetY = intersectionPoint?.y || 0
      translAccelleration += (targetY - translY) * 0.002 // stiffness
      translAccelleration *= 0.95 // damping
      translY += translAccelleration

      const dir = intersectionPoint
        .clone()
        .sub(new Vector3(0, translY, 0))
        .normalize()
      const dirCos = dir.dot(new Vector3(0, 1, 0))
      const angle = Math.acos(dirCos) - Math.PI * 0.5
      angleAccelleration += (angle - angleZ) * 0.01 // stiffness
      angleAccelleration *= 0.85 // damping
      angleZ += angleAccelleration
    }

    if (envMapRT) envMapRT.dispose()

    spaceShipRef.visible = false
    scene.background = null
    envMapRT = pmrem.fromScene(scene, 0, 0.1, 1000)
    scene.background = new Color('#598889').multiplyScalar(0.05)
    spaceShipRef.visible = true

    spaceShipRef.traverse((child) => {
      if (child?.material?.envMapIntensity) {
        child.material.envMap = envMapRT.texture
        child.material.envMapIntensity = 100
        child.material.normalScale.set(0.3, 0.3)
      }
    })

    composer.render()
  })

  onMount(() => {
    setupEffectComposer()

    const planeGeo = new PlaneGeometry(20, 20)
    const mesh = new Mesh(planeGeo)

    const raycaster = new Raycaster()
    const pointer = new Vector2()

    function onPointerMove(event) {
      pointer.x = (event.clientX / window.innerWidth) * 2 - 1
      pointer.y = -(event.clientY / window.innerHeight) * 2 + 1

      raycaster.setFromCamera(pointer, $camera)
      const intersects = raycaster.intersectObject(mesh)
      intersectionPoint = intersects[0]?.point

      if (intersectionPoint) {
        // this prevents the spring motion to be different while the pointer
        // spans the x axis
        intersectionPoint.x = 3
      }
    }

    window.addEventListener('pointermove', onPointerMove)
    return () => {
      window.removeEventListener('pointermove', onPointerMove)
    }
  })
</script>

<T.PerspectiveCamera
  makeDefault
  position={[-5, 6, 10]}
  fov={25}
>
  <OrbitControls
    enableDamping
    target={[0, 0, 0]}
  />
</T.PerspectiveCamera>

<T.DirectionalLight
  intensity={1.8}
  position={[0, 10, 0]}
  castShadow
  shadow.bias={-0.0001}
/>

<Spaceship
  bind:ref={spaceShipRef}
  position={[0, translY, 0]}
  rotation={[angleZ, 0, angleZ, 'ZXY']}
/>

<Stars />
<script>
  import { T, useFrame } from '@threlte/core'
  import { Instance, InstancedMesh, useTexture } from '@threlte/extras'
  import { Color, DoubleSide, Vector3 } from 'three'

  let STARS_COUNT = 350
  let colors = ['#fcaa67', '#C75D59', '#ffffc7', '#8CC5C6', '#A5898C']
  let stars = []

  const map = useTexture('/spaceship-tutorial/textures/star.png')

  function r(min, max) {
    let diff = Math.random() * (max - min)
    return min + diff
  }

  function resetStar(star) {
    if (r(0, 1) > 0.8) {
      star.pos = new Vector3(r(-10, -30), r(-5, 5), r(6, -6))
      star.len = r(1.5, 15)
    } else {
      star.pos = new Vector3(r(-15, -45), r(-10.5, 1.5), r(30, -45))
      star.len = r(2.5, 20)
    }

    star.speed = r(19.5, 42)
    star.rad = r(0.04, 0.07)
    star.color = new Color(colors[Math.floor(Math.random() * colors.length)])
      .convertSRGBToLinear()
      .multiplyScalar(1.3)

    return star
  }

  for (let i = 0; i < STARS_COUNT; i++) {
    let star = {
      pos: null,
      len: null,
      speed: null,
      color: null
    }

    stars.push(resetStar(star))
  }

  useFrame((_, delta) => {
    stars.forEach((star) => {
      star.pos.x += star.speed * delta
      if (star.pos.x > 40) resetStar(star)
    })
    stars = stars
  })
</script>

{#await map then value}
  <InstancedMesh
    limit={STARS_COUNT}
    range={STARS_COUNT}
  >
    <T.PlaneGeometry args={[1, 0.05]} />
    <T.MeshBasicMaterial
      side={DoubleSide}
      alphaMap={value}
      transparent
    />

    {#each stars as star}
      <Instance
        position={[star.pos.x, star.pos.y, star.pos.z]}
        scale={[star.len, 1, 1]}
        color={star.color}
      />
    {/each}
  </InstancedMesh>
{/await}
https://sketchfab.com/3d-models/rusty-spaceship-orange-18541ebed6ce44a9923f9b8dc30d87f5
<!--
Auto-generated by: https://github.com/threlte/threlte/tree/main/packages/gltf
Command: npx @threlte/gltf@2.0.0 C:\Users\Utente\Desktop\Trasferimento-PC\Projects\Youtube\Threlte\spaceship-header\static\models\spaceship.glb --root /models/ --printwidth 120 --precision 2
Author: Sousinho (https://sketchfab.com/sousinho)
License: CC-BY-4.0 (http://creativecommons.org/licenses/by/4.0/)
Source: https://sketchfab.com/3d-models/rusty-spaceship-orange-18541ebed6ce44a9923f9b8dc30d87f5
Title: Rusty Spaceship - Orange
-->
<script>
  import { AddEquation, CustomBlending, Group, LessEqualDepth, OneFactor } from 'three'
  import { T, forwardEventHandlers } from '@threlte/core'
  import { useGltf } from '@threlte/extras'
  import { useTexture } from '@threlte/extras'

  export const ref = new Group()

  const gltf = useGltf('/spaceship-tutorial/models/spaceship.glb')
  const map = useTexture('/spaceship-tutorial/textures/energy-beam-opacity.png')

  gltf.then((model) => {
    function alphaFix(material) {
      material.transparent = true
      material.alphaToCoverage = true
      material.depthFunc = LessEqualDepth
      material.depthTest = true
      material.depthWrite = true
    }
    alphaFix(model.materials.spaceship_racer)
    alphaFix(model.materials.cockpit)
  })

  const component = forwardEventHandlers()
</script>

<T
  is={ref}
  dispose={false}
  {...$$restProps}
  bind:this={$component}
>
  {#await gltf}
    <slot name="fallback" />
  {:then gltf}
    <T.Group
      scale={0.003}
      rotation={[0, -Math.PI * 0.5, 0]}
      position={[0.95, 0, -2.235]}
    >
      <T.Mesh
        castShadow
        receiveShadow
        geometry={gltf.nodes.Cube001_spaceship_racer_0.geometry}
        material={gltf.materials.spaceship_racer}
        position={[739.26, -64.81, 64.77]}
      />
      <T.Mesh
        castShadow
        receiveShadow
        geometry={gltf.nodes.Cylinder002_spaceship_racer_0.geometry}
        material={gltf.materials.spaceship_racer}
        position={[739.69, -59.39, -553.38]}
        rotation={[Math.PI / 2, 0, 0]}
      />
      <T.Mesh
        castShadow
        receiveShadow
        geometry={gltf.nodes.Cylinder003_spaceship_racer_0.geometry}
        material={gltf.materials.spaceship_racer}
        position={[742.15, -64.53, -508.88]}
        rotation={[Math.PI / 2, 0, 0]}
      />
      <T.Mesh
        castShadow
        receiveShadow
        geometry={gltf.nodes.Cube003_spaceship_racer_0.geometry}
        material={gltf.materials.spaceship_racer}
        position={[737.62, 46.84, -176.41]}
      />
      <T.Mesh
        castShadow
        receiveShadow
        geometry={gltf.nodes.Cylinder004_spaceship_racer_0.geometry}
        material={gltf.materials.spaceship_racer}
        position={[789.52, 59.45, -224.91]}
        rotation={[1, 0, 0]}
      />
      <T.Mesh
        castShadow
        receiveShadow
        geometry={gltf.nodes.Cube001_RExtr001_spaceship_racer_0.geometry}
        material={gltf.materials.spaceship_racer}
        position={[745.54, 159.32, -5.92]}
      />
      <T.Mesh
        castShadow
        receiveShadow
        geometry={gltf.nodes.Cube001_RPanel003_spaceship_racer_0.geometry}
        material={gltf.materials.spaceship_racer}
        position={[739.26, 0, 0]}
      />
      <T.Mesh
        castShadow
        receiveShadow
        geometry={gltf.nodes.Cube001_RPanel003_RExtr_spaceship_racer_0.geometry}
        material={gltf.materials.spaceship_racer}
        position={[739.26, 0, 0]}
      />
      <T.Mesh
        castShadow
        receiveShadow
        geometry={gltf.nodes.Cube002_spaceship_racer_0.geometry}
        material={gltf.materials.spaceship_racer}
        position={[736.79, -267.14, -33.21]}
      />
      <T.Mesh
        castShadow
        receiveShadow
        geometry={gltf.nodes.Cube001_RPanel001_spaceship_racer_0.geometry}
        material={gltf.materials.spaceship_racer}
        position={[739.26, 0, 0]}
      />
      <T.Mesh
        castShadow
        receiveShadow
        geometry={gltf.nodes.Cube001_RPanel003_RExtr001_spaceship_racer_0.geometry}
        material={gltf.materials.spaceship_racer}
        position={[739.26, 0, 0]}
      />
      <T.Mesh
        geometry={gltf.nodes.Cube005_cockpit_0.geometry}
        material={gltf.materials.cockpit}
        position={[739.45, 110.44, 307.18]}
        rotation={[0.09, 0, 0]}
      />
      <T.Mesh
        geometry={gltf.nodes.Sphere_cockpit_0.geometry}
        material={gltf.materials.cockpit}
        position={[739.37, 145.69, 315.6]}
        rotation={[0.17, 0, 0]}
      />
      {#await map then mapValue}
        <T.Mesh
          position={[740, -60, -1350]}
          rotation.x={Math.PI * 0.5}
        >
          <T.CylinderGeometry args={[70, 25, 1600, 15]} />
          <T.MeshBasicMaterial
            color={[1.0, 0.4, 0.02]}
            alphaMap={mapValue}
            transparent
            blending={CustomBlending}
            blendDst={OneFactor}
            blendEquation={AddEquation}
          />
        </T.Mesh>
      {/await}
    </T.Group>
  {:catch error}
    <slot
      name="error"
      {error}
    />
  {/await}

  <slot {ref} />
</T>

Part I

Part II

The second part of the tutorial focuses on applying a spring-based animation to the spaceship model by leveraging useFrame, a Threlte 6 hook used to run a callback on every frame. Threlte 7 improved the task scheduling API by introducing useTask, for newer Threlte projects it’s recommended to replace the useFrame line shown in the video with the updated API.

Threlte 6
useFrame(() => {
  ...
})
Threlte 7
useTask(() => {
  ...
})

Part III

In this last portion of the tutorial we’ll introduce post-processing effects that require control over the render loop, and similiarly to episode 2 the video relies on useRender, a Threlte 6 hook used to manually render a scene. The equivalent Threlte 7 logic adds a task to Threlte’s default renderStage

Threlte 6
const { scene, camera, renderer } = useThrelte()

useRender(() => {
  // render here
})
Threlte 7
const { scene, camera, renderer, renderStage } = useThrelte()

useTask(
  () => {
    // render here
  },
  { stage: renderStage, autoInvalidate: false }
)