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="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-BNlNRfcu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DuS2cUe8.css">
<script type="module" crossorigin src="/assets/index-eeooup-h.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DueAT_Qc.css">
</head>
<body>
<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 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)