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