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.
useFrame(() => {
...
})
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
const { scene, camera, renderer } = useThrelte()
useRender(() => {
// render here
})
const { scene, camera, renderer, renderStage } = useThrelte()
useTask(
() => {
// render here
},
{ stage: renderStage, autoInvalidate: false }
)