Files
mission-control-v2/frontend/src/components/voice/Avatar3D.tsx
T
Hitonabi b4a8f92cfa Feat: lebendigere Avatar-Bewegung — Blickkontakt (lookAt), Umschauen, Gewichtsverlagerung, Vorlehnen beim Sprechen
Avatar schaut zur Kamera (vrm.lookAt = camera), Kopf driftet zu wechselnden Zielen (Umschauen),
Hüfte/Arme verlagern Gewicht, lehnt sich beim Sprechen leicht vor. Über Ruhepose + Lippensync/Mimik.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 02:28:00 +02:00

194 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Canvas, useFrame } from "@react-three/fiber"
import { OrbitControls } from "@react-three/drei"
import { useEffect, useRef, useState, type MutableRefObject } from "react"
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"
import { VRM, VRMLoaderPlugin, VRMUtils } from "@pixiv/three-vrm"
import type { Emotion } from "@/lib/voice/sentiment"
// 3D-Avatar (VRM) mit Lippensync (Mund folgt dem TTS-Audiopegel), automatischem Blinzeln und
// stimmungsabhängiger Mimik. Liest die Live-Werte aus Mutable-Refs (kein Re-Render pro Frame).
type LevelRef = MutableRefObject<{ current: number }> // audioLevel.current.current = Pegel 0..1
type EmotionRef = MutableRefObject<Emotion>
// Ruhepose (A-Pose) als Basis — VRMs laden sonst in T-Pose (Arme waagerecht).
const REST: Record<string, [number, number, number]> = {
leftUpperArm: [0, 0, 1.2],
rightUpperArm: [0, 0, -1.2],
leftLowerArm: [0, -0.2, 0],
rightLowerArm: [0, 0.2, 0],
}
function setBone(vrm: VRM, name: string, x: number, y: number, z: number) {
const b = vrm.humanoid?.getNormalizedBoneNode(name as any)
if (b) b.rotation.set(x, y, z)
}
function applyRestPose(vrm: VRM) {
for (const [name, r] of Object.entries(REST)) setBone(vrm, name, r[0], r[1], r[2])
vrm.humanoid?.update()
}
// Lebendige Idle-Animation: prozedural (kein Animations-File). Über die Ruhepose gelegt:
// Atmung, langsames Wiegen + Gewichtsverlagerung, Umschauen (lookYaw/Pitch), Vorlehnen beim
// Sprechen (lean) und Arm-Mitbewegung. Die Augen (lookAt) hält die useFrame separat auf die Kamera.
function applyIdle(vrm: VRM, t: number, level: number, lookYaw: number, lookPitch: number, lean: number) {
const breathe = Math.sin(t * 1.7) // ~0.27 Hz Atmung
const sway = Math.sin(t * 0.45) // sanftes Wiegen
const weight = Math.sin(t * 0.32) // langsame Gewichtsverlagerung
const emphasis = Math.min(1, level * 1.4)
const nod = Math.sin(t * 1.3) * emphasis * 0.06 // Sprech-Nicken
// Rumpf — Atmung, Wiegen, Gewichtsverlagerung, Vorlehnen beim Sprechen
setBone(vrm, "hips", 0, weight * 0.045, weight * 0.03)
setBone(vrm, "spine", breathe * 0.025 + lean * 0.07, sway * 0.022, -weight * 0.03)
setBone(vrm, "chest", breathe * 0.02 + lean * 0.02, sway * 0.018, 0)
setBone(vrm, "upperChest", breathe * 0.015, 0, 0)
// Kopf/Hals — Umschauen (gedriftete Zielwinkel) + Atmung + Sprech-Nicken
setBone(vrm, "neck", lookPitch * 0.4 + nod * 0.5, lookYaw * 0.4, 0)
setBone(vrm, "head", lookPitch * 0.6 + nod * 0.5 + Math.sin(t * 0.6) * 0.015,
lookYaw * 0.6 + Math.sin(t * 0.27) * 0.025, Math.sin(t * 0.5) * 0.02)
// Arme — Ruhepose + leichtes Pendeln + Gewichtsverlagerung
const arm = Math.sin(t * 0.8) * 0.035
setBone(vrm, "leftUpperArm", 0, 0, 1.18 + arm + weight * 0.04)
setBone(vrm, "rightUpperArm", 0, 0, -1.18 - arm + weight * 0.04)
setBone(vrm, "leftLowerArm", 0, -0.18 - Math.sin(t * 0.8) * 0.03, 0)
setBone(vrm, "rightLowerArm", 0, 0.18 + Math.sin(t * 0.8) * 0.03, 0)
}
const EXPRESSIONS = ["happy", "angry", "sad", "surprised", "relaxed"] as const
const EMO_TO_EXPR: Record<Emotion, string | null> = {
neutral: null, happy: "happy", angry: "angry", sad: "sad", surprised: "surprised", relaxed: "relaxed",
}
function VrmModel({ url, audioLevel, emotion, onError }: {
url: string; audioLevel: LevelRef; emotion: EmotionRef; onError: (m: string) => void
}) {
const [vrm, setVrm] = useState<VRM | null>(null)
const smooth = useRef<Record<string, number>>({})
const blink = useRef({ t: 0, next: 3, active: 0 })
// Umschauen (gedriftete Kopf-Zielwinkel) + geglättetes Lehnen beim Sprechen.
const motion = useRef({ yaw: 0, pitch: 0, tYaw: 0, tPitch: 0, t: 0, next: 2.5, lean: 0 })
useEffect(() => {
let disposed = false
let loaded: VRM | null = null
const loader = new GLTFLoader()
loader.register((parser) => new VRMLoaderPlugin(parser))
loader.load(
url,
(gltf) => {
if (disposed) return
const v = gltf.userData.vrm as VRM | undefined
if (!v) { onError("Datei enthält kein gültiges VRM-Modell."); return }
VRMUtils.removeUnnecessaryVertices(gltf.scene)
if (v.meta?.metaVersion === "0") VRMUtils.rotateVRM0(v)
v.scene.rotation.y = Math.PI // dem Betrachter zuwenden
applyRestPose(v) // T-Pose → entspannte A-Pose (Arme unten)
loaded = v
setVrm(v)
},
undefined,
(err) => { console.error("VRM-Load-Fehler:", err); onError("Avatar konnte nicht geladen werden (CORS/URL?).") },
)
return () => {
disposed = true
if (loaded) VRMUtils.deepDispose(loaded.scene)
setVrm(null)
}
}, [url, onError])
useFrame((state, delta) => {
if (!vrm) return
const target = audioLevel.current?.current ?? 0
// Blickkontakt: Augen folgen der Kamera (schaut dich an).
if (vrm.lookAt) vrm.lookAt.target = state.camera
// Umschauen: alle paar Sekunden neues Kopf-Ziel, sanft hineinlerpen.
const m = motion.current
m.t += delta
if (m.t > m.next) {
m.tYaw = (Math.random() - 0.5) * 0.5 // ±0.25 rad Gieren
m.tPitch = (Math.random() - 0.5) * 0.24
m.t = 0
m.next = 2.5 + Math.random() * 3.5
}
m.yaw += (m.tYaw - m.yaw) * Math.min(1, delta * 1.5)
m.pitch += (m.tPitch - m.pitch) * Math.min(1, delta * 1.5)
m.lean += (Math.min(1, target * 1.6) - m.lean) * Math.min(1, delta * 3)
// Lebendige Idle-/Sprech-Bewegung (über die Ruhepose gelegt).
applyIdle(vrm, state.clock.elapsedTime, target, m.yaw, m.pitch, m.lean)
const em = vrm.expressionManager
if (em) {
// Lippensync: 'aa' folgt geglättet dem Audiopegel.
const aa = (smooth.current.aa ?? 0) * 0.4 + target * 0.6
smooth.current.aa = aa
em.setValue("aa", aa)
// Mimik: weich zur Ziel-Expression lerpen.
const want = EMO_TO_EXPR[emotion.current]
for (const name of EXPRESSIONS) {
const tv = want === name ? 0.75 : 0
const cv = smooth.current[name] ?? 0
const nv = cv + (tv - cv) * Math.min(1, delta * 4)
smooth.current[name] = nv
em.setValue(name, nv)
}
// Blinzeln: kurzer Dreieckspuls alle 37 s.
const b = blink.current
b.t += delta
if (b.active <= 0 && b.t > b.next) { b.active = 0.16; b.t = 0; b.next = 3 + Math.random() * 4 }
let blinkVal = 0
if (b.active > 0) {
b.active -= delta
const p = 1 - b.active / 0.16 // 0..1 Fortschritt
blinkVal = 1 - Math.abs(p - 0.5) * 2 // 0 → 1 → 0
}
em.setValue("blink", Math.max(0, blinkVal))
}
vrm.update(delta)
})
return vrm ? <primitive object={vrm.scene} /> : null
}
export function Avatar3D({ url, audioLevel, emotion }: {
url: string; audioLevel: LevelRef; emotion: EmotionRef
}) {
const [err, setErr] = useState<string | null>(null)
return (
<div className="relative h-full w-full">
<Canvas
camera={{ position: [0, 1.35, 1.25], fov: 30 }}
gl={{ alpha: true, antialias: true }}
style={{ background: "transparent" }}
>
<ambientLight intensity={0.85} />
<directionalLight position={[1, 2, 2]} intensity={1.1} />
<directionalLight position={[-1, 1, -1]} intensity={0.4} />
{/* key=url → bei Avatarwechsel sauber neu mounten */}
<VrmModel key={url} url={url} audioLevel={audioLevel} emotion={emotion} onError={setErr} />
<OrbitControls
target={[0, 1.3, 0]}
enablePan={false}
minDistance={0.7}
maxDistance={3}
minPolarAngle={Math.PI / 3}
maxPolarAngle={Math.PI / 1.8}
/>
</Canvas>
{err && (
<div className="absolute inset-x-0 bottom-3 mx-auto w-fit rounded-md bg-red-500/15 border border-red-500/30 px-3 py-1.5 text-xs text-red-300">
{err}
</div>
)}
</div>
)
}