3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

Reactで音声ビジュアライザーを作っていると、音声解析の値を毎フレーム扱いたくなります。

たとえば、

  • volume
  • bass
  • mids
  • highs
  • waveform

のような値です。

これらは requestAnimationFrame で更新されるため、かなり高い頻度で変わります。

最初は普通に useState で管理しようとしました。

ただ、実際にやってみると、毎フレーム setState するのはあまり相性がよくありませんでした。

そこで今回は、Reactで音声解析値を扱うときに、なぜ state ではなく ref を中心にしたのかを書きます。

最初に考えた実装

最初に思いつくのは、解析した値をそのまま state に入れる方法です。

const [features, setFeatures] = useState({
  volume: 0,
  bass: 0,
  mids: 0,
  highs: 0,
  waveform: new Float32Array(),
})

そして、解析ループの中で更新します。

function analyze() {
  const nextFeatures = getAudioFeatures()

  setFeatures(nextFeatures)

  requestAnimationFrame(analyze)
}

一見すると自然です。

ReactのUIで値を表示したいなら、state に入れるのは普通の選択です。

ただ、音声解析の場合、この方法には問題があります。

毎フレームsetStateすると何が起きるか

requestAnimationFrame は、だいたい1秒に60回ほど呼ばれます。

つまり、毎フレーム setState すると、1秒間に何十回も React の更新が走ります。

音声解析
  ↓
setState
  ↓
React再レンダリング
  ↓
また音声解析
  ↓
setState
  ↓
React再レンダリング

メーターを表示するだけならまだよいかもしれません。

しかし、音声ビジュアライザーでは、同時に3D描画やCanvas描画も走ることがあります。

その状態で React の再レンダリングまで高頻度に走ると、無駄が増えます。

そもそも毎フレームReactに知らせる必要はない

ここで考えたのは、音声解析値には2種類の用途があるということです。

1. 3D描画やアニメーションで毎フレーム使いたい値
2. UIに表示したい値

3D描画やアニメーションでは、常に最新の値が必要です。

一方で、UIのメーター表示は、必ずしも毎フレームReactで再描画する必要はありません。

たとえば、1秒に20〜30回くらい更新されれば十分見えます。

つまり、

描画ループ用の値: 毎フレーム更新したい
React UI用の値: 少し間引いて更新したい

と分けられます。

refに最新値を入れる

そこで、毎フレーム更新される最新値は useRef に入れることにしました。

type AudioFeatures = {
  volume: number
  bass: number
  mids: number
  highs: number
  waveform: Float32Array
}

const EMPTY_FEATURES: AudioFeatures = {
  volume: 0,
  bass: 0,
  mids: 0,
  highs: 0,
  waveform: new Float32Array(),
}

const featuresRef = useRef<AudioFeatures>(EMPTY_FEATURES)

解析ループでは、setState ではなく featuresRef.current を更新します。

function analyze() {
  const nextFeatures = getAudioFeatures()

  featuresRef.current = nextFeatures

  requestAnimationFrame(analyze)
}

ref を更新しても React の再レンダリングは発生しません。

そのため、音声解析のように高頻度で更新される値に向いています。

UI用にはstateも使う

ただし、UIに値を表示したい場合は state も必要です。

そこで、最新値は ref に持ちつつ、UI用には少し間引いた snapshot を state に入れます。

const [featuresSnapshot, setFeaturesSnapshot] =
  useState<AudioFeatures>(EMPTY_FEATURES)

解析ループの中で毎回 state 更新するのではなく、一定間隔ごとに更新します。

let lastStateUpdate = 0

function analyze(time: number) {
  const nextFeatures = getAudioFeatures()

  featuresRef.current = nextFeatures

  if (time - lastStateUpdate > 33) {
    setFeaturesSnapshot(nextFeatures)
    lastStateUpdate = time
  }

  requestAnimationFrame(analyze)
}

33msごとに更新すると、だいたい30fpsくらいです。

UIメーターや数値表示なら、このくらいでも十分なめらかに見えます。

役割を分ける

この構成では、役割が以下のように分かれます。

featuresRef
  ↓
常に最新の音声解析値
  ↓
3D描画やアニメーションで使う

featuresSnapshot
  ↓
UI表示用の値
  ↓
Reactコンポーネントで使う

Reactの再レンダリングを抑えつつ、描画側では常に最新値を読めます。

React Three Fiberとの相性

React Three Fiber を使っている場合、この設計はかなり相性がよいです。

useFrame の中で featuresRef.current を読めば、React state に依存せずに毎フレーム3Dオブジェクトを更新できます。

useFrame(() => {
  const { volume, bass, mids, highs } = featuresRef.current

  meshRef.current.scale.setScalar(1 + volume * 0.5)
  meshRef.current.rotation.x += mids * 0.01
  meshRef.current.rotation.y += highs * 0.01
})

このとき、featuresRef.current の中身が変わっても、Reactコンポーネント全体は再レンダリングされません。

3D描画ループの中だけで値を使えるので、音声反応のようなリアルタイム処理に向いています。

悪い例

毎フレーム state を更新し、その state を useFrame でも使う形は避けた方がよいです。

const [features, setFeatures] = useState(EMPTY_FEATURES)

useEffect(() => {
  function analyze() {
    const nextFeatures = getAudioFeatures()

    setFeatures(nextFeatures)

    requestAnimationFrame(analyze)
  }

  analyze()
}, [])

useFrame(() => {
  meshRef.current.scale.setScalar(1 + features.volume * 0.5)
})

この場合、音声解析のたびに React の更新が走ります。

さらに、useFrame 側でも React state に依存するため、描画とReactの更新が混ざりやすくなります。

よい例

毎フレーム必要な値は ref に持ちます。

const featuresRef = useRef(EMPTY_FEATURES)
const [featuresSnapshot, setFeaturesSnapshot] = useState(EMPTY_FEATURES)

useEffect(() => {
  let frameId = 0
  let lastStateUpdate = 0

  function analyze(time: number) {
    const nextFeatures = getAudioFeatures()

    featuresRef.current = nextFeatures

    if (time - lastStateUpdate > 33) {
      setFeaturesSnapshot(nextFeatures)
      lastStateUpdate = time
    }

    frameId = requestAnimationFrame(analyze)
  }

  frameId = requestAnimationFrame(analyze)

  return () => {
    cancelAnimationFrame(frameId)
  }
}, [])

3D側では ref を読みます。

useFrame(() => {
  const features = featuresRef.current

  // features.volume
  // features.bass
  // features.mids
  // features.highs
})

UI側では snapshot を使います。

<Meter label="volume" value={featuresSnapshot.volume} />
<Meter label="bass" value={featuresSnapshot.bass} />
<Meter label="mids" value={featuresSnapshot.mids} />
<Meter label="highs" value={featuresSnapshot.highs} />

stateを使わない方がいい、という話ではない

ここで言いたいのは、React state が悪いという話ではありません。

UIを表示するには state が便利です。

ただ、音声解析やアニメーションのように、毎フレーム変わる値をすべて state に入れる必要はありません。

用途によって分けるのが大事です。

画面に表示したい値: state
描画ループで読むだけの値: ref

この考え方にすると、Reactの更新とリアルタイム処理を分離できます。

音声解析以外にも使える

この考え方は、音声解析以外にも使えます。

たとえば、

  • mouse position
  • scroll position
  • game loop
  • WebSocketで高頻度に届く値
  • sensor data
  • Canvas animation
  • Three.js animation

などです。

ReactのUIとして見せたい値は state にする。

でも、毎フレーム処理の内部で使うだけなら ref にする。

この判断ができると、Reactでリアルタイム表現を作りやすくなります。

まとめ

音声ビジュアライザーでは、解析値が毎フレーム更新されます。

その値をすべて React state に入れると、再レンダリングが多くなりすぎることがあります。

そこで、

最新値: useRef
UI表示用: useState

という形に分けました。

この構成にすると、

  • 音声解析は高頻度に更新できる
  • 3D描画では常に最新値を読める
  • React UIは必要な頻度だけ更新できる
  • 再レンダリングを抑えられる

というメリットがあります。

Reactで音声解析や3D表現を扱うときは、すべてを state に入れるのではなく、ref と state を役割で分けると扱いやすくなりました。

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?