Instanced Sprites
This example demonstrates <InstancedSprites>, showing
a few different approaches for instancing and updating large numbers of sprites in your scene.
<script lang="ts"> import { MathUtils } from 'three' import { Canvas, T } from '@threlte/core' import Scene from './Scene.svelte' import Settings from './Settings.svelte' import { OrbitControls } from '@threlte/extras'
let billboarding = $state(true) let fps = $state(9)</script>
<div> <Canvas> <T.PerspectiveCamera makeDefault position.z={14} position.y={4} > <OrbitControls autoRotate autoRotateSpeed={0.5} minPolarAngle={MathUtils.DEG2RAD * 65} maxPolarAngle={MathUtils.DEG2RAD * 85} /> </T.PerspectiveCamera>
<Scene {billboarding} {fps} /> </Canvas>
<Settings bind:billboarding bind:fps /></div>
<style> div { height: 100%; }</style><script lang="ts"> import type { Snippet } from 'svelte' import { T, useThrelte } from '@threlte/core' import { CSM, Sky, useTexture } from '@threlte/extras' import { BackSide, NearestFilter, RepeatWrapping, MathUtils } from 'three' import DudeSprites from './sprites/DudeSprites.svelte' import FlyerSprites from './sprites/FlyerSprites.svelte' import FlyerSpritesTyped from './sprites/FlyerSpritesTyped.svelte' import GoblinSprites from './sprites/GoblinSprites.svelte' import TreeSpriteAtlas from './sprites/TreeSpriteAtlas.svelte'
interface Props { billboarding?: boolean fps: number children?: Snippet }
let { billboarding = false, fps, children }: Props = $props()
const grass = useTexture('/textures/sprites/pixel-grass.png', { transform: (texture) => { texture.wrapS = texture.wrapT = RepeatWrapping texture.repeat.set(500, 500) texture.minFilter = NearestFilter texture.magFilter = NearestFilter texture.needsUpdate = true return texture } })
const sky = useTexture('/textures/sprites/pixel-sky.png', { transform: (texture) => { texture.wrapS = texture.wrapT = RepeatWrapping texture.repeat.set(10, 2) texture.minFilter = NearestFilter texture.magFilter = NearestFilter texture.needsUpdate = true return texture } })
const { renderer } = useThrelte() renderer.setPixelRatio(1)</script>
{@render children?.()}
<CSM args={{ mode: 'logarithmic' }} lightDirection={[-1, -1, -1]} lightIntensity={5}> <!-- Dudes: - Michael's Aseprite loader - One is WASD controlled--> <DudeSprites {billboarding} {fps} />
<!-- Flyers: - Loading .png file with multiple animations--> <FlyerSprites {billboarding} {fps} />
<!-- Goblins: - Assemble a spritesheet out of multiple .png files.--> <GoblinSprites {billboarding} {fps} />
<!-- Flyers: - Loading .png file with multiple animations - uses a typed utility hook for animation name autocomplete etc.--> <FlyerSpritesTyped {billboarding} />
<!-- Multiple trees in a spritesheet, 1 frame each animation - acting as atlas - not animated --> <TreeSpriteAtlas {billboarding} />
<!-- SCENE SETUP: grass, sky, lights -->
{#if $sky} <T.Mesh position.y={-10} scale.y={0.5} > <T.SphereGeometry args={[300, 8, 8]} /> <T.MeshBasicMaterial map={$sky} side={BackSide} /> </T.Mesh> {/if}
{#if $grass} <T.Mesh rotation.x={-MathUtils.DEG2RAD * 90} receiveShadow > <T.CircleGeometry args={[300]} /> <T.MeshLambertMaterial map={$grass} /> </T.Mesh> {/if}</CSM>
<Sky elevation={13.35} />
<T.AmbientLight intensity={1} /><script lang="ts"> import { Checkbox, Pane, Slider, ThemeUtils } from 'svelte-tweakpane-ui'
interface Props { billboarding: boolean fps: number }
let { billboarding = $bindable(), fps = $bindable() }: Props = $props()</script>
<Pane theme={ThemeUtils.presets.light} position="fixed" title="InstancedSprite"> <Checkbox bind:value={billboarding} label="billboarding" />
<Slider label="fps" min={1} max={30} step={1} bind:value={fps} /></Pane><script lang="ts"> import { useTask } from '@threlte/core' import { useInstancedSprite } from '@threlte/extras' import { Vector2 } from 'three' import { randomPosition } from '../util'
const { updatePosition, count, animationMap, sprite } = useInstancedSprite()
sprite.offset.randomizeAll()
type FlyingAgent = { action: 'Idle' | 'Fly' velocity: [number, number] timer: number baseHeight: number }
const agents: FlyingAgent[] = [] for (let i = 0; i < count; i++) { agents.push({ action: 'Fly', timer: 0.1, velocity: [0, 1], baseHeight: 2 + Math.random() * 15 }) }
const posX: number[] = new Array(count).fill(0) const posY: number[] = new Array(count).fill(0) const posZ: number[] = new Array(count).fill(0)
const spawnRadius = 250 const minCenterDistance = 5 const maxCenterDistance = spawnRadius
for (let i = 0; i < agents.length; i++) { const pos = randomPosition(spawnRadius) posX[i] = pos.x posY[i] = agents[i]?.baseHeight || 10 posZ[i] = pos.y }
const velocityHelper = new Vector2(0, 0)
let totalTime = 0
const updateAgents = (delta: number) => { for (let i = 0; i < agents.length; i++) { // timer const agent = agents[i] if (!agent) return
agent.timer -= delta totalTime += delta
// apply velocity posX[i]! += agent.velocity[0] * delta posY[i]! = agent.baseHeight + Math.sin(totalTime * 0.00005 + i) posZ[i]! += agent.velocity[1] * delta
// roll new behaviour when time runs out or agent gets out of bounds if (i > 0) { const dist = Math.sqrt((posX[i] || 0) ** 2 + (posZ[i] || 0) ** 2) if (agent.timer < 0 || dist < minCenterDistance || dist > maxCenterDistance) { const FlyChance = 0.6 + (agent.action === 'Idle' ? 0.3 : 0) agent.action = Math.random() < FlyChance ? 'Fly' : 'Idle'
agent.timer = 5 + Math.random() * 5
if (agent.action === 'Fly') { velocityHelper .set(Math.random() - 0.5, Math.random() - 0.5) .normalize() .multiplyScalar(4.5) agent.velocity = velocityHelper.toArray()
if (velocityHelper.x > 0) { sprite.flipX.setAt(i, false) } else { sprite.flipX.setAt(i, true) } } } } } }
useTask((_delta) => { if ($animationMap.size > 0) { updateAgents(_delta) }
for (let i = 0; i < count; i++) { updatePosition(i, [posX[i] || 0, posY[i] || 0, posZ[i] || 0], [5, 5]) sprite.animation.setAt(i, 0) } })</script><script lang="ts"> import { useTask } from '@threlte/core'
import { Vector2 } from 'three' import { useDemonSprite } from '../sprites/FlyerSpritesTyped.svelte' import { randomPosition } from '../util'
const { updatePosition, count, animationMap, sprite } = useDemonSprite()
sprite.offset.randomizeAll()
type FlyingAgent = { action: 'Idle' | 'Run' velocity: [number, number] timer: number baseHeight: number }
const agents: FlyingAgent[] = [] for (let i = 0; i < count; i++) { agents.push({ action: 'Run', timer: 0.1, velocity: [0, 1], baseHeight: 2 + Math.random() * 15 }) }
const posX: number[] = new Array(count).fill(0) const posY: number[] = new Array(count).fill(0) const posZ: number[] = new Array(count).fill(0)
const spawnRadius = 250 const minCenterDistance = 5 const maxCenterDistance = spawnRadius
for (let i = 0; i < agents.length; i++) { const pos = randomPosition(spawnRadius) posX[i] = pos.x posY[i] = agents[i]?.baseHeight ?? 0 posZ[i] = pos.y }
const velocityHelper = new Vector2(0, 0)
let totalTime = 0
const updateAgents = (delta: number) => { for (let i = 0; i < agents.length; i++) { // timer
const agent = agents[i]!
agent.timer -= delta totalTime += delta
// apply velocity posX[i]! += agent.velocity[0] ?? 0 * delta posY[i] = agent.baseHeight ?? 0 + Math.sin(totalTime * 0.00005 + i) posZ[i]! += agent.velocity[1] ?? 0 * delta
// roll new behaviour when time runs out or agent gets out of bounds if (i > 0) { const dist = Math.sqrt((posX[i] || 0) ** 2 + (posZ[i] || 0) ** 2) if (agent.timer < 0 || dist < minCenterDistance || dist > maxCenterDistance) { const runChance = 0.6 + (agent.action === 'Idle' ? 0.3 : 0) agent.action = Math.random() < runChance ? 'Run' : 'Idle'
agent.timer = 5 + Math.random() * 5
if (agent.action === 'Run') { velocityHelper .set(Math.random() - 0.5, Math.random() - 0.5) .normalize() .multiplyScalar(2.1) agent.velocity = velocityHelper.toArray()
if (velocityHelper.x > 0) { sprite.flipX.setAt(i, false) } else { sprite.flipX.setAt(i, true) } } } } } }
useTask((_delta) => { if ($animationMap.size > 0) { updateAgents(_delta) }
for (let i = 0; i < count; i++) { updatePosition(i, [posX[i] || 0, posY[i] || 0, posZ[i] || 0], [5, 5]) sprite.animation.setAt(i, 0 as unknown as 'idle') } })</script><script lang="ts"> import { useTask } from '@threlte/core' import { useInstancedSprite } from '@threlte/extras' import { Vector2 } from 'three' import { randomPosition } from '../util'
const { updatePosition, count, sprite } = useInstancedSprite()
const posX: number[] = Array.from({ length: count }) const posZ: number[] = Array.from({ length: count })
const spawnRadius = 250
for (let i = 0; i < count; i++) { const pos = randomPosition(spawnRadius) posX[i] = pos.x posZ[i] = pos.y }
type Agent = { action: 'Idle' | 'Run' velocity: [number, number] timer: number }
const agents: Agent[] = [] for (let i = 0; i < count; i++) { agents.push({ action: 'Run', timer: 0.1, velocity: [0, 1] }) }
const velocityHelper = new Vector2(0, 0)
const pickAnimation = (i: number) => { const dirWords = ['Forward', 'Backward', 'Left', 'Right'] const agent = agents[i] as Agent
const isHorizontal = Math.abs(agent.velocity[0] * 2) > Math.abs(agent.velocity[1]) ? 2 : 0 const isLeft = agent.velocity[0] > 0 ? 1 : 0 const isUp = agent.velocity[1] > 0 ? 0 : 1
const secondMod = isHorizontal ? isLeft : isUp const chosenWord = dirWords.slice(0 + isHorizontal, 2 + isHorizontal)
const animationName = `${agent.action}${chosenWord[secondMod]}`
return animationName }
const updateAgents = (delta: number) => { for (let i = 0; i < agents.length; i++) { const agent = agents[i] as Agent agent.timer -= delta
// apply velocity posX[i]! += agent.velocity[0] * delta posZ[i]! += agent.velocity[1] * delta
// roll new behaviour when time runs out or agent gets out of bounds if (agent.timer < 0) { const runChance = 0.6 + (agent.action === 'Idle' ? 0.3 : 0) agent.action = Math.random() < runChance ? 'Run' : 'Idle'
agent.timer = 5 + Math.random() * 5
if (agent.action === 'Run') { velocityHelper .set(Math.random() - 0.5, Math.random() - 0.5) .normalize() .multiplyScalar(3) agent.velocity = velocityHelper.toArray() }
const animIndex = pickAnimation(i) if (agent.action === 'Idle') { agent.velocity = [0, 0] } sprite.animation.setAt(i, animIndex) } } }
useTask((delta) => { updateAgents(delta)
for (let i = 0; i < count; i++) { updatePosition(i, [posX[i] || 0, 0.5, posZ[i] || 0]) } })</script><!-- - uses aseprite json loader - one sprite is WASD controlled - uses an untyped useInstancedSprie() hook in UpdaterWalking component -->
<script lang="ts"> import { InstancedSprite, buildSpritesheet } from '@threlte/extras' import WalkingBehaviour from '../behaviours/WalkingBehaviour.svelte'
export let billboarding = false export let fps: number
const player = buildSpritesheet.fromAseprite( '/textures/sprites/player.json', '/textures/sprites/player.png' )</script>
{#await player then spritesheet} <InstancedSprite {spritesheet} count={4000} playmode={'FORWARD'} {fps} {billboarding} castShadow > <WalkingBehaviour /> </InstancedSprite>{/await}<!-- - builds spritesheet from the SpritesheetMetadata object with buildSpritesheet.from utility. Multiple animations in one sprite file. Not set up for typescript animation name autocomplete. For that check SpriteFlyersTyped.svelte file - uses an untyped useInstancedSprie() hook in UpdaterFlying component -->
<script lang="ts"> import { InstancedSprite, buildSpritesheet } from '@threlte/extras'
import type { SpritesheetMetadata } from '@threlte/extras' import FlyingBehaviour from '../behaviours/FlyingBehaviour.svelte'
export let billboarding = false export let fps: number
const demonSpriteMeta: SpritesheetMetadata = [ { url: '/textures/sprites/cacodaemon.png', type: 'rowColumn', width: 8, height: 4, animations: [ { name: 'fly', frameRange: [0, 5] }, { name: 'attack', frameRange: [8, 13] }, { name: 'idle', frameRange: [16, 19] }, { name: 'death', frameRange: [24, 31] } ] } ]
const flyerSheetbuilder = buildSpritesheet.from(demonSpriteMeta)</script>
{#await flyerSheetbuilder.spritesheet then spritesheet} <InstancedSprite count={2000} playmode={'FORWARD'} {fps} {billboarding} randomPlaybackOffset={1} castShadow {spritesheet} > <FlyingBehaviour /> </InstancedSprite>{/await}<!-- - builds spritesheet from the SpritesheetMetadata object with buildSpritesheet.from utility. Multiple animations in one sprite file. Set up for typescript animation name autocomplete. - notice that it's built in a script with context="module". This allows for exporting the built spritesheet and a typed hook. You could also have it somewhere else in a .ts file for example. - object has `as const satisfies SpritesheetMetadata`, necessary for autocomplete - a typed hook with animation name autocomplete is provided by buildSpritesheet.from then, this hook is used in UpdaterFlyingHook component instead of untyped useInstancedSprite -->
<script context="module" lang="ts"> import type { SpritesheetMetadata } from '@threlte/extras' const demonSpriteMeta = [ { url: '/textures/sprites/cacodaemon.png', type: 'rowColumn', width: 8, height: 4, animations: [ { name: 'fly', frameRange: [0, 5] }, { name: 'attack', frameRange: [8, 13] }, { name: 'idle', frameRange: [16, 19] }, { name: 'death', frameRange: [24, 31] } ] } ] as const satisfies SpritesheetMetadata
const cacodaemonSpritesheet = buildSpritesheet.from<typeof demonSpriteMeta>(demonSpriteMeta)
export const useDemonSprite = cacodaemonSpritesheet.useInstancedSprite</script>
<script lang="ts"> import { InstancedSprite, buildSpritesheet } from '@threlte/extras' import FlyingBehaviourHook from '../behaviours/FlyingBehaviourHook.svelte'
export let billboarding = false
const count = 2000</script>
{#await cacodaemonSpritesheet.spritesheet then spritesheet} <InstancedSprite {count} {billboarding} {spritesheet} castShadow hueShift={{ h: 0.3, s: 1.5, v: 1.5 }} > <FlyingBehaviourHook /> </InstancedSprite>{/await}<!-- - builds spritesheet from the SpritesheetMetadata object with buildSpritesheet.from utility. Multiple files, each with a single animation. Not set up for typescript animation name autocomplete. For that check SpriteFlyersTyped.svelte file - these sprites are stationary, but change their animation randomly very often - animation update is done directly on the underlying InstancedSpriteMesh exposed by a `ref` binding -->
<script lang="ts"> import { useTask } from '@threlte/core' import { InstancedSprite, buildSpritesheet, type SpritesheetMetadata } from '@threlte/extras' import { Matrix4 } from 'three'
export let billboarding = false export let fps: number
// DECLARE SPRIRESHEET META & BUILD IT const goblinSpriteMeta: SpritesheetMetadata = [ { url: '/textures/sprites/goblin/Attack.png', type: 'rowColumn', width: 8, height: 1, animations: [{ name: 'attack', frameRange: [0, 7] }] }, { url: '/textures/sprites/goblin/Death.png', type: 'rowColumn', width: 4, height: 1, animations: [{ name: 'death', frameRange: [0, 3] }] }, { url: '/textures/sprites/goblin/Idle.png', type: 'rowColumn', width: 4, height: 1, animations: [{ name: 'idle', frameRange: [0, 3] }] }, { url: '/textures/sprites/goblin/Run.png', type: 'rowColumn', width: 8, height: 1, animations: [{ name: 'run', frameRange: [0, 8] }] }, { url: '/textures/sprites/goblin/TakeHit.png', type: 'rowColumn', width: 4, height: 1, animations: [{ name: 'takeHit', frameRange: [0, 3] }] } ]
const goblinSpritesheet = buildSpritesheet.from(goblinSpriteMeta)
let spriteMesh: any const goblinCount = 80 const goblinPositionSpread = 50 const tempMatrix = new Matrix4() let animationNames: string[] = []
/** * GOBLIN LOGIC - * randomize positions by directly accessing the instanced sprite api without any helpers */ $: { if (spriteMesh) { // for (let i = 0; i < goblinCount; i++) { tempMatrix.makeScale(5, 5, 1) tempMatrix.setPosition( Math.random() * goblinPositionSpread - goblinPositionSpread / 2, 0.85, Math.random() * goblinPositionSpread - goblinPositionSpread / 2 ) spriteMesh.setMatrixAt(i, tempMatrix) } animationNames = Object.keys(spriteMesh.spritesheet.animations) } }
let goblinId = 0
useTask(() => { if (spriteMesh) { // Pick a random animation for a goblin, 1 change per frame spriteMesh.animation.setAt( goblinId, animationNames[Math.floor(Math.random() * animationNames.length)] ) }
goblinId++ if (goblinId > goblinCount) goblinId = 0 })</script>
{#await goblinSpritesheet.spritesheet then spritesheet} <InstancedSprite count={goblinCount} playmode={'FORWARD'} {spritesheet} {fps} {billboarding} bind:ref={spriteMesh} castShadow />{/await}<!-- - Example of using animations as a static sprite atlas - each frame is named and used as a different tree randomly - to achieve this playmode is "PAUSE" and autoUpdate={false} - the instanced sprite has to be updated once when initialized and then, each time the atlas changes - uses <Instance/> component instead of hook to set positions and frames -->
<script lang="ts"> import { InstancedSprite, buildSpritesheet, type SpritesheetMetadata } from '@threlte/extras' import { AdaptedPoissonDiscSample as Sampler } from '../util' import type { Vector3Tuple } from 'three'
export let billboarding = false
const treeAtlasMeta = [ { url: '/textures/sprites/trees-pixelart.png', type: 'rowColumn', width: 8, height: 3, animations: [ { name: 'green_0', frameRange: [0, 0] }, { name: 'green_1', frameRange: [1, 1] }, { name: 'green_2', frameRange: [2, 2] }, { name: 'green_3', frameRange: [3, 3] }, { name: 'green_4', frameRange: [4, 4] }, { name: 'green_5', frameRange: [5, 5] }, { name: 'green_6', frameRange: [6, 6] }, { name: 'green_7', frameRange: [7, 7] }, { name: 'green_8', frameRange: [12, 12] }, { name: 'green_9', frameRange: [13, 13] }, { name: 'green_10', frameRange: [14, 14] }, { name: 'green_11', frameRange: [15, 15] }, { name: 'red_0', frameRange: [8, 8] }, { name: 'red_1', frameRange: [9, 9] }, { name: 'red_2', frameRange: [10, 10] }, { name: 'red_3', frameRange: [11, 11] }, { name: 'red_4', frameRange: [20, 20] }, { name: 'red_5', frameRange: [21, 21] }, { name: 'red_6', frameRange: [22, 22] }, { name: 'red_7', frameRange: [23, 23] }, { name: 'dead_0', frameRange: [16, 16] }, { name: 'dead_1', frameRange: [17, 17] }, { name: 'dead_2', frameRange: [18, 18] }, { name: 'dead_3', frameRange: [19, 19] } ] } ] as const satisfies SpritesheetMetadata
const treeAtlas = buildSpritesheet.from<typeof treeAtlasMeta>(treeAtlasMeta)
const treePositions: Vector3Tuple[] = []
for (let x = 0; x < 5; x++) { for (let z = 0; z < 5; z++) { treePositions.push([x, 0.5, z]) } }
const REGION_W = 600 const REGION_Z = 600
const greenTrees = 11 const redTrees = 7 const deadTrees = 3
const maxRadius = 300
const sampler = new Sampler(4, [REGION_W, REGION_Z], undefined, Math.random)
const points = sampler.GeneratePoints().filter((v) => { return Math.sqrt((v[0] ?? 0 - REGION_W / 2) ** 2 + (v[1] ?? 0 - REGION_Z / 2) ** 2) < maxRadius })
const pickRandomTreeType = () => { const rnd = Math.random() if (rnd > 0.97) { return `dead_${Math.floor(deadTrees * Math.random())}` } if (rnd > 0.9) { return `red_${Math.floor(redTrees * Math.random())}` } return `green_${Math.floor(greenTrees * Math.random())}` }
let sprite: any
$: { // manually update once to apply tree atlas // also, flip random trees on X axis for more variety if (sprite) { for (let i = 0; i < points.length; i++) { sprite.flipX.setAt(i, Math.random() > 0.6 ? true : false) } sprite.update() } }</script>
{#await treeAtlas.spritesheet then spritesheet} <InstancedSprite count={points.length} autoUpdate={false} playmode={'PAUSE'} {billboarding} {spritesheet} bind:ref={sprite} castShadow > {#snippet children({ Instance })} {#each points as [x = 0, z = 0], i} {#if i < points.length / 2} <!-- Pick a random tree from atlas via animation name --> <Instance position={[x - REGION_W / 2, 1.5, z - REGION_Z / 2]} id={i} animationName={pickRandomTreeType()} scale={[3, 3]} /> {:else} <!-- Set and freeze a random frame from the spritesheet --> <Instance position={[x - REGION_W / 2, 1.5, z - REGION_Z / 2]} id={i} scale={[3, 3]} frameId={Math.floor(Math.random() * 24)} /> {/if} {/each} {/snippet} </InstancedSprite>{/await}// Adapted from: https://github.com/SebLague/Poisson-Disc-Sampling// https://www.cs.ubc.ca/~rbridson/docs/bridson-siggraph07-poissondisk.pdf
export class PoissonDiscSample { random: () => number radius: number cellSize: number maxCandidates: number width: number height: number gridHeight: number gridWidth: number grid: any[] points: [number, number][] spawnPoints: [number, number][] constructor(radius: number, region: [number, number], maxCandidates = 30) { this.random = Math.random
this.radius = radius this.cellSize = radius / Math.SQRT2 this.maxCandidates = maxCandidates
this.width = region[0] this.height = region[1]
this.gridHeight = Math.ceil(this.height / this.cellSize) this.gridWidth = Math.ceil(this.width / this.cellSize)
this.grid = new Array(this.gridHeight) for (let i = 0; i < this.gridHeight; i++) { this.grid[i] = [...new Array(this.gridWidth)].map((_) => 0) }
this.points = [] this.spawnPoints = []
this.spawnPoints.push([this.width / 2, this.height / 2]) }
GeneratePoints(): number[][] { while (this.spawnPoints.length > 0) { // choose one of the spawn points at random const spawnIndex = Math.floor(this.random() * this.spawnPoints.length) const spawnCentre = this.spawnPoints[spawnIndex]! let candidateAccepted = false
// then generate k candidates around it for (let k = 0; k < this.maxCandidates; k++) { const angle = this.random() * Math.PI * 2 const dir: [number, number] = [Math.sin(angle), Math.cos(angle)] const disp = Math.floor(this.random() * (this.radius + 1)) + this.radius const candidate: [number, number] = [ spawnCentre[0] + dir[0] * disp, spawnCentre[1] + dir[1] * disp ]
// check if the candidate is valid if (this.IsValid(candidate)) { this.points.push(candidate) this.spawnPoints.push(candidate) const gridX = Math.ceil(candidate[0] / this.cellSize) - 1 const gridY = Math.ceil(candidate[1] / this.cellSize) - 1 this.grid[gridY][gridX] = this.points.length candidateAccepted = true break } } // If no candidates around it were valid if (!candidateAccepted) { // Remove it from the spawnpoints list this.spawnPoints.splice(spawnIndex, 1) } } return this.points }
IsValid(candidate: [number, number]) { const cX = candidate[0] const cY = candidate[1] if (cX >= 0 && cX < this.width && cY >= 0 && cY < this.height) { const cellX = Math.ceil(cX / this.cellSize) const cellY = Math.ceil(cY / this.cellSize) const searchStartX = Math.max(0, cellX - 2) const searchEndX = Math.min(cellX + 2, this.gridWidth - 1) const searchStartY = Math.max(0, cellY - 2) const searchEndY = Math.min(cellY + 2, this.gridHeight - 1)
for (let x = searchStartX; x <= searchEndX; x++) { for (let y = searchStartY; y <= searchEndY; y++) { const pointIndex = this.grid[y][x] if (pointIndex != 0) { const diff = candidate.map((val, i) => val - this.points[pointIndex - 1]![i]!) // we're not worried about the actual distance, just the equality const sqrdDst = Math.pow(diff[0]!, 2) + Math.pow(diff[1]!, 2) if (sqrdDst < Math.pow(this.radius, 2)) { return false } } } } return true } return false }}
export class AdaptedPoissonDiscSample extends PoissonDiscSample { constructor( radius: number, region: [number, number], maxCandidates: number = 30, random: () => number ) { super(radius, region, maxCandidates) this.random = random this.spawnPoints = [] const x = Math.floor(this.random() * this.width) const y = Math.floor(this.random() * this.height) this.spawnPoints.push([x, y]) }}
export const randomPosition: any = (radius = 100) => { const x = (Math.random() - 0.5) * radius * 2 const y = (Math.random() - 0.5) * radius * 2
if (Math.sqrt(x ** 2 + y ** 2) > radius) { return randomPosition() }
return { x, y }}How does it work?
Section titled “How does it work?”This covers step-by-step how you can configure <InstancedSprite/> component, starting from defining a spritesheet, then adding it to the scene and finally updating the instances in real time.
Sprite metadata object
Section titled “Sprite metadata object”<script lang="ts"> import { InstancedSprite, buildSpritesheet } from '@threlte/extras' import UpdaterFlying from './UpdaterFlying.svelte' import type { SpritesheetMetadata } from '@threlte/extras'
const demonSpriteMeta = [ { url: '/textures/sprites/cacodaemon.png', type: 'rowColumn', width: 8, height: 4, animations: [ { name: 'fly', frameRange: [0, 5] }, { name: 'attack', frameRange: [8, 13] }, { name: 'idle', frameRange: [16, 19] }, { name: 'death', frameRange: [24, 31] } ] } ] as const satisfies SpritesheetMetadata
const flyerSheetbuilder = buildSpritesheet.from<typeof demonSpriteMeta>(demonSpriteMeta)</script>In this example, there is a single sprite image containing 4 different animations. The metadata is contained within the
demonSpriteMeta object, which describes the layout and animation details of the spritesheet.
In this case, the spritesheet image is arranged in a grid of 4 rows and 8 columns, so the type is set to 'rowColumn',
height to 4 (indicating the number of rows), and width to 8 (representing the number of columns).
The animations property is an array, where each element represents a separate animation with a name and a frameRange.
For detailed information on defining animations and using frame ranges, refer to the Spritesheet builder section
Adding the component to the scene
Section titled “Adding the component to the scene”{#await flyerSheetbuilder.spritesheet then spritesheet} <InstancedSprite count={20000} {spritesheet} > <!-- User component for updating instances --> <UpdaterFlying /> / <!-- --> </InstancedSprite>{/await}We add <InstancedSprite> to the scene with a count spritesheet - the only required props. Spritesheet is a result of the promise from the previous step.
To add the <InstancedSprite> component to the scene, you need to specify at least two essential properties:
count and spritesheet. The spritesheet property is the object obtained as the result of awaiting the Promise of the buildSpritesheet function called earlier.
Updating instances
Section titled “Updating instances”In our example, the user made <FlyingBehaviour> component is responsible for updating sprites.
This component leverages the useInstancedSprite() hook, which makes it easy to access and
adjust sprite properties such as position and animation.
To update sprite instances, we utilize the useTask hook. Inside, a loop iterates over the IDs
of all instances, applying updates to their positions and assigning the fly animation to each.
This description is simplified for brevity, this is where you’d have your complex movement or game logic.
A working example, demonstrating basic random movement, is available in the source of the live example for this component
(UpdaterFlying.svelte, UpdaterWalking.svelte, UpdaterFlyingHook.svelte).
<script lang="ts"> import { useTask } from '@threlte/core' import { useInstancedSprite } from '@threlte/extras'
const { updatePosition, count, animationMap, sprite } = useInstancedSprite()
useTask(() => { for (let i = 0; i < count; i++) { updatePosition(i, [0, 0, 0]) sprite.animation.setAt(i, 'fly') } })</script>Instancing approaches
Section titled “Instancing approaches”This section goes over each component used in the scene and provides a short explanation of different approaches used with <InstancedSprite/> component.
Every component is designed differently, aimed to present varied approaches to updating instance properties, loading and defining spritesheets.
From json
Section titled “From json”DudeSprites.svelte adds sprites with random walk to the scene. One of them is controlled by the player with the use of WASD keys.
This example uses an untyped useInstancedSprite() hook within the WalkingBehaviour.svelte component to update the sprites.
One file, many animations
Section titled “One file, many animations”FlyerSprites.svelte is the sprite from the first section where we went over step by step how to work with the component.
Here, the spritesheet is constructed using the buildSpritesheet.from utility, defining multiple animations within a single sprite file.
Although this setup does not initially add TypeScript autocompletion of animation names, an alternative version found in
FlyerSpritesTyped.svelte addresses this.
Multiple files, one animation per
Section titled “Multiple files, one animation per”GoblinSprites.svelte builds a spritesheet from multiple files, each of them containing a single animation. Similar to the
flyers, this example uses the buildSpritesheet.from utility.
Instances remain stationary but frequently change their animation. Direct updates to the animation are made through the InstancedSpriteMesh,
accessed via a ref binding.
Static
Section titled “Static”The example in TreeSpriteAtlas.svelte demonstrates the setup of static sprites, as outlined in Static sprites & Atlassing.
Each frame is named and used as a different tree sprite, selected at random. Then, the playmode is set to “PAUSE,” and auto-updates are disabled,
ensuring that each sprite remains fixed. In this case, the <Instance/> component is used for setting positions and frames.