threlte logo

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

Flyer.svelte
<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

Flyer.svelte
{#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).

FlyingBehaviour.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.