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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title> <title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-BNlNRfcu.js"></script> <script type="module" crossorigin src="/assets/index-eeooup-h.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DuS2cUe8.css"> <link rel="stylesheet" crossorigin href="/assets/index-DueAT_Qc.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+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 LevelRef = MutableRefObject<{ current: number }> // audioLevel.current.current = Pegel 0..1
type EmotionRef = MutableRefObject<Emotion> type EmotionRef = MutableRefObject<Emotion>
// VRMs laden in T-Pose (Bindepose, Arme waagerecht). Wir senken Ober-/Unterarme auf den // Ruhepose (A-Pose) als Basis — VRMs laden sonst in T-Pose (Arme waagerecht).
// normalisierten Humanoid-Knoten zu einer ruhigen A-Pose ab — sieht sofort natürlich aus. const REST: Record<string, [number, number, number]> = {
// (Echte Idle-Animation wäre die spätere Stufe.) 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) { function applyRestPose(vrm: VRM) {
const set = (name: any, x: number, y: number, z: number) => { for (const [name, r] of Object.entries(REST)) setBone(vrm, name, r[0], r[1], r[2])
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)
vrm.humanoid?.update() 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 EXPRESSIONS = ["happy", "angry", "sad", "surprised", "relaxed"] as const
const EMO_TO_EXPR: Record<Emotion, string | null> = { const EMO_TO_EXPR: Record<Emotion, string | null> = {
neutral: null, happy: "happy", angry: "angry", sad: "sad", surprised: "surprised", relaxed: "relaxed", neutral: null, happy: "happy", angry: "angry", sad: "sad", surprised: "surprised", relaxed: "relaxed",
@@ -66,12 +94,16 @@ function VrmModel({ url, audioLevel, emotion, onError }: {
} }
}, [url, onError]) }, [url, onError])
useFrame((_, delta) => { useFrame((state, delta) => {
if (!vrm) return 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 const em = vrm.expressionManager
if (em) { if (em) {
// Lippensync: 'aa' folgt geglättet dem Audiopegel. // Lippensync: 'aa' folgt geglättet dem Audiopegel.
const target = audioLevel.current?.current ?? 0
const aa = (smooth.current.aa ?? 0) * 0.4 + target * 0.6 const aa = (smooth.current.aa ?? 0) * 0.4 + target * 0.6
smooth.current.aa = aa smooth.current.aa = aa
em.setValue("aa", aa) em.setValue("aa", aa)