INTERACTIVE
AUDIO DRIVEN
PARTICLE SYSTEMS
Build a particle system that reacts in real-time to music using Three.js AudioAnalyser and frequency data.
LIVE DEMO
Click Play to start the audio and watch the particles react to the music frequency in real-time.
Introduction
Three.js has revolutionized web development by bringing 3D graphics and animations to the browser. One of its most captivating features is the ability to create particle systems that respond dynamically to audio — combining the visual allure of particle effects with the real-time responsiveness of sound data.
This tutorial walks through building a complete audio-reactive particle system from scratch. You'll learn how to load audio, analyze its frequency spectrum, and map that data to particle behavior — creating a mesmerizing display that dances with the music.
Frequently Asked Questions
How do you connect Web Audio API to Three.js particles?
Create a THREE.AudioListener attached to the camera, load audio with THREE.Audio, then wrap it with THREE.AudioAnalyser. Each frame, call analyser.getFrequencyData() to get a Uint8Array of frequency amplitudes (0-255). Map these values to particle positions, sizes, or colors in your animation loop.
What is AudioAnalyser in Three.js and how does it work?
THREE.AudioAnalyser wraps the Web Audio API AnalyserNode. It performs a Fast Fourier Transform (FFT) on the audio signal every frame, splitting it into frequency bins. getFrequencyData() returns amplitudes per bin, and getAverageFrequency() returns a single average energy value you can use to drive animations.
How do you make particles react to music in real-time?
In your animation loop, read frequency data from AudioAnalyser each frame. Use the average frequency or individual bins to control particle properties — scale them up on bass hits, change colors on high frequencies, or adjust velocity based on overall energy. Update the particle BufferGeometry attributes and mark them as needsUpdate.
What is the best FFT size for audio visualization?
For particle systems, FFT size 256 or 512 works well — it provides enough frequency detail (128 or 256 bins) without excessive computation. Smaller FFT sizes update faster but have less frequency resolution. Larger sizes (1024+) give more detail but introduce latency. Match the FFT size to your visual complexity needs.
How do you detect beats in Three.js audio?
Track spectral flux in the low frequency bins: sum the positive change of each bass bin since the last frame, keep a slow running average, and fire a beat when the current flux is clearly above that average (about 1.7x) and at least ~220 ms have passed since the last beat. Use each beat to trigger shockwave rings, spark bursts or a camera punch.
How do you split audio into bass, mid and treble in Three.js?
Use analyser.getFrequencyData() and average bin ranges by frequency. Each bin is sampleRate / fftSize Hz wide, so with fftSize 1024 at 44.1 kHz roughly bins 1-4 are bass (under 160 Hz), 6-58 are mids (250 Hz-2.5 kHz) and 93-279 are treble (4-12 kHz). Smooth each band with a fast attack and slow release so visuals pulse without flicker.
Why does my Three.js audio not play until I click?
Browsers block autoplay, so the AudioContext starts suspended. Create the AudioListener and call listener.context.resume() inside the Play button's click handler, then start THREE.Audio once the buffer has loaded. Until then, show a calm idle animation.
How AudioAnalyser Works
At the heart of this demo is THREE.AudioAnalyser — a built-in Three.js class that wraps the Web Audio API's AnalyserNode. It samples the audio signal and breaks it into frequency bins using a Fast Fourier Transform (FFT).
Each frame, analyser.getFrequencyData() returns a Uint8Array where each value (0–255) represents the amplitude of a specific frequency band. This demo wraps the sound with an analyser at fftSize 1024, giving 512 bins you can slice into bass, mid and treble.
Splitting the FFT Into Bass, Mid and Treble
The analyser hands you one flat array of 512 bins, but music lives in ranges. To slice it you need the frequency each bin represents: binHz = sampleRate / fftSize, so at 44.1 kHz with fftSize 1024 every bin is about 43 Hz wide. To find the bin for a target frequency you invert that: bin = Hz * fftSize / sampleRate.
This engine averages three ranges: bass below 160 Hz (roughly bins 1–4), mids from 250 Hz to 2.5 kHz (bins ~6–58), and treble from 4 kHz to 12 kHz (bins ~93–279). Averaging a band instead of reading a single bin gives a steady energy value that drives the cube heights, color sweep and sparkle rate without jittering on one noisy bin.
Smoothing and Adaptive Gain
Raw band values flicker frame to frame, so each band gets a fast-attack, slow-release filter: it jumps up quickly when the music hits (attack around 0.5–0.6) and eases back down slowly (release around 0.06–0.15). Bass attacks hardest so kicks feel punchy, while mids release slowly so the color sweep glides instead of strobing.
On top of that sits adaptive gain (AGC): per band the engine tracks a slowly-rising floor and a slowly-falling ceiling, then remaps the raw value into 0–1 between them. A quiet acoustic track and a loud EDM drop both end up filling the full visual range, so the field always looks alive no matter what you feed it.
Beat Detection With Spectral Flux
Beats are found with spectral flux in the low end. Each frame the engine compares the current bass bins to the previous frame, sums only the positive increases (energy that just arrived), and keeps a slow running average of that flux. A beat fires when the current flux jumps above roughly 1.7x the running average and the bass itself is above its own average.
A 220 ms cooldown stops a single kick from registering several times, which keeps the beat rate musical rather than twitchy. Every detected beat is the trigger for the big visual events: a new shockwave ring, a burst of sparks, and a camera punch.
From 10,000 Meshes to One InstancedMesh
The old version created thousands of individual THREE.Mesh cubes — thousands of draw calls and a lot of CPU churn. The new engine draws the entire floor as a single InstancedMesh: an 80x80 grid on desktop (48x48 on mobile), all rendered in one call.
The trick is that nothing is animated on the CPU. Each cube's XZ position lives in its instance matrix, and the vertex shader reads that position, samples the spectrum, and scales the cube's height right on the GPU. Moving the per-cube math into the shader is what lets 6,400 cubes react to the music every frame at full speed.
Shockwave Rings From a Ring Buffer
When a beat fires, its timestamp and strength are written into a small ring buffer of six slots (NR = 6), overwriting the oldest entry. That array of six vec2 values is passed straight into the shaders as a uniform.
In the shader, each slot expands outward over time — the ring's radius grows with its age while its strength fades — so a beat becomes an expanding wave that lifts the cubes it passes and glows on the floor plane. Because it is all driven by timestamps in a uniform, up to six waves can overlap with zero extra geometry.
The Blue to Cyan to Magenta Color Sweep
Color is driven mostly by the mids. A sweep value advances over time at a rate that speeds up with mid energy, and that phase is combined with each cube's angle around the center so color rolls around the field like a slow searchlight.
The palette is a two-stage gradient: deep blue through bright cyan up to hot magenta. A mix factor built from mids and cube height picks a point on that gradient, so calm passages stay cool blue and busy, mid-heavy sections push the whole field toward magenta.
Treble Sparkles and Beat Bursts
All the sparks are one pooled THREE.Points system with a fixed capacity (1,400 on desktop, 500 on mobile) and a rolling write index, so spawning never allocates. Treble energy continuously seeds a few sparkles from random cube tops — the louder the highs, the more hi-hat shimmer.
On every detected beat the engine also fires a radial burst of dozens of sparks from the center. Each spark gets a life, velocity and hue, falls under gravity, and bounces softly off the floor, so kicks read as a physical pop of light instead of just a flash.
Camera Punch: FOV Zoom and Micro-Shake
To make beats felt, the camera reacts too. Each beat adds to a punch value that decays quickly; while it is high the field of view narrows by up to about 7 degrees, giving a snappy zoom-in, then eases back out.
A tiny random shake is layered on the camera position scaled by that same punch. Because OrbitControls still owns the orbit, the shake is added after controls.update() and subtracted at the start of the next frame, so the jolt never fights the orbit or drifts the camera off target.
Fake Bloom With Additive Glow
There is no post-processing bloom pass. Instead, glow is faked with extra additive Points drawn on top of the tall cubes and a central core sprite, all using additive blending so overlapping light sums up into bright hot spots.
Because the canvas sits behind a semi-transparent page, the blend is set up alpha-safe: color adds while alpha is handled separately, so the glow brightens the scene without punching holes in the page overlay. The payoff is a bloom-like look for a few cheap draw calls instead of a full render target.
Autoplay Rules: Resuming the AudioContext
Browsers start every AudioContext suspended and will not let it make sound until a real user gesture. That is why audio cannot just start on load.
The fix is to call listener.context.resume() inside the Play button's click handler, then load the buffer and start THREE.Audio once it is ready. Before that click the field runs a calm idle animation, so the scene is never blank while it waits for permission to play.
Performance: Phones, DPR and Draw Calls
The engine scales itself down on mobile: the grid drops from 80x80 to 48x48, the spark pool from 1,400 to 500, antialiasing turns off, and the device pixel ratio is capped at 1.5 (2 on desktop) so a Retina phone is not forced to shade four times the pixels.
It also pauses the loop on visibilitychange when the tab is hidden, so a backgrounded tab costs nothing. The whole field renders in just six draw calls (grid, cube field, cube glow, shockwave floor, sparks, core glow), which you can confirm live via audioParticles.drawCalls() in the console.
Particle Behavior & Color Mapping
Each cube sits flat on the grid until the music lifts it. The vertex shader samples the spectrum at that cube's location and the current bass pulse, then scales its height on the GPU so the whole 80x80 field ripples in sync with the track.
Color comes from the shader's mid-driven sweep: calm sections glow cool blue, and as mids build the field climbs the blue→cyan→magenta gradient, with the tallest, most energetic cubes burning hottest. This creates a vivid, continuously moving map of the audio spectrum.
Use Cases
Audio-reactive particle systems have many practical applications:
- Music visualizers — create stunning visuals that sync with any audio track
- Game effects — sync explosions, ambient effects, or UI feedback to game audio
- Live events — real-time visual displays for concerts or DJ sets
- Marketing — captivating product showcases with audio-driven animations
Conclusion
Three.js makes it surprisingly straightforward to bridge audio and 3D visuals. By combining THREE.AudioLoader, THREE.Audio, and THREE.AudioAnalyser, you can build rich interactive experiences that respond to sound in real-time.
The techniques shown here — banded analysis, adaptive gain, spectral-flux beat detection, GPU-driven InstancedMesh heights, and additive glow — are reusable patterns you can apply to any audio-reactive project.
Step 1: Renderer, Scene and Orbit Camera
Everything starts in create(). It builds a THREE.Scene, a 60-degree PerspectiveCamera pulled back to (7.5, 5.5, 7.5), and an alpha WebGLRenderer whose canvas is pinned behind the page at z-index -1. OrbitControls auto-rotates slowly so the field is always moving even before any audio plays.
var scene = new T.Scene();
var camera = new T.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 1000);
camera.position.set(7.5, 5.5, 7.5);
var renderer = new T.WebGLRenderer({ antialias: !isMobile, alpha: true });
var st = renderer.domElement.style; st.position = 'fixed'; st.zIndex = '-1';
var controls = new T.OrbitControls(camera, renderer.domElement);
controls.autoRotate = true; controls.autoRotateSpeed = 0.6; controls.enableDamping = true;
Step 2: The Cube Field as One InstancedMesh
The entire floor is one InstancedMesh of GRID x GRID cubes (80x80 on desktop, 48x48 on mobile). Each cube's XZ position is baked into its instance matrix once, up front. Because the box is translated so its base sits at y = 0, scaling its height in the shader makes it grow up out of the floor, never through it.
var cell = FIELD / GRID, N = GRID * GRID;
var box = new T.BoxGeometry(cell * 0.72, 1, cell * 0.72); box.translate(0, 0.5, 0);
var cubes = new T.InstancedMesh(box, cubeMat, N);
var m = new T.Matrix4(), k = 0;
for (var gx = 0; gx < GRID; gx++) for (var gz = 0; gz < GRID; gz++) {
m.makeTranslation(-FIELD / 2 + (gx + 0.5) * cell, 0, -FIELD / 2 + (gz + 0.5) * cell);
cubes.setMatrixAt(k++, m);
}
cubes.frustumCulled = false; scene.add(cubes);
Step 3: Heights and Colour in the Vertex Shader
Nothing is animated on the CPU. The vertex shader reads each cube's XZ from instanceMatrix, calls fieldH(p) to turn the spectrum into a height, and scales pos.y by it. fieldCol(p, h) picks the colour. Moving this math onto the GPU is what lets 6,400 cubes react every frame.
// vertex shader (string), compiled into cubeMat vec2 p = vec2(instanceMatrix[3].x, instanceMatrix[3].z); float h = max(fieldH(p), 0.02); // spectrum -> height vec3 pos = position; pos.y *= h; pos.xz += p; vCol = fieldCol(p, h); // blue->cyan->magenta gl_Position = projectionMatrix * viewMatrix * vec4(pos, 1.0);
Step 4: AudioAnalyser and the Bass / Mid / Treble Split
On Play the engine wraps the sound in a THREE.AudioAnalyser at fftSize 1024. Each frame, getFrequencyData() returns the bins. Every bin is binHz = sampleRate / fftSize wide (about 43 Hz), so to find the bin for a target frequency you invert it: bin = Hz * fftSize / sampleRate. The engine averages three ranges: bass under 160 Hz, mids 250 Hz to 2.5 kHz, treble 4 to 12 kHz.
var hz = 22050 / n; // ~bin width at 44.1 kHz (n = fftSize/2) var bRaw = bandAvg(data, 1, Math.max(2, Math.round(160 / hz))); var mRaw = bandAvg(data, Math.round(250 / hz), Math.round(2500 / hz)); var tRaw = bandAvg(data, Math.round(4000 / hz), Math.min(n, Math.round(12000 / hz)));
Step 5: Fast-Attack / Slow-Release Smoothing + Adaptive Gain
Raw bands flicker, so each one jumps up fast and eases down slow — bass attacks hardest so kicks feel punchy. On top of that, agc() tracks a slowly-rising floor and slowly-falling ceiling per band and remaps the value into 0–1, so a quiet track and a loud one both fill the full visual range.
function agc(i, v) {
lo[i] = Math.min(v, lo[i] + 0.0015);
hi[i] = Math.max(v, hi[i] - 0.0012, lo[i] + 0.06);
return Math.max(0, Math.min(1, (v - lo[i]) / (hi[i] - lo[i])));
}
// attack fast, release slow
A.bass += (nb - A.bass) * (nb > A.bass ? 0.6 : 0.12);
A.mid += (nm - A.mid) * (nm > A.mid ? 0.35 : 0.06);
A.treble += (nt - A.treble) * (nt > A.treble ? 0.5 : 0.15);
Step 6: Beat Detection via Spectral Flux
Beats come from positive spectral flux in the low end: sum only the bins that got louder since last frame, keep a slow running average of that flux, and fire when the current flux is clearly above it (about 1.7x) while the bass is above its own average. A 220 ms cooldown stops one kick from registering several times.
var flux = 0, lim = Math.round(300 / hz) + 1;
if (A.prev) for (var i = 1; i < lim; i++) { var df = data[i] - A.prev[i]; if (df > 0) flux += df; }
A.prev = Uint8Array.from(data); flux /= lim * 255;
A.fluxAvg += (flux - A.fluxAvg) * 0.05;
var beat = false;
if (flux > A.fluxAvg * 1.7 + 0.012 && nb > A.bassAvg * 1.05 && t - A.lastBeat > 0.22) {
beat = true; A.lastBeat = t;
}
Step 7: Shockwave Rings as Shader Uniforms
Each beat writes its timestamp and strength into a six-slot ring buffer (NR = 6) passed to the shaders as a uRings uniform. In the shader each slot expands outward with age and fades, so up to six waves overlap with zero extra geometry — they lift the cubes they pass and glow on the floor plane.
function onBeat(strength) {
var r = U.uRings.value[A.ringIdx];
r.set(t, 0.6 + 0.9 * strength); // x = start time, y = strength
A.ringIdx = (A.ringIdx + 1) % NR;
punch = Math.min(1, punch + 0.55 + 0.4 * strength);
}
// in GLSL:
// float age = uTime - uRings[i].x;
// float w = d - age * 4.2; h += uRings[i].y * exp(-w*w*3.0) * exp(-age*1.1);
Step 8: Sparks and the Blue->Cyan->Magenta Sweep
All sparks live in one pooled THREE.Points system with a rolling write index, so spawning never allocates. Treble continuously seeds sparkles; every beat fires a radial burst. Colour is a two-stage gradient driven by mids and height: calm stays cool blue, busy sections push toward hot magenta.
function spark(x, y, z, vx, vy, vz, life, hue) {
var j = sIdx; sIdx = (sIdx + 1) % MAX_SPARKS; // rolling pool, no allocation
sp[j*3] = x; sp[j*3+1] = y; sp[j*3+2] = z;
sv[j*3] = vx; sv[j*3+1] = vy; sv[j*3+2] = vz;
sl[j] = sm[j] = life; sc[j] = hue;
}
// GLSL colour: k < 0.5 ? mix(blue, cyan, k*2.0) : mix(cyan, mag, (k-0.5)*2.0)
Step 9: Camera Punch over OrbitControls
To make beats felt, each beat adds to a fast-decaying punch that narrows the FOV by up to ~7 degrees and adds a tiny random shake. Because OrbitControls still owns the orbit, the shake is added after controls.update() and subtracted at the start of the next frame, so it never fights the orbit or drifts the target.
camera.position.sub(shake); // remove last frame's shake first controls.update(); punch *= Math.exp(-dt * 7); camera.fov = BASE_FOV - punch * 7; camera.updateProjectionMatrix(); shake.set(Math.random()-0.5, Math.random()-0.5, Math.random()-0.5).multiplyScalar(0.09 * punch); camera.position.add(shake); // apply after controls own the orbit
Step 10: The play() / stop() API and Autoplay Rules
Browsers start every AudioContext suspended, so play() calls listener.context.resume() inside the click, loads the buffer once, then starts looping and flips wantActive on. create() returns { play, stop, ... } so you wire it to your own buttons.
function play() {
if (!listener) { listener = new T.AudioListener(); camera.add(listener); sound = new T.Audio(listener); }
var ctx = listener.context; if (ctx.state === 'suspended') ctx.resume(); // inside the gesture
function go() {
sound.setBuffer(buffer); sound.setLoop(true); sound.setVolume(0.9); sound.play();
if (!analyser) { analyser = new T.AudioAnalyser(sound, 1024); }
wantActive = 1;
}
if (buffer) go();
else new T.AudioLoader().load(url, function (b) { buffer = b; go(); });
}
var app = AudioParticles.create({ audioUrl: '../sounds/threejs-drum-particles-001.mp3' });
FULL CODE
The complete, self-contained demo below runs on its own — it loads Three.js from a CDN, inlines the whole engine, and streams the audio from an absolute URL. Copy it into a .html file, or hit Launch to open it in a new tab.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Interactive Audio-Driven Particle System</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
html, body { margin: 0; height: 100%; background: #000; overflow: hidden; font-family: sans-serif; }
#ui { position: fixed; z-index: 10; left: 50%; bottom: 24px; transform: translateX(-50%); display: flex; gap: 12px; }
#ui button {
padding: 12px 28px; background: #2ad1d6; color: #000; border: none;
border-radius: 9999px; font-size: 1rem; cursor: pointer; box-shadow: 0 0 20px rgba(42,209,214,0.5);
}
#ui #stopBtn { display: none; }
</style>
</head>
<body>
<div id="ui">
<button id="playBtn">Play Audio</button>
<button id="stopBtn">Stop Audio</button>
</div>
<!-- Three.js pinned: engine is a classic script needing global THREE + THREE.OrbitControls (r160) -->
<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.147.0/examples/js/controls/OrbitControls.js"></script>
<!-- ===== Audio-reactive VFX engine (inlined verbatim) ===== -->
<script>
/*! audio-particles.js v1.0 (2026-10-26). Audio-reactive VFX field for
* /tutorials/interactive-audio-driven-particle-systems.html
* Classic script: needs global THREE (three.min.js r150-r160) + THREE.OrbitControls.
* Draw calls: grid + cube field (InstancedMesh) + cube-top glow (Points) + shockwave floor
* + sparks (Points) + core glow = 6. No lights, no post-processing.
*/
(function () {
'use strict';
// THREE is read lazily inside create() so this engine can be inlined and launched
// in a popup where the CDN three.min.js loads asynchronously (window.THREE is not
// defined at parse time). create() is only called after THREE is confirmed loaded.
var isMobile = /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent) || Math.min(innerWidth, innerHeight) < 600;
var GRID = isMobile ? 48 : 80, FIELD = 10, NB = 32, NR = 6, MAX_SPARKS = isMobile ? 500 : 1400;
// ---------- shared GLSL: field height + colour ----------
var FIELD_GLSL = [
'uniform float uTime, uActive, uBass, uMid, uTreble, uSweep, uPulse;',
'uniform float uSpec[' + NB + '];',
'uniform vec2 uRings[' + NR + '];', // x = start time, y = strength
'const float FIELD = ' + FIELD.toFixed(1) + ';',
'float ringAt(float d){ float h=0.0; for(int i=0;i<' + NR + ';i++){ float age=uTime-uRings[i].x;',
' if(age>0.0 && age<3.0){ float w=d-age*4.2; h+=uRings[i].y*exp(-w*w*3.0)*exp(-age*1.1);} } return h; }',
'float fieldH(vec2 p){',
' float r=length(p)/(FIELD*0.5*1.414);',
' float idle=0.05+0.05*sin(p.x*1.1+uTime*0.9)*sin(p.y*0.9+uTime*0.7);',
' int bi=int(clamp(r*float(' + NB + '),0.0,float(' + (NB - 1) + ')));',
' float s=uSpec[bi];',
' float spec=s*s*1.5*(0.6+0.7*uBass)*(1.0-0.35*r);',
' float pulse=uPulse*0.9*exp(-r*r*6.0);', // bass punch in the centre
' float ring=ringAt(length(p))*0.8;',
' return idle*(1.0-0.6*uActive)+uActive*(spec+pulse)+ring; }',
'vec3 fieldCol(vec2 p,float h){',
' float ang=atan(p.y,p.x);',
' float k=clamp(0.08+uMid*0.6*(0.55+0.45*sin(ang*2.0+uSweep))+h*0.16,0.0,1.0);',
' vec3 blue=vec3(0.08,0.25,1.0), cyan=vec3(0.1,0.95,1.0), mag=vec3(1.0,0.2,0.95);',
' vec3 c=k<0.5?mix(blue,cyan,k*2.0):mix(cyan,mag,(k-0.5)*2.0);',
' return c; }'
].join('\n');
function create(opts) {
opts = opts || {};
var T = window.THREE;
var ADD = { blending: T.CustomBlending, blendSrc: T.OneFactor, blendDst: T.OneFactor, blendEquation: T.AddEquation,
blendSrcAlpha: T.ZeroFactor, blendDstAlpha: T.OneFactor, blendEquationAlpha: T.AddEquation, transparent: true, depthWrite: false };
var scene = new T.Scene();
var camera = new T.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 1000);
camera.position.set(7.5, 5.5, 7.5);
var BASE_FOV = 60;
var renderer = new T.WebGLRenderer({ antialias: !isMobile, alpha: true, powerPreference: 'high-performance' });
var PR = Math.min(window.devicePixelRatio || 1, isMobile ? 1.5 : 2);
renderer.setPixelRatio(PR);
renderer.setSize(innerWidth, innerHeight);
renderer.setClearColor(0x000000, 1);
var st = renderer.domElement.style; st.position = 'fixed'; st.zIndex = '-1'; st.left = '0'; st.top = '0';
renderer.domElement.id = renderer.domElement.id || 'renderer';
(opts.container || document.body).appendChild(renderer.domElement);
var grid = new T.GridHelper(FIELD * 1.6, 32, 0x12304a, 0x0b1a2a); grid.position.y = -0.01; scene.add(grid);
var controls = new T.OrbitControls(camera, renderer.domElement);
controls.autoRotate = true; controls.autoRotateSpeed = 0.6; controls.enableDamping = true;
controls.target.set(0, 0.6, 0); controls.update();
var U = {
uTime: { value: 0 }, uActive: { value: 0 }, uBass: { value: 0 }, uMid: { value: 0 }, uTreble: { value: 0 },
uSweep: { value: 0 }, uPulse: { value: 0 },
uSpec: { value: new Array(NB).fill(0) },
uRings: { value: Array.from({ length: NR }, function () { return new T.Vector2(-99, 0); }) }
};
// ---------- cube field: ONE InstancedMesh ----------
var cell = FIELD / GRID, N = GRID * GRID;
var box = new T.BoxGeometry(cell * 0.72, 1, cell * 0.72); box.translate(0, 0.5, 0); // base at y=0, scaled by height
var cubeMat = new T.ShaderMaterial({
uniforms: U,
vertexShader: FIELD_GLSL + '\n' + [
'varying vec3 vCol; varying float vH; varying float vTop;',
'void main(){',
' vec2 p=vec2(instanceMatrix[3].x,instanceMatrix[3].z);',
' float h=max(fieldH(p),0.02);',
' vec3 pos=position; pos.y*=h; pos.xz+=p;',
' vH=h; vTop=step(0.5,normal.y)+0.35*step(0.5,abs(normal.x)+abs(normal.z))*position.y;',
' vCol=fieldCol(p,h);',
' gl_Position=projectionMatrix*viewMatrix*vec4(pos,1.0); }'].join('\n'),
fragmentShader: [
'uniform float uActive; varying vec3 vCol; varying float vH; varying float vTop;',
'void main(){ float e=0.28+0.25*uActive+min(vH,3.0)*0.25;', // emissive: taller = hotter
' vec3 c=vCol*e*(0.45+0.75*vTop); c+=vec3(1.0)*pow(clamp(vH-2.0,0.0,2.0)*0.35,2.0)*vTop;',
' gl_FragColor=vec4(c,1.0); }'].join('\n')
});
var cubes = new T.InstancedMesh(box, cubeMat, N);
var m = new T.Matrix4(), k = 0;
for (var gx = 0; gx < GRID; gx++) for (var gz = 0; gz < GRID; gz++) {
m.makeTranslation(-FIELD / 2 + (gx + 0.5) * cell, 0, -FIELD / 2 + (gz + 0.5) * cell); cubes.setMatrixAt(k++, m);
}
cubes.frustumCulled = false; scene.add(cubes);
// ---------- cube-top glow (fake bloom), ONE Points ----------
var gpos = new Float32Array(N * 3);
for (k = 0; k < N; k++) { cubes.getMatrixAt(k, m); gpos[k * 3] = m.elements[12]; gpos[k * 3 + 2] = m.elements[14]; }
var gg = new T.BufferGeometry(); gg.setAttribute('position', new T.BufferAttribute(gpos, 3));
var glowMat = new T.ShaderMaterial(Object.assign({
uniforms: Object.assign({ uScale: { value: 1 }, uMaxPx: { value: isMobile ? 36 : 56 } }, U),
vertexShader: FIELD_GLSL + '\n' + [
'uniform float uScale, uMaxPx; varying vec3 vCol; varying float vI;',
'void main(){ vec2 p=position.xz; float h=fieldH(p);',
' vI=clamp((h-0.5)*0.5,0.0,1.0)*(0.25+0.75*uActive); vCol=fieldCol(p,h);',
' vec4 mv=viewMatrix*vec4(p.x,h,p.y,1.0);',
' gl_PointSize=vI<=0.0?0.0:min((0.35+0.5*vI)*uScale/-mv.z,uMaxPx);',
' gl_Position=projectionMatrix*mv; }'].join('\n'),
fragmentShader: 'varying vec3 vCol; varying float vI; void main(){ vec2 q=gl_PointCoord*2.0-1.0; float d=dot(q,q); if(d>1.0) discard;' +
' gl_FragColor=vec4(vCol*exp(-d*4.0)*vI*0.28,1.0); }'
}, ADD));
var glow = new T.Points(gg, glowMat); glow.frustumCulled = false; scene.add(glow);
// ---------- shockwave floor rings, ONE plane ----------
var ringMat = new T.ShaderMaterial(Object.assign({
uniforms: U,
vertexShader: 'varying vec2 vP; void main(){ vP=position.xy; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}',
fragmentShader: FIELD_GLSL + '\nvarying vec2 vP; void main(){ float d=length(vP); float h=ringAt(d);' +
' float edge=smoothstep(FIELD*0.95,FIELD*0.55,d);' +
' vec3 c=mix(vec3(0.1,0.9,1.0),vec3(1.0,0.25,0.95),clamp(uMid*1.2,0.0,1.0));' +
' gl_FragColor=vec4(c*h*0.9*edge+vec3(0.05,0.2,0.4)*uPulse*0.25*exp(-d*d*0.08),1.0); }'
}, ADD));
var ringPlane = new T.Mesh(new T.PlaneGeometry(FIELD * 2, FIELD * 2), ringMat);
ringPlane.rotation.x = -Math.PI / 2; ringPlane.position.y = 0.005; scene.add(ringPlane);
// ---------- sparks (treble sparkle + beat bursts), ONE Points ----------
var sp = new Float32Array(MAX_SPARKS * 3), sv = new Float32Array(MAX_SPARKS * 3), sl = new Float32Array(MAX_SPARKS),
sm = new Float32Array(MAX_SPARKS), sh = new Float32Array(MAX_SPARKS), sc = new Float32Array(MAX_SPARKS), sIdx = 0;
var sg = new T.BufferGeometry();
sg.setAttribute('position', new T.BufferAttribute(sp, 3).setUsage(T.DynamicDrawUsage));
sg.setAttribute('aLife', new T.BufferAttribute(sh, 1).setUsage(T.DynamicDrawUsage));
sg.setAttribute('aHue', new T.BufferAttribute(sc, 1).setUsage(T.DynamicDrawUsage));
var sparkMat = new T.ShaderMaterial(Object.assign({
uniforms: { uScale: { value: 1 }, uTime: U.uTime },
vertexShader: 'attribute float aLife; attribute float aHue; uniform float uScale; varying float vL; varying float vH;' +
'void main(){ vL=aLife; vH=aHue; vec4 mv=modelViewMatrix*vec4(position,1.0);' +
' gl_PointSize=aLife<=0.0?0.0:min(0.09*uScale/-mv.z*(0.6+aLife),22.0); gl_Position=projectionMatrix*mv; }',
fragmentShader: 'varying float vL; varying float vH; void main(){ vec2 q=gl_PointCoord*2.0-1.0; float d=dot(q,q); if(d>1.0) discard;' +
' vec3 c=mix(vec3(0.6,1.0,1.0),vec3(1.0,0.5,1.0),vH); float a=(exp(-d*5.0)+0.8*exp(-d*40.0))*vL*1.3;' +
' gl_FragColor=vec4(c*a,1.0); }'
}, ADD));
var sparks = new T.Points(sg, sparkMat); sparks.frustumCulled = false; scene.add(sparks);
function spark(x, y, z, vx, vy, vz, life, hue) {
var j = sIdx; sIdx = (sIdx + 1) % MAX_SPARKS;
sp[j * 3] = x; sp[j * 3 + 1] = y; sp[j * 3 + 2] = z; sv[j * 3] = vx; sv[j * 3 + 1] = vy; sv[j * 3 + 2] = vz;
sl[j] = sm[j] = life; sc[j] = hue;
}
// ---------- core glow sprite ----------
var cg = new T.BufferGeometry(); cg.setAttribute('position', new T.BufferAttribute(new Float32Array([0, 0.8, 0]), 3));
var coreMat = new T.ShaderMaterial(Object.assign({
uniforms: { uScale: { value: 1 }, uPulse: U.uPulse, uMid: U.uMid, uActive: U.uActive },
vertexShader: 'uniform float uScale; void main(){ vec4 mv=modelViewMatrix*vec4(position,1.0); gl_PointSize=min(9.0*uScale/-mv.z,900.0); gl_Position=projectionMatrix*mv; }',
fragmentShader: 'uniform float uPulse,uMid,uActive; void main(){ vec2 q=gl_PointCoord*2.0-1.0; float d=dot(q,q); if(d>1.0) discard;' +
' vec3 c=mix(vec3(0.1,0.5,1.0),vec3(0.9,0.2,1.0),clamp(uMid,0.0,1.0)); float a=exp(-d*3.5)*(1.0-d)*(0.08+0.45*uPulse)*(0.4+0.6*uActive);' +
' gl_FragColor=vec4(c*a,1.0); }'
}, ADD));
var core = new T.Points(cg, coreMat); core.frustumCulled = false; scene.add(core);
function resize() {
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); renderer.setSize(innerWidth, innerHeight);
var s = innerHeight * PR / (2 * Math.tan(BASE_FOV * Math.PI / 360));
glowMat.uniforms.uScale.value = sparkMat.uniforms.uScale.value = coreMat.uniforms.uScale.value = s;
}
window.addEventListener('resize', resize); resize();
// ---------- audio analysis: bands, smoothing, adaptive gain, beat/onset ----------
var A = { bass: 0, mid: 0, treble: 0, bassAvg: 0.2, flux: 0, fluxAvg: 0.02, lastBeat: -9, prev: null, peak: [0.3, 0.3, 0.3], ringIdx: 0, beats: 0 };
var bandEdges = null, lo = new Float32Array(NB + 3).fill(1), hi = new Float32Array(NB + 3).fill(0);
function agc(i, v) { lo[i] = Math.min(v, lo[i] + 0.0015); hi[i] = Math.max(v, hi[i] - 0.0012, lo[i] + 0.06); return Math.max(0, Math.min(1, (v - lo[i]) / (hi[i] - lo[i]))); }
function bandAvg(d, a, b) { var s = 0; for (var i = a; i < b; i++) s += d[i]; return s / (b - a) / 255; }
function analyse(data, t, dt) {
var n = data.length; // bins (fftSize/2)
if (!bandEdges) { // log-spaced 32 visual bands
bandEdges = []; for (var b = 0; b <= NB; b++) bandEdges.push(Math.max(1, Math.round(Math.pow(n * 0.7, b / NB))));
}
var hz = 22050 / n; // ~bin width at 44.1 kHz
var bRaw = bandAvg(data, 1, Math.max(2, Math.round(160 / hz)));
var mRaw = bandAvg(data, Math.round(250 / hz), Math.round(2500 / hz));
var tRaw = bandAvg(data, Math.round(4000 / hz), Math.min(n, Math.round(12000 / hz)));
var raws = [bRaw, mRaw, tRaw];
// adaptive floor/ceiling per band so any track uses the full 0..1 range
var nb = agc(NB, bRaw), nm = agc(NB + 1, mRaw), nt = agc(NB + 2, tRaw);
// attack fast, release slow
A.bass += (nb - A.bass) * (nb > A.bass ? 0.6 : 0.12);
A.mid += (nm - A.mid) * (nm > A.mid ? 0.35 : 0.06);
A.treble += (nt - A.treble) * (nt > A.treble ? 0.5 : 0.15);
// onset: positive spectral flux in the low end
var flux = 0, lim = Math.round(300 / hz) + 1;
if (A.prev) for (var i = 1; i < lim; i++) { var df = data[i] - A.prev[i]; if (df > 0) flux += df; }
A.prev = Uint8Array.from(data);
flux /= lim * 255;
A.fluxAvg += (flux - A.fluxAvg) * 0.05;
A.bassAvg += (nb - A.bassAvg) * 0.04;
var beat = false;
if (flux > A.fluxAvg * 1.7 + 0.012 && nb > A.bassAvg * 1.05 && t - A.lastBeat > 0.22) { beat = true; A.lastBeat = t; A.beats++; }
for (b = 0; b < NB; b++) {
var v = agc(b, bandAvg(data, bandEdges[b], Math.max(bandEdges[b + 1], bandEdges[b] + 1)));
var cur = U.uSpec.value[b];
U.uSpec.value[b] = cur + (v - cur) * (v > cur ? 0.55 : 0.12);
}
return beat;
}
// ---------- per-frame update (shared by live and offline/headless render) ----------
var punch = 0, shake = new T.Vector3(), active = 0, wantActive = 0, t = 0;
function onBeat(strength) {
var r = U.uRings.value[A.ringIdx]; r.set(t, 0.6 + 0.9 * strength); A.ringIdx = (A.ringIdx + 1) % NR;
punch = Math.min(1, punch + 0.55 + 0.4 * strength);
var n = isMobile ? 25 : 60;
for (var i = 0; i < n; i++) {
var a = Math.random() * 6.283, s = 1.5 + Math.random() * 3.5;
spark(Math.cos(a) * 0.3, 0.6, Math.sin(a) * 0.3, Math.cos(a) * s, 2.5 + Math.random() * 4, Math.sin(a) * s, 0.8 + Math.random() * 0.6, Math.random());
}
}
function update(dt, data) {
t += dt; U.uTime.value = t;
active += (wantActive - active) * Math.min(1, dt * 2.5); U.uActive.value = active;
var beat = false;
if (data && wantActive) beat = analyse(data, t, dt);
else { A.bass *= 0.9; A.mid *= 0.95; A.treble *= 0.9; for (var b = 0; b < NB; b++) U.uSpec.value[b] *= 0.9; }
U.uBass.value = A.bass; U.uMid.value = A.mid; U.uTreble.value = A.treble;
U.uSweep.value += dt * (0.3 + A.mid * 3.0);
U.uPulse.value = Math.max(U.uPulse.value * Math.exp(-dt * 6), A.bass * A.bass * active);
if (beat) onBeat(A.bass);
// treble sparkle: spawn from random cell tops
var nT = Math.floor(A.treble * A.treble * (isMobile ? 6 : 14) * active + (Math.random() < 0.15 ? 1 : 0));
for (var i = 0; i < nT; i++) {
var x = (Math.random() - 0.5) * FIELD, z = (Math.random() - 0.5) * FIELD;
spark(x, 0.2 + Math.random() * (0.5 + A.bass * 2.0), z, (Math.random() - 0.5) * 0.3, 0.6 + Math.random() * 1.6, (Math.random() - 0.5) * 0.3, 0.5 + Math.random() * 0.7, 0.3 + 0.7 * A.mid);
}
for (var j = 0; j < MAX_SPARKS; j++) {
if (sl[j] <= 0) { sh[j] = 0; continue; }
var j3 = j * 3; sl[j] -= dt; sv[j3 + 1] -= 4.5 * dt;
sp[j3] += sv[j3] * dt; sp[j3 + 1] += sv[j3 + 1] * dt; sp[j3 + 2] += sv[j3 + 2] * dt;
if (sp[j3 + 1] < 0) { sp[j3 + 1] = 0; sv[j3 + 1] *= -0.35; }
sh[j] = Math.max(0, sl[j] / sm[j]);
}
sg.attributes.position.needsUpdate = sg.attributes.aLife.needsUpdate = sg.attributes.aHue.needsUpdate = true;
// camera punch: zoom + micro-shake (applied after controls, removed before next update)
camera.position.sub(shake);
controls.autoRotateSpeed = 0.6 + active * (0.6 + A.mid * 1.5);
controls.update();
punch *= Math.exp(-dt * 7);
camera.fov = BASE_FOV - punch * 7; camera.updateProjectionMatrix();
shake.set((Math.random() - 0.5), (Math.random() - 0.5), (Math.random() - 0.5)).multiplyScalar(0.09 * punch);
camera.position.add(shake);
renderer.render(scene, camera);
}
// ---------- live audio (Play/Stop hooks; AudioContext resumed inside the click) ----------
var listener = null, sound = null, analyser = null, buffer = null, loading = false, last = performance.now(), paused = false, manual = false;
var url = opts.audioUrl || '../sounds/threejs-drum-particles-001.mp3';
function play() {
if (!listener) { listener = new T.AudioListener(); camera.add(listener); sound = new T.Audio(listener); }
var ctx = listener.context; if (ctx.state === 'suspended') ctx.resume(); // must happen in the user gesture
function go() {
if (sound.isPlaying) sound.stop();
sound.setBuffer(buffer); sound.setLoop(true); sound.setVolume(0.9); sound.play();
if (!analyser) { analyser = new T.AudioAnalyser(sound, 1024); analyser.analyser.smoothingTimeConstant = 0.55; }
wantActive = 1;
}
if (buffer) go();
else if (!loading) { loading = true; new T.AudioLoader().load(url, function (b) { buffer = b; loading = false; go(); }, undefined, function (e) { loading = false; console.error('[audio-particles] audio load failed', e); }); }
}
function stop() { if (sound && sound.isPlaying) sound.stop(); wantActive = 0; }
var startBtn = opts.startButton, stopBtn = opts.stopButton;
if (startBtn) startBtn.addEventListener('click', function () { play(); startBtn.style.display = 'none'; if (stopBtn) stopBtn.style.display = 'inline-block'; });
if (stopBtn) stopBtn.addEventListener('click', function () { stop(); stopBtn.style.display = 'none'; if (startBtn) startBtn.style.display = 'inline-block'; });
document.addEventListener('visibilitychange', function () { paused = document.hidden; last = performance.now(); });
function loop() {
requestAnimationFrame(loop);
if (paused || manual) return;
var now = performance.now(), dt = Math.min(0.05, (now - last) / 1000); last = now;
update(dt, analyser && wantActive ? analyser.getFrequencyData() : null);
}
loop();
var api = {
scene: scene, camera: camera, renderer: renderer, controls: controls, play: play, stop: stop, state: A,
drawCalls: function () { return renderer.info.render.calls; },
// headless/offline: drive frames yourself with a byte spectrum (same format as getByteFrequencyData)
manualStep: function (dt, bytes) { manual = true; wantActive = bytes ? 1 : 0; update(dt, bytes); }
};
console.log('[audio-particles] v1.0 ready - THREE r' + T.REVISION + ', cubes=' + N + (isMobile ? ' (mobile)' : ''));
return api;
}
window.AudioParticles = { create: create, version: '1.0' };
})();
</script>
<!-- ===== Bootstrap: wait for THREE + OrbitControls, then create the scene and wire the buttons ===== -->
<script>
(function () {
function start() {
var app = AudioParticles.create({
audioUrl: 'https://www.shanebrumback.com/sounds/threejs-drum-particles-001.mp3'
});
var playBtn = document.getElementById('playBtn');
var stopBtn = document.getElementById('stopBtn');
playBtn.addEventListener('click', function () {
app.play(); // resumes the AudioContext inside the user gesture
playBtn.style.display = 'none';
stopBtn.style.display = 'inline-block';
});
stopBtn.addEventListener('click', function () {
app.stop();
stopBtn.style.display = 'none';
playBtn.style.display = 'inline-block';
});
}
// In a popup opened via document.write the CDN scripts may still be loading,
// so wait until THREE, OrbitControls and the engine are all ready.
(function ready() {
if (window.THREE && window.THREE.OrbitControls && window.AudioParticles) { start(); }
else { setTimeout(ready, 40); }
})();
})();
</script>
</body>
</html>