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>
import { T, useTask, 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)
}
// 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()
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()
},
{
stage: renderStage
}
)
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, useTask } 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))
}
useTask((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 } 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)
})
</script>
<T
is={ref}
dispose={false}
{...$$restProps}
>
{#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,
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 }
)