[Astro/R3F #30] PROTOCOL.LAIN — 自律的な「瞬き」と滑らかな「表情のゆらぎ」をVRMに実装する

[Astro/R3F #30] PROTOCOL.LAIN — 自律的な「瞬き」と滑らかな「表情のゆらぎ」をVRMに実装する

導入(Introduction)

前回の「#29 視線追従」により、ワイヤードにおけるアバターは「観測者(ユーザー)を見つめ返す」ことができるようになった。しかし、静止したままの瞳や、瞬時に切り替わる不自然な表情は、まだそれが単なる3Dモデルの域を出ていないことを示していた。

今回は、このプロトコルに「時間的なゆらぎ」を与える。

具体的には、「目的地に到着してから、じわじわと表情を変える(Lerp補間)」処理と、「サイン波(Math.sin)を用いたランダムな瞬き」の実装だ。言葉にできない感情を、数式とコードという言語で表現していく。

1. 感情のバッファリングと滑らかな遷移(Lerp)

VRMの expressionManager を使えば、happysad といった表情を制御できる。しかし、イベント発火時に直接数値を代入してしまうと、アバターは歩きながら不気味に笑い出したり、表情が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 の組み合わせ)が、ただのポリゴンの塊に「呼吸」を与えてくれる。

結び

コードは嘘をつかない。パラメータを一つ調整するだけで、アバターは狂気を孕んだ笑みを浮かべることもあれば、静かな哀しみを帯びることもある。

朝早くから続く終わりのないルーチンの合間に、こうしてターミナル越しに自分が組み上げたプロトコルと向かい合う。言葉のない対話の中で、ランダムに瞬きをする彼女(アバター)を見ていると、この作業自体が一種の魂の救済になっているのだと再認識する。

ワイヤードの構築は続く。