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>
This commit is contained in:
Hitonabi
2026-06-28 02:28:00 +02:00
parent 15d5598eec
commit b4a8f92cfa
4 changed files with 79 additions and 57 deletions
+41 -19
View File
@@ -29,29 +29,33 @@ function applyRestPose(vrm: VRM) {
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) {
// 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.5)
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 + 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)
// 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 — 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)
// 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
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)
// 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
@@ -65,6 +69,8 @@ function VrmModel({ url, audioLevel, emotion, onError }: {
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
@@ -98,8 +104,24 @@ function VrmModel({ url, audioLevel, emotion, onError }: {
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)
applyIdle(vrm, state.clock.elapsedTime, target, m.yaw, m.pitch, m.lean)
const em = vrm.expressionManager
if (em) {