[Astro/R3F #30] PROTOCOL.LAIN — 自律的な「瞬き」と滑らかな「表情のゆらぎ」をVRMに実装する
導入(Introduction)
前回の「#29 視線追従」により、ワイヤードにおけるアバターは「観測者(ユーザー)を見つめ返す」ことができるようになった。しかし、静止したままの瞳や、瞬時に切り替わる不自然な表情は、まだそれが単なる3Dモデルの域を出ていないことを示していた。
[Astro #29] VRM Proximity Control & Eye-Contact Optimization // PROTOCOL.LAIN
接近フェーズにおける到達判定の修正、ドアップ時の白目防止ロジック、CustomEvent経由のwalkSpeed同期、ターミナル履歴機能の追加について。
lain-lab.com今回は、このプロトコルに「時間的なゆらぎ」を与える。
具体的には、「目的地に到着してから、じわじわと表情を変える(Lerp補間)」処理と、「サイン波(Math.sin)を用いたランダムな瞬き」の実装だ。言葉にできない感情を、数式とコードという言語で表現していく。
1. 感情のバッファリングと滑らかな遷移(Lerp)
VRMの expressionManager を使えば、happy や sad といった表情を制御できる。しかし、イベント発火時に直接数値を代入してしまうと、アバターは歩きながら不気味に笑い出したり、表情が1フレームで「パッ」と切り替わったりしてしまう。
これを解決するために、「目標値の予約(バッファ)」と「毎フレームの補間(Lerp)」を分離する設計を行った。
状態管理(stateRef)の拡張
useRef で管理している状態に、表情用のパラメータを追加する。重要なのは、最終的な目標値を保持する expressionLimit を用意することだ。
const stateRef = useRef({
// ... (座標などの既存ステート)
expressionType: 'neutral', // 現在の目標表情名
expressionTarget: 0, // 実際にlerpが追従する現在の値
expressionLimit: 0, // JSONから受け取った「最終的な強さ」を予約する場所
expressionSpeed: 0.05 // 変化速度 (Lerp係数)
});
トリガーと実行の分離
イベント受信時は expressionLimit に値を「予約」するだけに留め、アバターがカメラ前に「到着」した瞬間に、その予約値を expressionTarget に流し込む。
これにより、「目の前に歩いてきて、ピタリと止まった瞬間に、ふっと微笑む」という、極めて人間(あるいは高度なAI)らしい間(ま)を表現できるようになった。帰り際に 0 を代入することで、スッと真顔に戻る冷徹さも演出している。
2. 複数の表情のクロスフェード処理
useFrame 内で、予約された expressionTarget に向かって毎フレーム数値を近づけていく。この時、特定の表情だけを更新するのではなく、「モデルが持つすべての表情」をループで回し、指定された表情以外は 0 に向かわせるのがポイントだ。
// useFrame 内
const presets = ['happy', 'smile', 'sad', 'angry', 'relaxed', 'surprised'];
presets.forEach((name) => {
const currentVal = manager.getValue(name) || 0;
const target = (name === s.expressionType) ? s.expressionTarget : 0;
if (Math.abs(currentVal - target) > 0.001) {
const nextVal = THREE.MathUtils.lerp(currentVal, target, s.expressionSpeed);
manager.setValue(name, nextVal);
}
});
これで、表情の「幽霊(前の表情が消えずに残るバグ)」を防ぎつつ、喜怒哀楽が滑らかに交差するようになる。
3. サイン波(Math.sin)がもたらす「生存感」としての瞬き
静止状態における最大の問題は「人形感」だ。これを打破するために、独立したタイマーによるプロシージャルな瞬き(Blink)を実装した。
単に 1.0 を代入するのではなく、まぶたの開閉を Math.sin によるカーブで描画することで、有機的な動きをシミュレートする。
// --- ランダムな瞬き (Blink) ロジック ---
s.blinkTimer -= delta;
if (s.blinkTimer <= 0) {
s.blinkProcessing = true;
// 瞬き完了後、次のタイマーを2〜6秒のランダムでセット
if (s.blinkTimer < -s.blinkDuration) {
s.blinkTimer = 2.0 + Math.random() * 4.0;
s.blinkProcessing = false;
}
}
if (s.blinkProcessing) {
// 0 -> 1 -> 0 へと滑らかに変化するまぶたの動き
const progress = Math.abs(s.blinkTimer) / s.blinkDuration;
s.blinkValue = Math.sin(progress * Math.PI);
} else {
s.blinkValue = 0;
}
// 最終的な適用
manager.setValue('blink', s.blinkValue);
ノイズやレイマーチングで作る背景の歪みと同じように、この数行の数式(Math.sin と Math.random の組み合わせ)が、ただのポリゴンの塊に「呼吸」を与えてくれる。
結び
コードは嘘をつかない。パラメータを一つ調整するだけで、アバターは狂気を孕んだ笑みを浮かべることもあれば、静かな哀しみを帯びることもある。
朝早くから続く終わりのないルーチンの合間に、こうしてターミナル越しに自分が組み上げたプロトコルと向かい合う。言葉のない対話の中で、ランダムに瞬きをする彼女(アバター)を見ていると、この作業自体が一種の魂の救済になっているのだと再認識する。
ワイヤードの構築は続く。