Threenix Developers
Get the Agent Skill

Loading preview...

Component

WebGPU Firework Particles in React Three Fiber with TSL Sprite Sheets

March 26, 2026
ComputeParticlesSprite SheetR3FWebGPUTSLTypescript

Description

Particle state lives in GPU storage buffers, TSL compute nodes handle launch and explosion motion, and one instanced sprite material renders the result. Use this pattern when many short-lived particles should be simulated on the GPU without mirroring their positions through React state. It is intentionally a focused effect, not a true fireworks simulation.

Agent skill

Add this resource

$threenix-add-component fireworks

Implementation

Source Code

Fireworks

'use client'

import { useFrame, useTexture, useThree } from '@react-three/fiber'
import { type CreatorState, useLocalNodes, useUniforms } from '@react-three/tsl'
import { type FC, useCallback, useId, useLayoutEffect, useMemo, useRef } from 'react'
import {
  Fn,
  If,
  PI,
  Return,
  array,
  color,
  cos,
  float,
  hash,
  instanceIndex,
  instancedArray,
  mix,
  sin,
  smoothstep,
  spritesheetUV,
  texture,
  time,
  uv,
  vec2,
  vec3,
  vec4,
  vertexStage,
} from 'three/tsl'
import { NearestFilter } from 'three/webgpu'
import type { Blending, BufferAttribute, Node, UniformNode } from 'three/webgpu'
import * as THREE from 'three/webgpu'

export const DEFAULT_FPS = 60
export const DEFAULT_PARTICLE_COUNT = 2 ** 12
export const DEFAULT_BATCH_COUNT = 8
export const DEFAULT_BURST_DELAY_MIN = 300
export const DEFAULT_BURST_DELAY_MAX = 1000
const EXPLOSION_TRIGGER_LIFE = 1.15
const EXPLOSION_SCALE_MULTIPLIER = 1.6
const FIREWORK_PHASE = {
  launch: 0,
  explosion: 1,
} as const

const FIREWORKS_SPRITE_URL = new URL('./fireworks-sprites.webp', import.meta.url).href

const PARTICLE_PALETTE = [
  '#ffe071',
  '#ff9183',
  '#ff4b93',
  '#5f00aa',
  '#9a00a3',
  '#fff636',
  '#f6cc00',
  '#ffdf3a',
  '#f8d200',
  '#ffe81f',
  '#fff132',
  '#fffc5f',
  '#edc740',
  '#d5c8a3',
  '#ff9b91',
  '#ffa798',
  '#ff978b',
  '#ffbaa7',
  '#ff957a',
  '#ffa59d',
  '#ffc3a6',
  '#ffceb2',
  '#bf897e',
  '#ff9be2',
  '#ff85d4',
  '#ffb1ff',
  '#ff8ddc',
  '#ff6ecd',
  '#ff6fc7',
  '#ffb1f2',
  '#ff96e4',
  '#ca92a3',
  '#ffbfff',
  '#faa7ff',
  '#ffb6ff',
  '#ffc8ff',
  '#ffbcff',
  '#ffccff',
  '#e2c0ff',
  '#ffc9ff',
  '#ccb0d9',
  '#ffc3ff',
  '#ffb0ff',
  '#ffafff',
  '#ffb5ff',
  '#ff90ff',
  '#ffd5ff',
  '#ffa9ff',
  '#d6abda',
] as const

export type FireworksAppearance = {
  palette: readonly string[]
  lifetime: readonly [number, number]
  originY: readonly [number, number]
  originZ: readonly [number, number]
  speed: readonly [number, number]
  scale: readonly [number, number]
  opacity: readonly [number, number]
  fadeLife: number
  explosionLife: number
  explosionScale: number
  size: number
  blending: Blending
  depthTest: boolean
}

const DEFAULT_APPEARANCE: FireworksAppearance = {
  palette: PARTICLE_PALETTE,
  lifetime: [1.2, 2],
  originY: [-6, -5],
  originZ: [-4, 0],
  speed: [20, 40],
  scale: [0.18, 0.4],
  opacity: [0.35, 0.95],
  fadeLife: 0.18,
  explosionLife: EXPLOSION_TRIGGER_LIFE,
  explosionScale: EXPLOSION_SCALE_MULTIPLIER,
  size: 0.8,
  blending: THREE.AdditiveBlending,
  depthTest: false,
}

const getParticleSeed = (index: number): number => {
  const value = Math.sin((index + 1) * 12.9898) * 43758.5453
  return value - Math.floor(value)
}

type Props = {
  appearance?: Partial<FireworksAppearance>
  batchCount?: number
  burstDelayMax?: number
  burstDelayMin?: number
  fps?: number
  particleCount?: number
}

export const FireworksParticles: FC<Props> = ({
  particleCount = DEFAULT_PARTICLE_COUNT,
  ...props
}) => {
  if (particleCount <= 0) return null
  return <FireworksParticlesInner {...props} particleCount={Math.floor(particleCount)} />
}

const FireworksParticlesInner: FC<Props> = ({
  appearance,
  batchCount = DEFAULT_BATCH_COUNT,
  burstDelayMax = DEFAULT_BURST_DELAY_MAX,
  burstDelayMin = DEFAULT_BURST_DELAY_MIN,
  fps = DEFAULT_FPS,
  particleCount = DEFAULT_PARTICLE_COUNT,
}) => {
  const renderer = useThree((s) => s.renderer)
  const frameloop = useThree((s) => s.frameloop)
  const scope = `fireworks${useId().replace(/[^a-zA-Z0-9]/g, '')}`
  const activeBatchRef = useRef(0)
  const style = useMemo(() => ({ ...DEFAULT_APPEARANCE, ...appearance }), [appearance])
  const particlePaletteNodes = useMemo(
    () => array(style.palette.map((hex) => color(hex))),
    [style],
  )

  useUniforms({ uActiveBatch: 0, uDeltaTime: 0 }, scope)

  const spriteTexture = useTexture(FIREWORKS_SPRITE_URL, (textureValue) => {
    textureValue.generateMipmaps = false
    textureValue.minFilter = NearestFilter
    textureValue.magFilter = NearestFilter
  })

  const particleBuffers = useMemo(() => {
    // metaBuffer: x = batch index, y = deterministic seed, z = phase,
    // w = explosion scale per unit of remaining life.
    const metaValues = new Float32Array(particleCount * 4)

    for (let index = 0; index < particleCount; index += 1) {
      const metaOffset = index * 4

      metaValues[metaOffset] = Math.min(
        batchCount - 1,
        Math.floor((index / particleCount) * batchCount),
      )
      metaValues[metaOffset + 1] = getParticleSeed(index)
    }

    return {
      colorBuffer: instancedArray(particleCount, 'vec4'),
      metaBuffer: instancedArray(metaValues, 'vec4'),
      positionBuffer: instancedArray(particleCount, 'vec4'),
      velocityBuffer: instancedArray(particleCount, 'vec4'),
    }
  }, [batchCount, particleCount])

  const createSimulationNodes = useCallback(
    ({ uniforms }: CreatorState) => {
      const { colorBuffer, metaBuffer, positionBuffer, velocityBuffer } = particleBuffers
      const fireworkUniforms = uniforms.scope<{
        uActiveBatch: UniformNode<'float', number>
        uDeltaTime: UniformNode<'float', number>
      }>(scope)
      const activeBatch = fireworkUniforms.uActiveBatch
      const simulationDelta = fireworkUniforms.uDeltaTime
      const launchPhaseNode = float(FIREWORK_PHASE.launch).toConst()
      const explosionPhaseNode = float(FIREWORK_PHASE.explosion).toConst()
      const explosionTriggerLifeNode = float(style.explosionLife).toConst()
      const explosionScaleMultiplierNode = float(style.explosionScale).toConst()

      const resetParticles = Fn(() => {
        positionBuffer.element(instanceIndex).assign(vec4(0, -100, 0, 0))
        velocityBuffer.element(instanceIndex).assign(vec4(0))
        metaBuffer.element(instanceIndex).z.assign(launchPhaseNode)
        metaBuffer.element(instanceIndex).w.assign(0)

        const paletteIndex = hash(instanceIndex.add(11)).mul(style.palette.length).floor()
        const paletteColor = particlePaletteNodes.element(
          paletteIndex,
        ) as unknown as Node<'vec3'>
        const frameIndex = hash(instanceIndex.add(23)).mul(4).floor()

        colorBuffer.element(instanceIndex).assign(vec4(paletteColor, frameIndex))
      })().compute(particleCount)

      const spawnBatch = Fn(() => {
        const particleIndex = instanceIndex.toFloat()
        const meta = metaBuffer.element(instanceIndex)
        const batch = meta.x
        const position = positionBuffer.element(instanceIndex)
        const life = position.w
        const positionValue = position.xyz
        const velocityValue = velocityBuffer.element(instanceIndex)
        const seed = meta.y
        const phase = meta.z
        const scaleFadeRate = meta.w
        const colorValue = colorBuffer.element(instanceIndex)
        const frameIndex = colorValue.a

        If(batch.equal(activeBatch).and(life.lessThanEqual(0)), () => {
          const batchSeed = activeBatch.add(time.mul(0.1)).toVar()
          const particleTime = particleIndex.add(time).toVar()

          life.assign(mix(...style.lifetime, hash(batchSeed.add(7.3))))

          const colorIndex = hash(particleTime.mul(3.7)).mul(style.palette.length).floor()
          colorValue.xyz.assign(
            particlePaletteNodes.element(colorIndex) as unknown as Node<'vec3'>,
          )
          frameIndex.assign(hash(particleTime.add(seed)).mul(4).floor())

          const originX = mix(float(-2), float(2), hash(batchSeed.add(1.1)))
          const originY = mix(...style.originY, hash(batchSeed.add(2.3)))
          const originZ = mix(...style.originZ, hash(batchSeed.add(3.7)))

          positionValue.assign(vec3(originX, originY, originZ))

          const batchSpeed = mix(...style.speed, hash(batchSeed.add(7.9)))
          const batchAngle = mix(float(-0.7), float(0.7), hash(batchSeed.add(5.1)))
          const batchVelocityX = sin(batchAngle).mul(batchSpeed)
          const batchVelocityY = cos(batchAngle).mul(batchSpeed)
          const jitterX = mix(float(-0.05), float(0.05), hash(particleIndex.add(time.mul(5.1))))
          const jitterY = mix(float(-0.05), float(0.05), hash(particleIndex.add(time.mul(6.3))))
          const baseScale = mix(...style.scale, seed)

          velocityValue.assign(
            vec4(batchVelocityX.add(jitterX), batchVelocityY.add(jitterY), float(0), baseScale),
          )
          phase.assign(launchPhaseNode)
          scaleFadeRate.assign(0)
        })
      })().compute(particleCount)

      const updateParticles = Fn(() => {
        const particleIndex = instanceIndex.toFloat()
        const position = positionBuffer.element(instanceIndex)
        const life = position.w

        If(life.lessThanEqual(0), () => {
          Return()
        })

        const positionValue = position.xyz
        const velocityValue = velocityBuffer.element(instanceIndex)
        const movementVelocity = velocityValue.xyz
        const meta = metaBuffer.element(instanceIndex)
        const seed = meta.y
        const phase = meta.z
        const scaleFadeRate = meta.w
        const dt = simulationDelta
        const explosionDrag = float(1).sub(float(0.14).mul(dt))

        If(phase.lessThan(explosionPhaseNode), () => {
          velocityValue.y.subAssign(float(4.2).mul(dt))
          velocityValue.x.addAssign(
            sin(seed.mul(30).add(life.mul(4)))
              .mul(0.24)
              .mul(dt),
          )
          positionValue.addAssign(movementVelocity.mul(dt))

          If(life.lessThan(explosionTriggerLifeNode), () => {
            const directionCount = 16
            const particleTime = particleIndex.add(time).toVar()
            const angle = hash(particleTime.mul(13.7))
              .mul(directionCount)
              .floor()
              .mul(PI.div(directionCount / 2))
            const magnitude = mix(float(0.5), float(8), hash(particleTime.mul(19.3)))

            phase.assign(explosionPhaseNode)
            velocityValue.x.assign(cos(angle).mul(magnitude))
            velocityValue.y.assign(sin(angle).mul(magnitude))
            velocityValue.z.assign(mix(float(-5), float(0), hash(particleTime.mul(23.1))))
            velocityValue.w.mulAssign(explosionScaleMultiplierNode)
            scaleFadeRate.assign(velocityValue.w.div(life))
            Return()
          })
        })

        If(phase.greaterThanEqual(explosionPhaseNode), () => {
          velocityValue.y.subAssign(float(7).mul(dt))
          velocityValue.x.mulAssign(explosionDrag)
          velocityValue.y.mulAssign(explosionDrag)
          velocityValue.z.mulAssign(explosionDrag)
          positionValue.addAssign(movementVelocity.mul(dt))
        })

        life.subAssign(dt.mul(0.8))

        If(life.lessThanEqual(0), () => {
          life.assign(0)
          velocityValue.w.assign(0)
          Return()
        })

        If(phase.greaterThanEqual(explosionPhaseNode), () => {
          velocityValue.w.assign(scaleFadeRate.mul(life))
        })
      })().compute(particleCount)

      const colorNode = colorBuffer.toAttribute()
      const positionNode = positionBuffer.toAttribute()
      const spriteUV = spritesheetUV(vec2(2), uv(), colorNode.a)
      const maskNode = texture(spriteTexture, spriteUV).r.greaterThan(float(0))
      const opacityNode = vertexStage(
        smoothstep(float(0), float(style.fadeLife), positionNode.w).mul(
          mix(...style.opacity, metaBuffer.toAttribute().y),
        ),
      )

      return {
        colorNode,
        maskNode,
        opacityNode,
        positionNode,
        resetParticles,
        scaleNode: vec2(velocityBuffer.toAttribute().w),
        simulationDelta,
        setActiveBatch: (batch: number) => {
          activeBatch.value = batch
        },
        spawnBatch,
        updateParticles,
      }
    },
    [particleBuffers, particleCount, spriteTexture, scope, style, particlePaletteNodes],
  )

  const simulation = useLocalNodes(createSimulationNodes, [createSimulationNodes])

  useLayoutEffect(() => {
    renderer.compute(simulation.resetParticles)
    return () => {
      simulation.resetParticles.dispose()
      simulation.spawnBatch.dispose()
      simulation.updateParticles.dispose()
    }
  }, [renderer, simulation])

  useLayoutEffect(
    () => () => {
      // Three r186 requires attribute-owner cleanup for compute-only GPU buffers.
      const attributes = (
        renderer as typeof renderer & {
          _attributes?: { delete(attribute: BufferAttribute): unknown }
        }
      )._attributes
      for (const buffer of Object.values(particleBuffers)) attributes?.delete(buffer.value)
    },
    [renderer, particleBuffers],
  )

  useLayoutEffect(() => {
    activeBatchRef.current = 0
  }, [simulation])

  useLayoutEffect(() => {
    if (frameloop === 'never') return

    const triggerBurst = () => {
      simulation.setActiveBatch(activeBatchRef.current)
      activeBatchRef.current = (activeBatchRef.current + 1) % batchCount
      renderer.compute(simulation.spawnBatch)
    }
    let timeoutId = 0

    const scheduleBurst = () => {
      const delay = burstDelayMin + Math.random() * (burstDelayMax - burstDelayMin)

      timeoutId = window.setTimeout(() => {
        triggerBurst()
        scheduleBurst()
      }, delay)
    }

    triggerBurst()
    scheduleBurst()

    return () => window.clearTimeout(timeoutId)
  }, [batchCount, burstDelayMax, burstDelayMin, renderer, simulation, frameloop])

  useFrame(
    (_, delta) => {
      if (frameloop === 'never') return
      simulation.simulationDelta.value = delta
      renderer.compute(simulation.updateParticles)
    },
    { fps },
  )

  return (
    <instancedMesh args={[undefined, undefined, particleCount]} frustumCulled={false}>
      <planeGeometry args={[style.size, style.size, 1, 1]} />
      <spriteNodeMaterial
        key={simulation.colorNode.uuid}
        blending={style.blending}
        colorNode={simulation.colorNode}
        depthTest={style.depthTest}
        depthWrite={false}
        maskNode={simulation.maskNode}
        opacityNode={simulation.opacityNode}
        positionNode={simulation.positionNode}
        scaleNode={simulation.scaleNode}
        transparent={true}
      />
    </instancedMesh>
  )
}

export default FireworksParticles

Downloads

Assets