はじめに
本稿では、単なる「音量に合わせた口の開閉」を超え、アニメやゲームのような「意思を感じさせるキャラクター表現」をWebブラウザ上で実装する手法について解説します。
3Dモデル(VRM)による精緻な制御も一つの解ですが、計算負荷とアセット制作のコストを抑えつつ、現代的なアニメ調の高品質なビジュアルを維持するためには、「静止画のレイヤー合成」と「ステートマシンによる制御」の組み合わせが極めて有効です。
本実装の目的は、AI(Gemini)から得られる「文脈(感情)」と、リアルタイムな「音声信号(音量)」を独立した軸として扱い、1枚のスプライトシート上でそれらを再合成することにあります。
1. 3×3 ステートマトリックスによる描画ロジック
キャラクターの状態を「感情(行)」と「口の形(列)」の2軸で定義します。
- 感情軸(Emotion): Geminiのレスポンス内容から「通常・笑顔・真剣」を判定し、Y座標を決定します。
- 音声軸(MouthState): 音量解析値(RMS)から「閉じ・半開き・全開」を判定し、X座標を決定します。
コードの要点:CSS背景座標の算出
スプライトシート(300%サイズ)内の表示領域を、以下の計算式で特定しています。
// row: 感情 (0: neutral, 1: happy, 2: serious)
// col: 口の形 (0: closed, 1: half, 2: open)
const bgX = col * 50; // 0%, 50%, 100% の3段階
const bgY = row * 50; // 0%, 50%, 100% の3段階
CSSの background-position: ${bgX}% ${bgY}% を適用することで、1枚の画像から瞬時に特定の状態(例:笑顔で口が半開き)を切り出します。
2. 高速な音声同期を実現する audioLevelRef
Reactの通常の state 更新(useState)は非同期であり、毎秒60フレーム(60fps)のリップシンクに適用すると再レンダリングのオーバーヘッドが無視できません。
本実装では、useRef を使用して音声レベルを管理し、requestAnimationFrame 内で直接値を参照しています。
useEffect(() => {
const update = () => {
const level = audioLevelRef.current; // Refから最新値をノーレイテンシで取得
// 閾値(Threshold)による口の形状判定
if (level > 0.3) setMouthState("open");
else if (level > 0.08) setMouthState("half");
else setMouthState("closed");
mouthTimerRef.current = requestAnimationFrame(update);
};
// ...
}, [isActive]);
数値の意図:
- 0.3 / 0.08: 音声信号のノイズを拾わないためのマージンです。この閾値を設けることで、囁き声(半開き)から叫び声(全開)までのメリハリを、デジタルな切り替えでありながら自然に表現します。
3. 生体感を与える「自律動作」の実装
静止画が「古臭く」見える最大の要因は、ドットの変化が完全に止まる瞬間にあります。これを回避するために、音声とは独立した2つの「生命維持」ロジックを走らせています。
A. フローティング(呼吸エフェクト)
framer-motion を使い、画像全体を数秒周期でわずかにスケーリング・移動させます。
animate={{
scale: [1, 1.015, 1], // 1.5%の膨張
y: [0, -2, 0] // 2pxの浮遊
}}
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }}
これにより、喋っていない時でもキャラクターが「その場に存在している」という錯覚を維持します。
B. 非規則なまばたき
人間は一定間隔でまばたきをしません。これを模すために、ランダムなディレイを生成する再帰的なタイマーを実装しています。
const scheduleBlink = () => {
const delay = 2000 + Math.random() * 4000; // 2〜6秒のランダム性
blinkTimeoutRef.current = setTimeout(() => {
setIsBlinking(true);
setTimeout(() => {
setIsBlinking(false);
scheduleBlink(); // 次のまばたきを予約
}, 150); // まばたき自体の速度は一定
}, delay);
};
4. 感情の遅延遷移(Smooth Emotion Shift)
音声(リップシンク)は瞬時に反応すべきですが、感情(表情の変化)がパチパチ切り替わると不自然です。
useEffect(() => {
const timer = setTimeout(() => setCurrentEmotion(emotion), 300);
return () => clearTimeout(timer);
}, [emotion]);
Geminiから届いた感情タグに対し、意図的にわずかな遅延(300ms)とフェードを加えることで、「ハッとして表情が変わる」ような有機的な遷移を演出しています。
まとめ
この実装の核心は、「AIが理解した意味(感情)」を低速なレイヤーで、「物理的な音の振動(リップシンク)」を高速なレイヤーで処理し、それらを1枚のスプライトシート上で合成した点にあります。
「高解像度なイラストをそのまま動かせる」という2Dの利点を最大限に活かしつつ、コードによる物理的なゆらぎを加えることで、現代的なゲームインターフェースとしてのクオリティを担保しています。