Feat: Avatar lebendiger — prozedurale Idle-Bewegung (Atmen, Wiegen, Kopf, Sprech-Nicken)

Über die Ruhepose gelegte Sinus-Bewegung auf Spine/Chest/Hips/Neck/Head/Arme; beim Sprechen
(Audiopegel) nickt der Kopf stärker. Kein Animations-File. Frontend neu gebaut (dist).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-28 00:07:15 +02:00
parent 8e7ce1b1d3
commit cf587616dd
8 changed files with 9424 additions and 4536 deletions
+45 -13
View File
@@ -11,21 +11,49 @@ import type { Emotion } from "@/lib/voice/sentiment"
type LevelRef = MutableRefObject<{ current: number }> // audioLevel.current.current = Pegel 0..1
type EmotionRef = MutableRefObject<Emotion>
// VRMs laden in T-Pose (Bindepose, Arme waagerecht). Wir senken Ober-/Unterarme auf den
// normalisierten Humanoid-Knoten zu einer ruhigen A-Pose ab — sieht sofort natürlich aus.
// (Echte Idle-Animation wäre die spätere Stufe.)
// 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) {
const set = (name: any, x: number, y: number, z: number) => {
const b = vrm.humanoid?.getNormalizedBoneNode(name)
if (b) b.rotation.set(x, y, z)
}
set("leftUpperArm", 0, 0, 1.2) // Arm runter an die Seite
set("rightUpperArm", 0, 0, -1.2)
set("leftLowerArm", 0, -0.2, 0) // leichte Beugung
set("rightLowerArm", 0, 0.2, 0)
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). Atmung, langsames Wiegen,
// Kopf-/Blickbewegung und Arm-Pendeln werden überlagert; beim Sprechen (level) nickt der Kopf
// stärker, damit der Avatar „mitgeht". Wird jede Frame über die Ruhepose gelegt.
function applyIdle(vrm: VRM, t: number, level: number) {
const breathe = Math.sin(t * 1.7) // ~0.27 Hz Atmung
const sway = Math.sin(t * 0.5)
const emphasis = Math.min(1, level * 1.4)
// Rumpf — Atmung + sanftes Wiegen
setBone(vrm, "spine", breathe * 0.025, sway * 0.03, Math.sin(t * 0.43) * 0.015)
setBone(vrm, "chest", breathe * 0.02, sway * 0.02, 0)
setBone(vrm, "hips", 0, Math.sin(t * 0.37) * 0.02, Math.sin(t * 0.29) * 0.01)
// Kopf/Hals — langsamer Blick + Atmung + Sprech-Nicken
setBone(vrm, "neck", Math.sin(t * 0.8) * 0.03 + emphasis * 0.06, Math.sin(t * 0.31) * 0.05, 0)
setBone(vrm, "head", Math.sin(t * 0.6) * 0.025 + emphasis * 0.05, Math.sin(t * 0.27) * 0.06, Math.sin(t * 0.5) * 0.02)
// Arme — Ruhepose + leichtes Pendeln
const arm = Math.sin(t * 0.8) * 0.04
setBone(vrm, "leftUpperArm", 0, 0, 1.2 + arm)
setBone(vrm, "rightUpperArm", 0, 0, -1.2 - arm)
setBone(vrm, "leftLowerArm", 0, -0.2 - Math.sin(t * 0.8) * 0.03, 0)
setBone(vrm, "rightLowerArm", 0, 0.2 + 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",
@@ -66,12 +94,16 @@ function VrmModel({ url, audioLevel, emotion, onError }: {
}
}, [url, onError])
useFrame((_, delta) => {
useFrame((state, delta) => {
if (!vrm) return
const target = audioLevel.current?.current ?? 0
// Lebendige Idle-/Sprech-Bewegung (über die Ruhepose gelegt).
applyIdle(vrm, state.clock.elapsedTime, target)
const em = vrm.expressionManager
if (em) {
// Lippensync: 'aa' folgt geglättet dem Audiopegel.
const target = audioLevel.current?.current ?? 0
const aa = (smooth.current.aa ?? 0) * 0.4 + target * 0.6
smooth.current.aa = aa
em.setValue("aa", aa)