Complex sprite scene
This example demonstrates <InstancedSprites>
, showing
a few different approaches for instancing and updating large numbers of sprites in your scene.
<script lang="ts">
import { Canvas, T } from '@threlte/core'
import Scene from './Scene.svelte'
import Settings from './Settings.svelte'
import { OrbitControls, PerfMonitor } from '@threlte/extras'
import { DEG2RAD } from 'three/src/math/MathUtils.js'
let billboarding = true
let fps = 9
</script>
<div>
<Canvas>
<PerfMonitor />
<T.PerspectiveCamera
makeDefault
position.z={14}
position.y={4}
>
<OrbitControls
autoRotate
autoRotateSpeed={0.5}
minPolarAngle={DEG2RAD * 65}
maxPolarAngle={DEG2RAD * 85}
/>
</T.PerspectiveCamera>
<Scene
{billboarding}
{fps}
/>
</Canvas>
<Settings
bind:billboarding
bind:fps
/>
</div>
<style>
div {
height: 100%;
}
</style>
<script lang="ts">
import { T, useThrelte } from '@threlte/core'
import { CSM, Sky, useTexture } from '@threlte/extras'
import { BackSide, NearestFilter, RepeatWrapping } from 'three'
import { DEG2RAD } from 'three/src/math/MathUtils.js'
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'
export let billboarding = false
export let fps: number
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>
<slot />
<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={-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'
export let billboarding: boolean
export let fps: number
</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
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
agents[i].timer -= delta
totalTime += delta
// apply velocity
posX[i] += agents[i].velocity[0] * delta
posY[i] = agents[i]?.baseHeight + Math.sin(totalTime * 0.00005 + i)
posZ[i] += agents[i].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 (agents[i].timer < 0 || dist < minCenterDistance || dist > maxCenterDistance) {
const runChance = 0.6 + (agents[i].action === 'Idle' ? 0.3 : 0)
agents[i].action = Math.random() < runChance ? 'Run' : 'Idle'
agents[i].timer = 5 + Math.random() * 5
if (agents[i].action === 'Run') {
velocityHelper
.set(Math.random() - 0.5, Math.random() - 0.5)
.normalize()
.multiplyScalar(2.1)
agents[i].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 { 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] - REGION_W / 2) ** 2 + (v[1] - 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, z], 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 {
/**
* @param {number} radius
* @param {number[]} region even numbered width/height vector
* @param {number} maxCandidates default 30
*/
constructor(radius, region, 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])
}
/**
* @returns {number[][]} an array of points
*/
GeneratePoints() {
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 = [Math.sin(angle), Math.cos(angle)]
const disp = Math.floor(this.random() * (this.radius + 1)) + this.radius
const candidate = spawnCentre.map((val, i) => val + dir[i] * 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) {
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 {
/**
* @param {number} radius
* @param {number[]} region even numbered width/height vector
* @param {number} maxCandidates default 30
* @param {()=>number} random a random (or pusedo-random) number generator (0, 1)
*/
constructor(radius, region, maxCandidates = 30, random) {
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 it works?
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
<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 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
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
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
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
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
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
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.