Grouping all dots together for high speed
Creating individual HTML elements for thousands of dots makes the browser painfully slow. Instead, we bundle all the dot coordinates into one single mathematical list. This lets the browser draw thousands of particles all at once without breaking a sweat.
import React, { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
const PARTICLE_COUNT = 5000;
export const SoundParticles = () => {
const pointsRef = useRef<THREE.Points>(null);
const geometryRef = useRef<THREE.BufferGeometry>(null);
return (
<points ref={pointsRef}>
<bufferGeometry ref={geometryRef}>
<bufferAttribute
attach="attributes-position"
count={PARTICLE_COUNT}
array={new Float32Array(PARTICLE_COUNT * 3)}
itemSize={3}
/>
</bufferGeometry>
<pointsMaterial
size={0.005}
color="#ffffff"
transparent
opacity={0.6}
blending={THREE.AdditiveBlending}
depthWrite={false}
/>
</points>
);
};Work out the shapes before starting
Calculating where every single dot should go while the animation is running causes annoying lags. To keep it lag-free, calculate the shapes (like a cube or a sphere) once when the page loads, and then simply transition between them.
const { shapeA, shapeB, initialArray } = useMemo(() => {
const a = new Float32Array(PARTICLE_COUNT * 3);
const b = new Float32Array(PARTICLE_COUNT * 3);
for (let i = 0; i < PARTICLE_COUNT; i++) {
// Shape A: Random Cube
a[i * 3] = (Math.random() - 0.5) * 2;
a[i * 3 + 1] = (Math.random() - 0.5) * 2;
a[i * 3 + 2] = (Math.random() - 0.5) * 2;
// Shape B: Sphere Coordinate Distro
const u = Math.random();
const v = Math.random();
const theta = u * 2.0 * Math.PI;
const phi = Math.acos(2.0 * v - 1.0);
b[i * 3] = Math.sin(phi) * Math.cos(theta);
b[i * 3 + 1] = Math.sin(phi) * Math.sin(theta);
b[i * 3 + 2] = Math.cos(phi);
}
return { shapeA: a, shapeB: b, initialArray: new Float32Array(a) };
}, []);Smoothing the transitions between shapes
To swap shapes elegantly, we slowly slide each coordinate from the first shape to the second shape over time. This math technique is called linear interpolation, or 'lerping'. It gives particles a natural, floating transition as they change form.
useFrame((state) => {
if (!geometryRef.current) return;
const positions = geometryRef.current.attributes.position.array as Float32Array;
const t = state.clock.getElapsedTime();
const morphAlpha = Math.min(1, Math.max(0, Math.sin(t * 0.5) * 0.5 + 0.5));
for (let i = 0; i < PARTICLE_COUNT; i++) {
const i3 = i * 3;
const targetX = THREE.MathUtils.lerp(shapeA[i3], shapeB[i3], morphAlpha);
const targetY = THREE.MathUtils.lerp(shapeA[i3 + 1], shapeB[i3 + 1], morphAlpha);
const targetZ = THREE.MathUtils.lerp(shapeA[i3 + 2], shapeB[i3 + 2], morphAlpha);
positions[i3] = THREE.MathUtils.lerp(positions[i3], targetX, 0.05);
positions[i3 + 1] = THREE.MathUtils.lerp(positions[i3 + 1], targetY, 0.05);
positions[i3 + 2] = THREE.MathUtils.lerp(positions[i3 + 2], targetZ, 0.05);
}
geometryRef.current.attributes.position.needsUpdate = true;
});Making dots run away from your mouse
To make the particles feel alive, track where the user's mouse is. If any particle gets too close to the cursor, push it slightly away. This creates a really cool tactile effect where the dots part as you sweep your hand across them.
const dx = targetX - mx;
const dy = targetY - my;
const distSq = dx * dx + dy * dy;
const hoverRadius = 0.6;
if (distSq < hoverRadius * hoverRadius && distSq > 0.001) {
const dist = Math.sqrt(distSq);
const force = (hoverRadius - dist) * 0.4;
targetX += (dx / dist) * force;
targetY += (dy / dist) * force;
targetZ += force * 0.2;
}