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 autoRender={false}>
    <Scene />
  </Canvas>
</div>

<style>
  div {
    width: 100%;
    height: 100%;
  }
</style>
<script lang="ts">
  import { T, useTask, useThrelte } from '@threlte/core'
  import { OrbitControls } from '@threlte/extras'
  import Spaceship from './models/spaceship.svelte'
  import {
    Color,
    type Group,
    Mesh,
    MeshStandardMaterial,
    PMREMGenerator,
    PlaneGeometry,
    Raycaster,
    Vector2,
    Vector3,
    WebGLRenderTarget
  } from 'three'
  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, size, camera, renderer } = useThrelte()

  let intersectionPoint: Vector3 | undefined
  let translAccelleration = 0
  let angleAccelleration = 0
  let pmrem = new PMREMGenerator(renderer)
  let envMapRT: WebGLRenderTarget

  let spaceShipRef = $state<Group>()
  let translY = $state(0)
  let angleZ = $state(0)

  const composer = new EffectComposer(renderer)
  const renderPass = new RenderPass(scene, $camera)
  const bloomPass = new UnrealBloomPass(new Vector2($size.width, $size.height), 0.275, 1, 0)
  const outputPass = new OutputPass()

  composer.addPass(renderPass)
  composer.addPass(bloomPass)
  composer.addPass(outputPass)

  $effect(() => {
    composer.setSize($size.width, $size.height)
    bloomPass.resolution.set($size.width, $size.height)
  })

  $effect(() => {
    renderPass.camera = $camera
  })

  // Replaces the default render task, which does not execute because autoRender=false
  // https://threlte.xyz/docs/learn/basics/render-modes#render-modes-and-custom-rendering
  const { renderStage } = useThrelte()
  useTask(
    () => {
      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()
      }

      if (spaceShipRef) {
        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 ('material' in child) {
            const material = child.material as MeshStandardMaterial
            if ('envMapIntensity' in material) {
              material.envMap = envMapRT.texture
              material.envMapIntensity = 100
              material.normalScale.set(0.3, 0.3)
            }
          }
        })
      }

      composer.render()
    },
    {
      stage: renderStage
    }
  )

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

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

  function onpointermove(event: PointerEvent) {
    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
    }
  }
</script>

<svelte:window {onpointermove} />

<T.PerspectiveCamera
  makeDefault
  position={[-10, 6, 15]}
  fov={25}
>
  <OrbitControls
    enableDamping
    enableZoom={false}
    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 lang="ts">
  import { T, useTask } from '@threlte/core'
  import { Instance, InstancedMesh, useTexture } from '@threlte/extras'
  import { Color, DoubleSide, MathUtils, type Vector3Tuple } from 'three'

  let STARS_COUNT = 350
  let colors = ['#fcaa67', '#C75D59', '#ffffc7', '#8CC5C6', '#A5898C'] as const
  let stars = $state<Star[]>([])

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

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

  interface Star {
    id: string
    position: Vector3Tuple
    length: number
    speed: number
    color: Color
  }

  function resetStar(star: Star) {
    if (r(0, 1) > 0.8) {
      star.position = [r(-10, -30), r(-5, 5), r(6, -6)]
      star.length = r(1.5, 15)
    } else {
      star.position = [r(-15, -45), r(-10.5, 1.5), r(30, -45)]
      star.length = r(2.5, 20)
    }

    star.speed = r(19.5, 42)
    star.color
      .set(colors[Math.floor(Math.random() * colors.length)] ?? 'white')
      .convertSRGBToLinear()
      .multiplyScalar(1.3)
  }

  for (let i = 0; i < STARS_COUNT; i++) {
    const star: Star = {
      id: MathUtils.generateUUID(),
      position: [0, 0, 0],
      length: 0,
      speed: 0,
      color: new Color()
    }

    resetStar(star)
    stars.push(star)
  }

  useTask((delta) => {
    for (const star of stars) {
      star.position[0] += star.speed * delta
      if (star.position[0] > 40) {
        resetStar(star)
      }
    }
  })
</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 { id, position, length, color } (id)}
      <Instance
        {position}
        scale={[length, 1, 1]}
        {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 lang="ts">
  import type { Snippet } from 'svelte'
  import { AddEquation, CustomBlending, Group, LessEqualDepth, Material, OneFactor } from 'three'
  import { T } from '@threlte/core'
  import { useGltf, useTexture } from '@threlte/extras'

  interface Props {
    ref?: Group
    fallback?: Snippet
    error?: Snippet<[any]>
    children?: Snippet<[any]>
    [key: string]: any
  }

  let { fallback, error, children, ref = $bindable(), ...rest }: Props = $props()

  const group = new Group()

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

  function alphaFix(material: Material) {
    material.transparent = true
    material.alphaToCoverage = true
    material.depthFunc = LessEqualDepth
    material.depthTest = true
    material.depthWrite = true
  }

  gltf.then((model) => {
    alphaFix(model.materials.spaceship_racer)
    alphaFix(model.materials.cockpit)
  })
</script>

<T
  is={group}
  bind:ref
  dispose={false}
  {...rest}
>
  {#await gltf}
    {@render 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 err}
    {@render error?.({ error: err })}
  {/await}

  {@render children?.({ 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, as of Threlte 8 useFrame has been removed and should be replaced.

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