Threenix Developers
Get the Agent Skill

Loading preview...

Component

WebGPU Confetti Particles in React Three Fiber with a Low-Resolution Overlay Pass

October 27, 2026
ComputeParticlesPostprocessingSprite SheetR3FWebGPUTSLTypescript

Description

Particle seeds, colours, sprite-sheet frames, lifetimes, and velocities live in GPU storage buffers. TSL compute passes spawn dead particles, integrate a balloon-like upward velocity with gravity and optional turbulence noise, and fade each sprite over its lifetime. A 2x2 sprite sheet supplies strips, a circle, a dollar sign, and a sparkle without extra draw calls.

The burst is designed to render as a second pass: mount it in its own scene with a dedicated camera, render that pass at half resolution, and composite it over the main scene. Because every particle is drawn from storage buffers, the lower-resolution pass costs a fraction of the pixel fill while keeping the motion and colour variety.

Use this pattern for win celebrations, reward showers, and any full-screen confetti overlay that should not compete with the main scene's resolution.

Agent skill

Add this resource

$threenix-add-component confetti-particles

Implementation

Source Code

ConfettiParticles

'use client'

import { useFrame, useTexture, useThree } from '@react-three/fiber'
import { type FC, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef } from 'react'
import {
  Fn,
  If,
  PI,
  Return,
  array,
  bool,
  color,
  cos,
  deltaTime,
  float,
  hash,
  instanceIndex,
  instancedArray,
  mix,
  mx_noise_float,
  mx_noise_vec3,
  oneMinus,
  sin,
  smoothstep,
  spritesheetUV,
  texture,
  time,
  uniform,
  uv,
  vec2,
  vec3,
  vec4,
  vertexStage,
} from 'three/tsl'
import {
  type BufferAttribute,
  type ComputeNode,
  type InstancedMesh,
  NearestFilter,
  type Node,
  type WebGPURenderer,
} from 'three/webgpu'

export const DEFAULT_PARTICLE_COUNT = 512
export const DEFAULT_UPDATE_STRIDE = 1
/** How long a burst stays visible before the mesh is hidden again. */
export const BURST_DURATION_MS = 4000

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

/**
 * Bright, multi-colour confetti. The sheet is tinted per particle, so a palette entry is
 * multiplied into the white sprite rather than replacing it.
 */
const CONFETTI_PALETTE = [
  '#ff6b9d',
  '#ff8fab',
  '#ffa8c5',
  '#ffd6e8',
  '#ffe066',
  '#ffd43b',
  '#ffc300',
  '#ffb703',
  '#ff9f1c',
  '#ff8c42',
  '#ff6b35',
  '#ff4d6d',
  '#8ce99a',
  '#51cf66',
  '#2f9e44',
  '#63e6be',
  '#38d9a9',
  '#20c997',
  '#3bc9db',
  '#4dabf7',
  '#339af0',
  '#5c7cfa',
  '#9775fa',
  '#b197fc',
  '#e599f7',
  '#da77f2',
  '#f783ac',
  '#ffffff',
] as const

const confettiPaletteNodes = array(CONFETTI_PALETTE.map((hex) => color(hex)))

/** Deterministic, instance-local randomness for cosmetic particles only, never gameplay outcomes. */
const createConfettiRandom = (seed: string): (() => number) => {
  // FNV-1a accepts arbitrary strings, including React useId() values.
  let state = 0x811c9dc5
  for (let index = 0; index < seed.length; index++) {
    state = Math.imul(state ^ seed.charCodeAt(index), 0x01000193)
  }

  return () => {
    // Mulberry32 keeps each allocation's sequence independent of every other allocation.
    state = (state + 0x6d2b79f5) | 0
    let value = Math.imul(state ^ (state >>> 15), state | 1)
    value ^= value + Math.imul(value ^ (value >>> 7), value | 61)
    return ((value ^ (value >>> 14)) >>> 0) / 0x100000000
  }
}

type ConfettiParticleLifetime = {
  computeNodes: ComputeNode[]
  generation: number
  initialized: boolean
}

type ConfettiParticleWarmup = {
  active: boolean
  ready: boolean
  promise: Promise<void> | null
}

export type ConfettiParticlesProps = {
  /** Increment to start a burst. `null` keeps the component idle. */
  burstTick: number | null
  particleCount?: number
  updateStride?: number
  useTurbulenceNoise?: boolean
}

/**
 * GPU-computed confetti burst. The component owns its compute graph and sprite material only;
 * the host positions it and can render it in a dedicated low-resolution overlay pass.
 */
const ConfettiParticles: FC<ConfettiParticlesProps> = ({
  burstTick,
  particleCount = DEFAULT_PARTICLE_COUNT,
  updateStride = DEFAULT_UPDATE_STRIDE,
  useTurbulenceNoise = false,
}) => {
  const renderer = useThree((state) => state.renderer) as unknown as WebGPURenderer
  const meshRef = useRef<InstancedMesh | null>(null)
  const updateFrameModuloRef = useRef(0)
  const pendingCompilations = useRef(new Set<Promise<void>>())
  const burstTimeoutRef = useRef<number | null>(null)
  const lastBurstTick = useRef<number | null>(null)
  const lifetimeRef = useRef<ConfettiParticleLifetime | null>(null)
  const warmupRef = useRef<ConfettiParticleWarmup | null>(null)
  const particleSeed = useId()

  const alphaMapTexture = useTexture(CONFETTI_SPRITE_URL, (loadedTexture) => {
    // For sprite sheets, disabling mipmaps and using NearestFilter prevents
    // edge-bleeding between the 2x2 grid frames when scaled down and provides
    // a crisp look.
    loadedTexture.generateMipmaps = false
    loadedTexture.minFilter = NearestFilter
    loadedTexture.magFilter = NearestFilter
  })

  const instanceCapacity = Math.max(1, particleCount)
  const safeUpdateStride = Math.max(1, Math.floor(updateStride))

  const {
    colorNode,
    maskNode,
    opacityNode,
    positionNode,
    resetParticles,
    scaleNode,
    uniforms,
    computeNodes,
    storageAttributes,
    updatePositions,
  } = useMemo(() => {
    // Each seed is the immutable random offset for one particle.
    const seeds = new Float32Array(instanceCapacity * 2)
    const random = createConfettiRandom(particleSeed)
    for (let index = 0; index < instanceCapacity; index++) {
      seeds[index * 2] = random()
    }

    const uniforms = {
      uIsActive: uniform(bool(false)).setName('uIsActive'),
      uDtScale: uniform(float(1)).setName('uParticleDtScale'),
    }

    const colorBuffer = instancedArray(instanceCapacity, 'vec4') // w stores the frame index for spritesheet lookup
    const seedBuffer = instancedArray(seeds, 'vec2')
    const positionBuffer = instancedArray(instanceCapacity, 'vec4') // w stores the life
    const velocityBuffer = instancedArray(instanceCapacity, 'vec3')

    const resetParticles = Fn(() => {
      const initialPosition = positionBuffer.element(instanceIndex).xyz
      const life = positionBuffer.element(instanceIndex).w
      const velocity = velocityBuffer.element(instanceIndex).xyz
      const seed = seedBuffer.element(instanceIndex)

      initialPosition.assign(vec3(-1000.0, -1000.0, 0.0))
      life.assign(0.0)
      velocity.assign(vec3(0.0, 0.0, 0.0))

      const random = mx_noise_float(seed.x.add(time)).mul(0.5).add(0.5)
      seed.y.assign(mix(0.25, 1.0, random))

      const colour = colorBuffer.element(instanceIndex)
      // Hash spreads the palette evenly; a noise sample clusters around the middle entries.
      const colorIndex = hash(instanceIndex.toFloat().add(time).add(13))
        .mul(CONFETTI_PALETTE.length)
        .floor()
      const randomColor = confettiPaletteNodes.element(colorIndex) as unknown as Node<'vec3'>
      const frameIndexValue = hash(instanceIndex.add(5)).mul(4.0).floor()
      colour.assign(vec4(randomColor, frameIndexValue))
    })().compute(instanceCapacity)

    const colorNode = vertexStage(colorBuffer.element(instanceIndex).rgb)

    const textureUV = vertexStage(
      spritesheetUV(vec2(2, 2), uv(), colorBuffer.element(instanceIndex).w),
    )
    const maskNode = texture(alphaMapTexture, textureUV).r

    const positionNode = positionBuffer.toAttribute()

    const opacityNode = vertexStage(
      Fn(() => {
        const age = positionBuffer.element(instanceIndex).w.oneMinus()
        const fadeIn = smoothstep(float(0.0), float(0.1), age)
        const fadeOut = oneMinus(smoothstep(float(0.7), float(1.0), age))
        return fadeIn.mul(fadeOut)
      })(),
    )

    const scaleNode = vec2(seedBuffer.element(instanceIndex).y)

    const updatePositions = Fn(() => {
      If(uniforms.uIsActive.not(), () => {
        // Do nothing if not active
        Return()
      })
      const position = positionBuffer.element(instanceIndex).xyz
      const life = positionBuffer.element(instanceIndex).w
      const velocity = velocityBuffer.element(instanceIndex).xyz
      const seed = seedBuffer.element(instanceIndex).x
      const simulationDelta = deltaTime.mul(uniforms.uDtScale)
      const lifeDelta = simulationDelta.mul(0.8)
      const gravity = float(-24).mul(lifeDelta)

      If(life.greaterThan(float(0.0)), () => {
        const velocityChange = useTurbulenceNoise
          ? (() => {
              const noise = mx_noise_vec3(position.mul(3).add(simulationDelta))
              return vec3(noise.x, noise.y.add(gravity), noise.z)
            })()
          : (() => {
              const swayPhase = time.mul(2.4).add(seed.mul(31.7))
              const swayX = sin(swayPhase).mul(0.24)
              const swayZ = cos(swayPhase.add(seed.mul(7.1))).mul(0.24)
              return vec3(swayX, gravity, swayZ)
            })()
        // Update velocity with turbulence.
        velocity.addAssign(velocityChange)
        // Update position based on velocity and time.
        position.addAssign(velocity.mul(simulationDelta))
        // Subtract lifetime.
        life.subAssign(lifeDelta)
      })
    })().compute(instanceCapacity)

    // Spawn function: only reset particles that are dead.
    const spawnParticles = Fn(() => {
      const index = instanceIndex.toFloat().toVar()
      const position = positionBuffer.element(instanceIndex).xyz
      const life = positionBuffer.element(instanceIndex).w
      const velocity = velocityBuffer.element(instanceIndex)
      const seed = seedBuffer.element(instanceIndex).x
      const frameIndex = colorBuffer.element(instanceIndex).a

      // Spawn new particle if the current one is dead
      If(life.lessThanEqual(float(0.0)), () => {
        // Assign a random lifetime
        life.assign(mix(0.3, 2.0, hash(index.mul(3.2).add(time))))
        frameIndex.assign(hash(index.add(seed).add(time)).mul(4.0).floor())

        const noise = mx_noise_float(vec2(hash(seed.mul(4.2)), index.mul(27.1).add(time)))
          .mul(0.5)
          .add(0.5)

        // Position along the bottom with random x and z position
        const x = mix(float(-7), float(7), noise)
        const y = mix(float(-14), float(-10), hash(noise)) // Bottom of the frame
        const z = mix(float(-40), float(-2), hash(instanceIndex))

        position.assign(vec3(x, y, z))

        // Create an upward burst with a random angle
        const angle = PI.div(3) // 60 degrees

        const upwardAngle = mix(float(angle.negate()), float(angle), hash(index.add(time))) // Spread around vertical
        const velocityMagnitude = mix(16.0, 48.0, hash(instanceIndex.add(7)))
        const initialVelocity = vec3(
          sin(upwardAngle).mul(velocityMagnitude),
          cos(upwardAngle).mul(velocityMagnitude),
          float(0.0),
        )
        velocity.assign(initialVelocity)
      })
    })().compute(instanceCapacity)

    return {
      colorNode,
      maskNode,
      opacityNode,
      positionNode,
      resetParticles,
      scaleNode,
      uniforms,
      computeNodes: [resetParticles, spawnParticles, updatePositions],
      storageAttributes: [
        colorBuffer.value,
        seedBuffer.value,
        positionBuffer.value,
        velocityBuffer.value,
      ],
      updatePositions,
    }
  }, [alphaMapTexture, instanceCapacity, particleSeed, useTurbulenceNoise])

  const uniformsRef = useRef(uniforms)
  useLayoutEffect(() => {
    uniformsRef.current = uniforms
  }, [uniforms])

  useEffect(() => {
    uniformsRef.current.uDtScale.value = safeUpdateStride
  }, [safeUpdateStride, uniforms])

  const clearBurstTimeout = useCallback(() => {
    if (!burstTimeoutRef.current) return
    window.clearTimeout(burstTimeoutRef.current)
    burstTimeoutRef.current = null
  }, [])

  useLayoutEffect(() => {
    // Each committed allocation owns its lifetime; old cleanup keeps its own descriptor.
    if (lifetimeRef.current?.computeNodes !== computeNodes) {
      lifetimeRef.current = { computeNodes, generation: 0, initialized: false }
    }
    const lifetime = lifetimeRef.current
    const generation = ++lifetime.generation
    const pending = pendingCompilations.current
    return () => {
      // Only the latest setup can dispose: Strict Mode can reuse these nodes with more work pending.
      void Promise.all([...pending]).then(() => {
        if (lifetime.generation !== generation) return
        for (const node of computeNodes) node.dispose()
        // Three r186 does not listen for BufferAttribute.dispose() on compute storage buffers.
        const attributes = (
          renderer as WebGPURenderer & {
            _attributes: { delete(attribute: BufferAttribute): unknown }
          }
        )._attributes
        for (const attribute of storageAttributes) attributes.delete(attribute)
      })
    }
  }, [computeNodes, renderer, storageAttributes])

  useLayoutEffect(() => {
    const lifetime = lifetimeRef.current
    if (!lifetime) return
    const currentUniforms = uniforms
    const warmup: ConfettiParticleWarmup = { active: true, ready: false, promise: null }
    warmupRef.current = warmup
    const mesh = meshRef.current
    clearBurstTimeout()
    // A rebuilt graph owns fresh buffers, so allow the current tick to start a new burst.
    lastBurstTick.current = null
    currentUniforms.uIsActive.value = false
    if (mesh) mesh.visible = false
    let isCancelled = false

    if (particleCount > 0) {
      // Start after commit so the storage buffers exist before the first dispatch.
      const compilation = (async () => {
        // Strict Mode replays effects while the previous GPU compilation is still pending.
        await Promise.all([...pendingCompilations.current])
        if (isCancelled) return
        if (!lifetime.initialized) {
          await renderer.compileComputeAsync(computeNodes)
          if (isCancelled) return
          renderer.compute(resetParticles)
          lifetime.initialized = true
        }
        if (!isCancelled) warmup.ready = true
      })()
        .catch((error) => {
          console.warn('[ConfettiParticles] Initialization warmup failed.', error)
        })
        .finally(() => {
          pendingCompilations.current.delete(compilation)
        })
      warmup.promise = compilation
      pendingCompilations.current.add(compilation)
    }

    return () => {
      isCancelled = true
      warmup.active = false
      warmup.ready = false
      clearBurstTimeout()
      currentUniforms.uIsActive.value = false
      if (mesh) mesh.visible = false
    }
  }, [clearBurstTimeout, computeNodes, particleCount, renderer, resetParticles, uniforms])

  useEffect(() => {
    const warmup = warmupRef.current
    if (burstTick == null || particleCount <= 0 || !warmup?.promise) return
    const currentUniforms = uniforms
    let isCancelled = false
    const mesh = meshRef.current

    const runBurst = async () => {
      await warmup.promise
      if (isCancelled || !warmup.active || !warmup.ready) return
      if (lastBurstTick.current === burstTick) return

      clearBurstTimeout()
      currentUniforms.uIsActive.value = true
      updateFrameModuloRef.current = 0
      if (mesh) mesh.visible = true
      renderer.compute(computeNodes)
      lastBurstTick.current = burstTick
      burstTimeoutRef.current = window.setTimeout(() => {
        currentUniforms.uIsActive.value = false
        if (mesh) mesh.visible = false
        burstTimeoutRef.current = null
      }, BURST_DURATION_MS)
    }

    void runBurst().catch((error) => {
      currentUniforms.uIsActive.value = false
      if (mesh) mesh.visible = false
      console.warn('[ConfettiParticles] Burst failed.', error)
    })

    return () => {
      isCancelled = true
      clearBurstTimeout()
      currentUniforms.uIsActive.value = false
      if (mesh) mesh.visible = false
    }
  }, [burstTick, clearBurstTimeout, computeNodes, particleCount, renderer, uniforms])

  useFrame(() => {
    if (!renderer || !updatePositions) return
    if (particleCount <= 0) return
    if (!uniformsRef.current.uIsActive.value) return
    updateFrameModuloRef.current++
    if (updateFrameModuloRef.current < safeUpdateStride) return
    updateFrameModuloRef.current = 0
    renderer.compute(updatePositions)
  })

  return (
    <instancedMesh
      ref={meshRef}
      args={[undefined, undefined, instanceCapacity]}
      count={particleCount}
      frustumCulled={false}
      visible={false}>
      <planeGeometry args={[0.8, 0.8]} />
      <spriteNodeMaterial
        key={maskNode.uuid}
        positionNode={positionNode}
        colorNode={colorNode}
        maskNode={maskNode}
        opacityNode={opacityNode}
        scaleNode={scaleNode}
        depthTest={false}
        depthWrite={false}
        transparent={true}
      />
    </instancedMesh>
  )
}

export default ConfettiParticles

Downloads

Assets