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/gltfCommand: 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 2Author: 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-18541ebed6ce44a9923f9b8dc30d87f5Title: 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
Section titled “Part I”Part II
Section titled “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
Section titled “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 })