はじめに
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 を役割で分けると扱いやすくなりました。