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-particlesImplementation
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