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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user