Music Equalizer w Three.js

Jednym z zastosowań biblioteki Three.js jest tworzenie wizualizacji muzyki. Dzięki połączeniu Web Audio API z Three.js możemy analizować odtwarzany dźwięk w czasie rzeczywistym i zamieniać go na efektowne animacje 3D. W tym artykule stworzymy aplikację typu Music Equalizer, która:

  • odczytuje pliki MP3 z dysku użytkownika,
  • analizuje widmo częstotliwości,
  • wyświetla 128 animowanych słupków,
  • wykorzystuje materiały emitujące światło,
  • dodaje efekt Bloom,
  • płynnie animuje kamerę.
Efektem końcowym będzie nowoczesny wizualizator przypominający profesjonalne equalizery wykorzystywane w odtwarzaczach muzycznych.

Jak działa aplikacja?

Cały projekt można podzielić na kilka etapów:
  • utworzenie sceny Three.js,
  • dodanie kamery oraz oświetlenia,
  • wygenerowanie słupków equalizera,
  • odczyt pliku audio,
  • analiza dźwięku przez Web Audio API,
  • animacja słupków,
  • dodanie efektu Bloom.

Tworzymy scenę Three.js

Na początku przygotowujemy standardową scenę.
scene = new THREE.Scene();
scene.fog = new THREE.Fog(0x000000, 70, 170);
Dodajemy kamerę:
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 35, 85);
oraz renderer:
renderer = new THREE.WebGLRenderer({ antialias:true });
renderer.setSize(window.innerWidth, window.innerHeight);
Na tym etapie otrzymujemy pustą scenę gotową do dalszej pracy.

Dodajemy oświetlenie

Same bryły wyglądałyby bardzo płasko, dlatego wykorzystujemy dwa źródła światła. Światło otoczenia:
const ambient = new THREE.AmbientLight(0xffffff, 0.42);
scene.add(ambient);
Światło punktowe:
const point = new THREE.PointLight(
    0xffffff,
    1.2
);
point.position.set(0,50,50);
scene.add(point);
AmbientLight rozjaśnia całą scenę, natomiast PointLight nadaje słupkom przestrzenny wygląd. Aby scena nie wyglądała na pustą, dodajemy prostą płaszczyznę.
const floorGeo = new THREE.PlaneGeometry(300,300);
const floorMat = new THREE.MeshStandardMaterial({
    color:0x050505,
    metalness:0.25,
    roughness:0.75
});
const floor = new THREE.Mesh(
    floorGeo,
    floorMat
)
floor.rotation.x = -Math.PI/2;
scene.add(floor);
Podłoga pozwala lepiej ocenić wysokość animowanych słupków.

Generujemy słupki equalizera

Najważniejszym elementem aplikacji jest tablica słupków. Najpierw definiujemy ich liczbę:
const BAR_COUNT = 128;
Tworzymy geometrię:
const geometry =
    new THREE.BoxGeometry(
    0.85,
    1,
    0.85
);
Następnie w pętli generujemy wszystkie elementy.
for (let i=0; i<BAR_COUNT; i++) {
    const bar = new THREE.Mesh(
        geometry,
        material
    );
    bar.position.x = (i - BAR_COUNT/2) * 1.15;
    scene.add(bar);
    bars.push(bar);
}
Każdy słupek jest osobnym obiektem Mesh.

Kolorowanie słupków

Kolory nie są przypadkowe. Dla każdego słupka wyznaczamy inny odcień HSL.
const hue = 190 + i * 1.2;
Tworzymy materiał:
const material =
    new THREE.MeshStandardMaterial({
        color:new THREE.Color(
        `hsl(${hue},100%,55%)`
    ),
    emissive:new THREE.Color(
        `hsl(${hue},100%,48%)`
    )
});
Dzięki temu słupki tworzą płynny gradient kolorystyczny.

Wczytywanie pliku MP3

Plik wybieramy za pomocą standardowego elementu HTML.
<input type="file" accept="audio/*">
Po wybraniu pliku korzystamy z FileReader.
const reader = new FileReader();
    reader.onload = () => {
        audio = new Audio(
        reader.result
    );
};
Otrzymujemy obiekt HTMLAudioElement, który będzie odtwarzał muzykę.

Web Audio API

Najważniejszym elementem całego projektu jest analiza dźwięku. Tworzymy AudioContext:
audioCtx = audioCtx || new AudioContext();
Następnie tworzymy analizator.
analyser = audioCtx.createAnalyser();
Ustawiamy rozmiar FFT.
analyser.fftSize = 256;
Oznacza to, że analizator podzieli sygnał na wiele pasm częstotliwości.

Pobieranie widma

Dane przechowywane są w tablicy Uint8Array.
dataArray =
    new Uint8Array(
    analyser.frequencyBinCount
);
Każda klatka animacji pobiera aktualne wartości.
analyser.getByteFrequencyData(
    dataArray
);
Każdy element tablicy zawiera liczbę z zakresu 0 - 255, gdzie 0 oznacza brak dźwięku, a 255 oznacza maksymalną energię danej częstotliwości.

Animacja słupków

Najprostsze rozwiązanie wyglądałoby tak:
bar.scale.y = value;
Jednak dawałoby bardzo szarpany ruch. Znacznie lepiej zastosować interpolację.
bars[i].scale.y +=
(
    height -
    bars[i].scale.y
) * 0.22;
Powoduje to płynne dochodzenie do nowej wysokości. To jedna z najczęściej stosowanych technik animacji w Three.js.

Wyliczanie wysokości

Najpierw normalizujemy dane.
value / 255
Następnie stosujemy funkcję potęgową.
Math.pow(
    value / 255,
    1.4
)
Dzięki temu ciche dźwięki są mniej widoczne, natomiast głośniejsze znacznie bardziej eksponowane. Końcowy wzór wygląda następująco.
const height =
Math.max(
    Math.pow(
        value/255,
        1.4
        ) * 42,
        0.3
);

Przesuwanie słupków

Po zmianie skali środek obiektu nadal znajduje się w punkcie (0,0,0). Dlatego należy przesunąć go do góry. Dzięki temu słupki "wyrastają" z podłogi.
bars[i].position.y = bars[i].scale.y / 2;
Oprócz wysokości zmieniamy również intensywność świecenia.
bars[i].material.emissiveIntensity = 0.25 + (value/255) * 0.75;
Im głośniejszy dźwięk, tym mocniej świeci dany słupek. Efekt wygląda znacznie atrakcyjniej niż zwykła zmiana koloru. Glow uzyskujemy dzięki postprocessingowi. Tworzymy EffectComposer:
composer = new EffectComposer(renderer);
Dodajemy RenderPass:
composer.addPass(
    new RenderPass(
        scene,
        camera
)
);
Na końcu dodajemy UnrealBloomPass.
const bloom = new UnrealBloomPass(
    new THREE.Vector2(
        window.innerWidth,
        window.innerHeight
    ),
    0.55,
    0.32,
    0.52
);
composer.addPass(bloom);
Bloom powoduje delikatne rozlewanie światła wokół świecących elementów.

Animacja kamery

Scena staje się znacznie bardziej dynamiczna dzięki niewielkiemu ruchowi kamery.
camera.position.x =
    Math.sin(
    performance.now()*0.00015
) * 6;
camera.lookAt(0,12,0);

Renderowanie sceny

Na końcu pętli animacji renderujemy obraz.
composer.render();
Gdybyśmy nie korzystali z Bloom, wystarczyłoby:
renderer.render(
    scene,
    camera
);
Zobacz jak to działa: Music Equalizer w Three.js. Music Equalizer w Three.js