[JavaScript] WebAudio × three.js で作るサウンドリアクティブ・ジオメトリアニメーション

はじめに

昨日、reddit で公開されている nw_wrld というプロジェクトを夜に動かしてみたのですが、3年かけて制作したとの事でかなりの力作で、redditの評価も高め。

ただ、難しくて余り仕組みを理解する事はできなかったです。

これに影響を受けて

「BGMに連動してジオメトリを動かしたら面白いんじゃないか?」

と思い立ち、昨晩から今朝にかけて、ざっと実装してみたので、そのメモになります。

ジオメトリタイプを一定時間で切り替える、今までやった事がない実装にもチャレンジしてます。

動画(パソコン)

動機

きっかけは、GitHub で公開されている nw_wrld というプロジェクト。

nw_wrld is an event-driven sequencer for triggering visuals using web technologies.
It enables users to scale up audiovisual compositions for prototyping, demos, exhibitions, and live performances.

reddit 上で「3年かけて作った」と紹介されていたこの作品を実際に動かし、
サウンドイベントによって視覚表現を制御するというアプローチに強い影響を受けた。

本家はイベント駆動・シーケンサ中心だが、
今回は WebAudio の解析値を直接 three.js のアニメーションに接続する
シンプルな構成で、サウンドリアクティブなビジュアルを実装することを目的とした。


全体構成

構成は以下の3レイヤに分離している。

  1. Audio Layer

    • WebAudio API
    • AnalyserNode による周波数解析
  2. Animation State Layer

    • サウンドに応じた状態管理(scale / rotation / color / geometry type)
    • soundAnime オブジェクトに集約
  3. Rendering Layer

    • three.js
    • Geometry / Material / Mesh の生成・破棄・更新

soundAnime(状態管理)

サウンドアニメーションは 状態オブジェクト1つに集約して管理する。

export const soundAnime = {
  isActive: true,

  // geometry switch
  enableGeometrySwitch: true,
  geometryInterval: 8000,
  lastGeometrySwitchTime: 0,

  type: 'box',
  geometryTypes: ['box', 'torusKnot', 'cone', 'torus'],
  geometryIndex: 0,

  rotSpeed: 0.002,
  rotTimer: 0,

  bass: 0,
  mid: 0,
  high: 0,

  mesh: null,
  pos: null,
  basePositions: null,
  scaleEnergy: 0,

  rotAxis: new THREE.Vector3(0, 1, 0),

  paletteIndex: 0,
  colorLock: false,
  colorPalette: [
    { h: 0.08, s: 0.9, l: 0.6 },
    { h: 0.55, s: 0.7, l: 0.5 },
    { h: 0.33, s: 0.8, l: 0.55 },
    { h: 0.9, s: 0.6, l: 0.6 },
  ],
};
  • geometry / color / scale / rotation をすべてここで管理
  • update 関数はこの state を読むだけ
  • three.js の Mesh や Geometry を直接グローバルに触らない

Audio解析(getAudioLevels)

WebAudio の AnalyserNode を使用し、 低音・中音・高音を簡易的に分離。

export function getAudioLevels() {
  if (!analyser || !soundAnime.pos) return;

  analyser.getByteFrequencyData(analyserData);

  soundAnime.bass =
    analyserData.slice(0, 20).reduce((a, b) => a + b, 0) / 20;
  soundAnime.mid =
    analyserData.slice(20, 60).reduce((a, b) => a + b, 0) / 40;
  soundAnime.high =
    analyserData.slice(60, 120).reduce((a, b) => a + b, 0) / 60;
}
  • 正確な周波数分解ではなく 視覚用途向けの軽量実装
  • 音量値はそのまま animation に流す

Geometry の生成と破棄

作成

export function createSoundGeometry() {
  let geometry = null;

  if (soundAnime.type === 'box') {
    geometry = new THREE.BoxGeometry(0.4, 0.4, 0.4, 4, 4, 4);
  }
  if (soundAnime.type === 'torusKnot') {
    geometry = new THREE.TorusKnotGeometry(0.2, 0.04, 128, 16, 3, 5);
  }
  if (soundAnime.type === 'cone') {
    geometry = new THREE.ConeGeometry(0.4, 0.4, 32);
  }
  if (soundAnime.type === 'torus') {
    geometry = new THREE.TorusGeometry(0.2, 0.1, 32, 32);
  }

  const material = new THREE.MeshStandardMaterial({
    color: 0xffaa55,
    wireframe: true,
    transparent: true,
    opacity: 0.5,
    side: THREE.DoubleSide,
    depthWrite: false,
  });

  soundAnime.mesh = new THREE.Mesh(geometry, material);
  soundAnime.mesh.position.set(0, 4, 0);
  config.scene.add(soundAnime.mesh);

  soundAnime.pos = geometry.attributes.position;
  soundAnime.basePositions = soundAnime.pos.array.slice();
}

破棄

function disposeSoundGeometry() {
  if (!soundAnime.mesh) return;

  soundAnime.mesh.geometry.dispose();
  soundAnime.mesh.material.dispose();
  config.scene.remove(soundAnime.mesh);

  soundAnime.mesh = null;
  soundAnime.pos = null;
  soundAnime.basePositions = null;
}

three.js では Geometry を途中で差し替えない。 切り替えたい場合は Meshごと破棄→再生成が安全。


Geometry 切り替え(時間トリガ)

一定時間ごとに geometry を切り替える。

function changeSoundGeometry(type) {
  soundAnime.type = type;
  disposeSoundGeometry();
  createSoundGeometry();
}

update 内で時間管理:

const now = performance.now();

if (soundAnime.enableGeometrySwitch) {
  if (now - soundAnime.lastGeometrySwitchTime > soundAnime.geometryInterval) {
    soundAnime.geometryIndex =
      (soundAnime.geometryIndex + 1) % soundAnime.geometryTypes.length;

    changeSoundGeometry(soundAnime.geometryTypes[soundAnime.geometryIndex]);
    soundAnime.lastGeometrySwitchTime = now;
    soundAnime.scaleEnergy = 0;
    return;
  }
}

アニメーション更新

export function updateSoundGeometry(delta) {
  if (!soundAnime.mesh) return;

  getAudioLevels();

  soundAnime.scaleEnergy += soundAnime.bass / 600;
  soundAnime.scaleEnergy *= 0.9;

  const s = 1 + soundAnime.scaleEnergy * 0.5;
  soundAnime.mesh.scale.setScalar(Math.max(s, 0.7));

  soundAnime.mesh.rotateOnAxis(soundAnime.rotAxis, soundAnime.rotSpeed);

  const colorInterval = 4000;
  const index =
    Math.floor(performance.now() / colorInterval) %
    soundAnime.colorPalette.length;

  const baseColor = soundAnime.colorPalette[index];

  soundAnime.mesh.material.color.setHSL(
    baseColor.h + soundAnime.high / 255 * 0.05,
    baseColor.s,
    baseColor.l + soundAnime.bass / 255 * 0.1
  );
}
  • 低音:スケール
  • 高音:色の揺らぎ
  • 時間:回転軸・カラーパレット切替
  • 連続的な色相回転は避け、パレット方式を採用

Geometry 選定について

最終的に BoxGeometry が最も安定した。

  • 直交形状なので原型が崩れにくい
  • 非等方スケールが視覚的に分かりやすい
  • ワイヤーフレームとの相性が良い
  • 空間演出(フィールド・結界・ゾーン)として意味を持ちやすい

曲線系ジオメトリ(Torus / Sphere / TorusKnot)は 頂点変形やスケールを強くかけるとノイズ化しやすく、 音連動では制御が難しかった。


まとめ

  • WebAudio の解析値を 直接 three.js の状態に接続
  • Geometry は 作り直す前提
  • Animation State を 1オブジェクトに集約
  • 色は連続変化ではなく パレット制御
  • 形状は世界観と文脈で選ぶ

nw_wrld に影響を受けつつ、 イベント駆動ではなく 連続音解析ベースのアプローチとして整理した。

WebXR / WebGL 空間での サウンドリアクティブ表現の土台として使える構成になったと思う。