feat(stage-ui,stage-pages,stage-tamagotchi): visualize beat sync target value, plugin-lize live2d motion, styles of beat sync

This commit is contained in:
Neko Ayaka
2025-12-03 20:05:29 +08:00
parent 347b55f3ce
commit 93cbfec583
6 changed files with 1001 additions and 134 deletions
@@ -47,6 +47,12 @@ const menu = computed(() => [
icon: 'i-solar:sledgehammer-bold-duotone',
to: '/devtools/providers-transcription-realtime-aliyun-nls',
},
{
title: 'Beat Sync Visualizer',
description: 'Plot V-motion targets, trajectory, and scalar Y/Z over time',
icon: 'i-solar:chart-bold-duotone',
to: '/devtools/beat-sync',
},
])
const openDevTools = useElectronEventaInvoke(electronOpenMainDevtools)
@@ -0,0 +1,346 @@
<script setup lang="ts">
import type { BeatSyncStyleName } from '../../../../stage-ui/src/composables/live2d/beat-sync'
import { Callout, Section } from '@proj-airi/stage-ui/components'
import { Button, FieldCheckbox, FieldRange, FieldSelect } from '@proj-airi/ui'
import { useRafFn } from '@vueuse/core'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { createBeatSyncController } from '../../../../stage-ui/src/composables/live2d/beat-sync'
interface TrailPoint { x: number, y: number, t: number }
interface ScalarSample { t: number, x: number, y: number, z: number }
const baseAngleX = ref(0)
const baseAngleY = ref(0)
const baseAngleZ = ref(0)
const scale = ref(6) // px per degree
const dampingOverlay = ref(0.08)
const timeWindowMs = 4000
const style = ref<BeatSyncStyleName>('punchy-v')
const autoStyleShift = ref(false)
const controller = createBeatSyncController({
baseAngles: () => ({ x: baseAngleX.value, y: baseAngleY.value, z: baseAngleZ.value }),
initialStyle: style.value,
autoStyleShift: autoStyleShift.value,
})
const state = reactive({
angleX: baseAngleX.value,
angleY: baseAngleY.value,
angleZ: baseAngleZ.value,
velX: 0,
velY: 0,
velZ: 0,
last: performance.now(),
})
const trail = ref<TrailPoint[]>([])
const scalars = ref<ScalarSample[]>([])
const canvasXY = ref<HTMLCanvasElement>()
const debugState = computed(() => controller.debugState())
const nowTs = ref(performance.now())
const styleOptions: Array<{ label: string, value: BeatSyncStyleName }> = [
{ label: 'Punchy V (10/8/4)', value: 'punchy-v' },
{ label: 'Balanced V (6/0/6)', value: 'balanced-v' },
{ label: 'Swing L/R (A-shape side-to-side)', value: 'swing-lr' },
{ label: 'Sway Sine (lifted arc between sides)', value: 'sway-sine' },
]
watch(style, val => controller.setStyle(val))
watch(autoStyleShift, enabled => controller.setAutoStyleShift(enabled))
const currentPose = computed(() => ({
x: controller.targetX.value,
y: controller.targetY.value,
z: controller.targetZ.value,
}))
const formatDegrees = (value: number) => `${value.toFixed(1)}°`
const formatScale = (value: number) => `${value.toFixed(1)} px/deg`
const formatFade = (value: number) => value.toFixed(2)
function springTowardTarget(now: number) {
const dt = now - state.last
if (!Number.isFinite(dt))
return
state.last = now
controller.updateTargets(now)
// Same semi-implicit Euler params as runtime
const stiffness = 120
const damping = 16
const mass = 1
// X
{
const target = controller.targetX.value
const pos = state.angleX
const vel = state.velX
const accel = (stiffness * (target - pos) - damping * vel) / mass
state.velX = vel + accel * dt
state.angleX = pos + state.velX * dt
}
// Y
{
const target = controller.targetY.value
const pos = state.angleY
const vel = state.velY
const accel = (stiffness * (target - pos) - damping * vel) / mass
state.velY = vel + accel * dt
state.angleY = pos + state.velY * dt
}
// Z
{
const target = controller.targetZ.value
const pos = state.angleZ
const vel = state.velZ
const accel = (stiffness * (target - pos) - damping * vel) / mass
state.velZ = vel + accel * dt
state.angleZ = pos + state.velZ * dt
}
}
function pushSamples(now: number) {
if (!Number.isFinite(state.angleX) || !Number.isFinite(state.angleZ))
return
trail.value.push({ x: state.angleX, y: state.angleZ, t: now })
scalars.value.push({ t: now, x: state.angleX, y: state.angleY, z: state.angleZ })
const cutoff = now - timeWindowMs
while (trail.value.length && trail.value[0].t < cutoff)
trail.value.shift()
while (scalars.value.length && scalars.value[0].t < cutoff)
scalars.value.shift()
}
function drawXY() {
const canvas = canvasXY.value
if (!canvas)
return
const dpr = window.devicePixelRatio || 1
const { clientWidth, clientHeight } = canvas
if (canvas.width !== clientWidth * dpr || canvas.height !== clientHeight * dpr) {
canvas.width = clientWidth * dpr
canvas.height = clientHeight * dpr
}
const ctx = canvas.getContext('2d')
if (!ctx)
return
ctx.save()
ctx.scale(dpr, dpr)
ctx.fillStyle = `rgba(0, 0, 0, ${dampingOverlay.value})`
ctx.fillRect(0, 0, clientWidth, clientHeight)
const centerX = clientWidth / 2
const centerY = clientHeight / 2
ctx.strokeStyle = 'rgba(255,255,255,0.12)'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(0, centerY)
ctx.lineTo(clientWidth, centerY)
ctx.moveTo(centerX, 0)
ctx.lineTo(centerX, clientHeight)
ctx.stroke()
ctx.fillStyle = 'rgba(94,234,212,0.4)'
ctx.strokeStyle = 'rgba(94,234,212,1)'
ctx.lineWidth = 2
ctx.beginPath()
trail.value.forEach((p, idx) => {
const x = centerX + p.x * scale.value
const y = centerY - p.y * scale.value
if (idx === 0)
ctx.moveTo(x, y)
else
ctx.lineTo(x, y)
})
ctx.stroke()
const head = trail.value[trail.value.length - 1]
if (head) {
ctx.beginPath()
ctx.arc(centerX + head.x * scale.value, centerY - head.y * scale.value, 5, 0, Math.PI * 2)
ctx.fill()
}
// Current target marker
ctx.fillStyle = 'rgba(244,114,182,0.8)'
ctx.beginPath()
ctx.arc(centerX + controller.targetY.value * scale.value, centerY - controller.targetZ.value * scale.value, 4, 0, Math.PI * 2)
ctx.fill()
ctx.restore()
}
useRafFn(({ timestamp }) => {
nowTs.value = timestamp
springTowardTarget(timestamp)
pushSamples(timestamp)
drawXY()
})
onMounted(() => {
// Seed initial trail
const now = performance.now()
pushSamples(now)
drawXY()
})
function hitBeat() {
controller.scheduleBeat(performance.now())
}
function hitVSequence() {
const now = performance.now()
const spacing = 180
controller.scheduleBeat(now)
controller.scheduleBeat(now + spacing)
controller.scheduleBeat(now + spacing * 2)
}
</script>
<template>
<div class="grid gap-4 p-4 lg:grid-cols-[2fr_1fr]">
<Section
title="Beat sync driver"
icon="i-solar:cursor-linear"
inner-class="gap-4"
>
<div class="flex flex-wrap items-center gap-3">
<Button label="Hit beat" icon="i-solar:flash-bold-duotone" size="sm" @click="hitBeat" />
<Button
label="Hit V sequence"
icon="i-solar:repeat-one-minimalistic-bold-duotone"
size="sm"
variant="secondary"
@click="hitVSequence"
/>
<FieldCheckbox
v-model="autoStyleShift"
class="min-w-[240px]"
label="Auto style by BPM"
description="Switch styles based on detected tempo"
/>
</div>
<div class="grid gap-4 md:grid-cols-2">
<FieldSelect
v-model="style"
label="Style"
description="Choose how head motion is sculpted between beats"
:options="styleOptions"
layout="vertical"
select-class="w-full"
/>
<Callout label="Current targets" theme="violet">
<div class="text-sm text-neutral-800 dark:text-neutral-100">
X/Y/Z: {{ currentPose.x.toFixed(2) }} / {{ currentPose.y.toFixed(2) }} / {{ currentPose.z.toFixed(2) }}
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Live targets fed into the spring solver.
</div>
</Callout>
</div>
<div class="grid gap-4 md:grid-cols-2">
<FieldRange
v-model="baseAngleX"
label="Base X"
description="Baseline tilt forward/back"
:min="-20"
:max="20"
:step="0.1"
:format-value="formatDegrees"
/>
<FieldRange
v-model="baseAngleY"
label="Base Y"
description="Baseline tilt left/right"
:min="-20"
:max="20"
:step="0.1"
:format-value="formatDegrees"
/>
<FieldRange
v-model="baseAngleZ"
label="Base Z"
description="Baseline roll"
:min="-20"
:max="20"
:step="0.1"
:format-value="formatDegrees"
/>
<FieldRange
v-model="scale"
label="Scale (px/deg)"
description="Trail & marker scale"
:min="2"
:max="18"
:step="0.5"
:format-value="formatScale"
/>
</div>
<div class="grid gap-4 md:grid-cols-2">
<FieldRange
v-model="dampingOverlay"
label="Trail fade"
description="Overlay alpha for XY trace"
:min="0.02"
:max="0.3"
:step="0.01"
:format-value="formatFade"
/>
<Callout label="Controller" theme="lime">
<div class="text-xs text-neutral-700 dark:text-neutral-200">
Beat targets update each frame; the spring here mirrors the runtime Live2D hook.
</div>
</Callout>
</div>
<div class="h-80 w-full overflow-hidden border border-neutral-200/70 rounded-xl bg-neutral-900/80 dark:border-neutral-800/60">
<canvas ref="canvasXY" class="h-full w-full" />
</div>
</Section>
<Section
title="Signals & debug"
icon="i-solar:chart-2-bold-duotone"
inner-class="gap-4"
>
<div class="space-y-3">
<div class="text-sm text-neutral-500 dark:text-neutral-400">
Scalars (Y / Z over time, last {{ (timeWindowMs / 1000).toFixed(1) }}s)
</div>
</div>
<Callout label="Spring model" theme="orange">
<div class="text-xs text-neutral-700 dark:text-neutral-200">
Semi-implicit Euler spring matches Live2D hook (stiffness 120, damping 16). Targets driven by beat controller.
</div>
</Callout>
<div class="text-xs text-neutral-500 space-y-1 dark:text-neutral-400">
<div>Style: {{ debugState.style }}</div>
<div>BPM (avg): {{ debugState.bpm ? debugState.bpm.toFixed(1) : '—' }}</div>
<div>Primed: {{ debugState.primed }}</div>
<div>Pattern started: {{ debugState.patternStarted }}</div>
<div>Segments: {{ debugState.segments.length }}</div>
<div v-if="debugState.segments.length">
Next segment: toY {{ debugState.segments[0].toY.toFixed(2) }}, toZ {{ debugState.segments[0].toZ.toFixed(2) }},
starts in {{ Math.max(0, debugState.segments[0].start - nowTs).toFixed(0) }} ms
</div>
</div>
</Section>
</div>
</template>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { Application } from '@pixi/app'
import type { Cubism4InternalModel, InternalModel } from 'pixi-live2d-display/cubism4'
import type { PixiLive2DInternalModel } from '../../../composables/live2d'
import { listenBeatSyncBeatSignal } from '@proj-airi/stage-shared/beat-sync/browser'
import { useTheme } from '@proj-airi/ui'
@@ -11,18 +12,19 @@ import { DropShadowFilter } from 'pixi-filters'
import { Live2DFactory, Live2DModel, MotionPriority } from 'pixi-live2d-display/cubism4'
import { computed, onMounted, onUnmounted, ref, shallowRef, toRef, watch } from 'vue'
import { useLive2DIdleEyeFocus } from '../../../composables/live2d'
import {
createBeatSyncController,
useLive2DMotionManagerUpdate,
useMotionUpdatePluginAutoEyeBlink,
useMotionUpdatePluginBeatSync,
useMotionUpdatePluginIdleDisable,
useMotionUpdatePluginIdleFocus,
} from '../../../composables/live2d'
import { Emotion, EmotionNeutralMotionName } from '../../../constants/emotions'
import { useLive2d } from '../../../stores/live2d'
import { useSettings } from '../../../stores/settings'
type CubismModel = Cubism4InternalModel['coreModel']
type CubismEyeBlink = Cubism4InternalModel['eyeBlink']
type PixiLive2DInternalModel = InternalModel & {
eyeBlink?: CubismEyeBlink
coreModel: CubismModel
}
const props = withDefaults(defineProps<{
modelSrc?: string
modelId?: string
@@ -72,13 +74,6 @@ const modelSrcRef = toRef(() => props.modelSrc)
const modelLoading = ref(false)
// Beat Sync: Values are all in degrees
const beatSyncTargetY = ref<number>(0)
const beatSyncTargetZ = ref<number>(0)
const beatSyncVelocityY = ref<number>(0)
const beatSyncVelocityZ = ref<number>(0)
// End of Beat Sync
const offset = computed(() => parsePropsOffset())
const pixiApp = toRef(() => props.app)
@@ -93,7 +88,6 @@ const lastUpdateTime = ref(0)
const { isDark: dark } = useTheme()
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = computed(() => breakpoints.between('sm', 'md').value || breakpoints.smaller('sm').value)
const idleEyeFocus = useLive2DIdleEyeFocus()
const dropShadowFilter = shallowRef(new DropShadowFilter({
alpha: 0.2,
blur: 0,
@@ -140,6 +134,14 @@ const {
} = storeToRefs(useSettings())
const localCurrentMotion = ref<{ group: string, index: number }>({ group: 'Idle', index: 0 })
const beatSync = createBeatSyncController({
baseAngles: () => ({
x: modelParameters.value.angleX,
y: modelParameters.value.angleY,
z: modelParameters.value.angleZ,
}),
initialStyle: 'sway-sine',
})
// Listen for model reload requests (e.g., when runtime motion is uploaded)
live2dStore.onShouldUpdateView(() => {
@@ -262,119 +264,22 @@ async function loadModel() {
}
// This is hacky too
const hookedUpdate = motionManager.update as (model: CubismModel, now: number) => boolean
motionManager.update = function (model: CubismModel, now: number) {
const timeDelta = now - lastUpdateTime.value
const motionManagerUpdate = useLive2DMotionManagerUpdate({
internalModel,
motionManager,
modelParameters,
live2dIdleAnimationEnabled,
lastUpdateTime,
})
// Beat Sync
{
// Semi-implicit Euler approach
const stiffness = 120 // Higher -> Snappier
const damping = 16 // Higher -> Less bounce
const mass = 1
motionManagerUpdate.register(useMotionUpdatePluginBeatSync(beatSync), 'pre')
motionManagerUpdate.register(useMotionUpdatePluginIdleDisable(), 'pre')
motionManagerUpdate.register(useMotionUpdatePluginIdleFocus(), 'post')
motionManagerUpdate.register(useMotionUpdatePluginAutoEyeBlink(), 'post')
let paramAngleY = coreModel.getParameterValueById('ParamAngleY') as number
let paramAngleZ = coreModel.getParameterValueById('ParamAngleZ') as number
// Y
{
const target = beatSyncTargetY.value
const pos = paramAngleY
const vel = beatSyncVelocityY.value
const accel = (stiffness * (target - pos) - damping * vel) / mass
beatSyncVelocityY.value = vel + accel * timeDelta
paramAngleY = pos + beatSyncVelocityY.value * timeDelta
// Snap
if (Math.abs(target - paramAngleY) < 0.01 && Math.abs(beatSyncVelocityY.value) < 0.01) {
paramAngleY = target
beatSyncVelocityY.value = 0
}
}
// Z
{
const target = beatSyncTargetZ.value
const pos = paramAngleZ
const vel = beatSyncVelocityZ.value
const accel = (stiffness * (target - pos) - damping * vel) / mass
beatSyncVelocityZ.value = vel + accel * timeDelta
paramAngleZ = pos + beatSyncVelocityZ.value * timeDelta
// Snap
if (Math.abs(target - paramAngleZ) < 0.01 && Math.abs(beatSyncVelocityZ.value) < 0.01) {
paramAngleZ = target
beatSyncVelocityZ.value = 0
}
}
coreModel.setParameterValueById('ParamAngleY', paramAngleY)
coreModel.setParameterValueById('ParamAngleZ', paramAngleZ)
}
lastUpdateTime.value = now
// Check if current motion is an idle motion (including user-selected runtime motion)
const selectedMotionGroup = localStorage.getItem('selected-runtime-motion-group')
const isIdleMotion = !motionManager.state.currentGroup
|| motionManager.state.currentGroup === motionManager.groups.idle
|| (selectedMotionGroup && motionManager.state.currentGroup === selectedMotionGroup)
// Stop idle motions if they're disabled
if (!live2dIdleAnimationEnabled.value && isIdleMotion) {
motionManager.stopAllMotions()
// Still update eye focus and blink even if idle motion is stopped
idleEyeFocus.update(internalModel, now)
if (internalModel.eyeBlink != null) {
internalModel.eyeBlink.updateParameters(model, timeDelta / 1000)
}
// Apply manual eye parameters after auto eye blink
coreModel.setParameterValueById('ParamEyeLOpen', modelParameters.value.leftEyeOpen)
coreModel.setParameterValueById('ParamEyeROpen', modelParameters.value.rightEyeOpen)
return true
}
hookedUpdate?.call(this, model, now)
// Possibility 1: Only update eye focus when the model is idle
// Possibility 2: For models having no motion groups, currentGroup will be undefined while groups can be { idle: ... }
if (isIdleMotion) {
idleEyeFocus.update(internalModel, now)
// If the model has eye blink parameters
if (internalModel.eyeBlink != null) {
// For the part of the auto eye blink implementation in pixi-live2d-display
//
// this.emit("beforeMotionUpdate");
// const motionUpdated = this.motionManager.update(this.coreModel, now);
// this.emit("afterMotionUpdate");
// model.saveParameters();
// this.motionManager.expressionManager?.update(model, now);
// if (!motionUpdated) {
// this.eyeBlink?.updateParameters(model, dt);
// }
//
// https://github.com/guansss/pixi-live2d-display/blob/31317b37d5e22955a44d5b11f37f421e94a11269/src/cubism4/Cubism4InternalModel.ts#L202-L214
//
// If the this.motionManager.update returns true, as motion updated flag on,
// the eye blink parameters will not be updated, in another hand, the auto eye blink is disabled
//
// Since we are hooking the motionManager.update method currently,
// and previously a always `true` was returned, eye blink parameters were never updated.
//
// Thous we are here to manually update the eye blink parameters within this hooked method
internalModel.eyeBlink.updateParameters(model, (now - lastUpdateTime.value) / 1000)
}
// Apply manual eye parameters after auto eye blink
coreModel.setParameterValueById('ParamEyeLOpen', modelParameters.value.leftEyeOpen)
coreModel.setParameterValueById('ParamEyeROpen', modelParameters.value.rightEyeOpen)
// still, mark the motion as updated
return true
}
return false
const hookedUpdate = motionManager.update as (model: PixiLive2DInternalModel['coreModel'], now: number) => boolean
motionManager.update = function (model: PixiLive2DInternalModel['coreModel'], now: number) {
return motionManagerUpdate.hookUpdate(model, now, hookedUpdate)
}
motionManager.on('motionStart', (group, index) => {
@@ -661,12 +566,7 @@ watch(focusAt, (value) => {
})
onMounted(() => {
const onBeat = () => {
beatSyncTargetY.value = Math.max(-5, Math.min(5, (beatSyncTargetY.value < 0 ? 10 : -10) * (0.5 + Math.random() * 0.3)))
beatSyncTargetZ.value = Math.max(-5, Math.min(5, (beatSyncTargetZ.value < 0 ? 10 : -10) * (0.5 + Math.random() * 0.3)))
}
const removeListener = listenBeatSyncBeatSignal(() => onBeat())
const removeListener = listenBeatSyncBeatSignal(() => beatSync.scheduleBeat())
onUnmounted(() => removeListener())
})
@@ -0,0 +1,361 @@
import type { Ref } from 'vue'
import { computed, ref } from 'vue'
export interface BeatBaseAngles { x: number, y: number, z: number }
export interface BeatSyncController {
targetX: Ref<number>
targetY: Ref<number>
targetZ: Ref<number>
velocityX: Ref<number>
velocityY: Ref<number>
velocityZ: Ref<number>
updateTargets: (now: number) => void
scheduleBeat: (timestamp?: number | null) => void
debugState: () => {
primed: boolean
patternStarted: boolean
lastBeatTimestamp: number | null
lastInterval: number | null
avgInterval: number | null
bpm: number | null
style: BeatSyncStyleName
segments: BeatSegment[]
}
setStyle: (style: BeatSyncStyleName) => void
getStyle: () => BeatSyncStyleName
setAutoStyleShift: (enabled: boolean) => void
}
interface BeatSegment {
start: number
duration: number
fromX?: number
fromY: number
fromZ: number
toX?: number
toY: number
toZ: number
}
interface CreateBeatSyncControllerOptions {
baseAngles: () => BeatBaseAngles
releaseDelayMs?: number
defaultIntervalMs?: number
styles?: Partial<Record<BeatSyncStyleName, BeatStyleConfig>>
initialStyle?: BeatSyncStyleName
autoStyleShift?: boolean
}
type BeatStylePattern = 'v' | 'swing' | 'sway'
export type BeatSyncStyleName = 'punchy-v' | 'balanced-v' | 'swing-lr' | 'sway-sine'
interface BeatStyleConfig {
topYaw: number
topRoll: number
bottomDip: number
pattern: BeatStylePattern
swingLift?: number
}
const defaultStyles: Record<BeatSyncStyleName, BeatStyleConfig> = {
'punchy-v': { topYaw: 10, topRoll: 8, bottomDip: 4, pattern: 'v' },
'balanced-v': { topYaw: 6, topRoll: 0, bottomDip: 6, pattern: 'v' },
'swing-lr': { topYaw: 8, topRoll: 0, bottomDip: 6, swingLift: 8, pattern: 'swing' },
// sway uses a three-point path per beat: side A -> side B -> center (A-shape arcs)
'sway-sine': { topYaw: 10, topRoll: 0, bottomDip: 0, swingLift: 10, pattern: 'sway' },
}
export function createBeatSyncController(options: CreateBeatSyncControllerOptions): BeatSyncController {
const {
baseAngles: baseAnglesGetter,
releaseDelayMs = 1800,
defaultIntervalMs = 600,
styles = {},
initialStyle = 'punchy-v',
autoStyleShift = false,
} = options
const styleMap = { ...defaultStyles, ...styles }
const targetX = ref<number>(0)
const targetY = ref<number>(0)
const targetZ = ref<number>(0)
const velocityX = ref<number>(0)
const velocityY = ref<number>(0)
const velocityZ = ref<number>(0)
const segments = ref<BeatSegment[]>([])
const currentTopSide = ref<'left' | 'right'>('left')
const primed = ref(false)
const patternStarted = ref(false)
const lastBeatTimestamp = ref<number | null>(null)
const lastInterval = ref<number | null>(null)
const avgInterval = ref<number | null>(null)
const style = ref<BeatSyncStyleName>(initialStyle)
const autoShift = ref(autoStyleShift)
const baseAngles = computed(() => baseAnglesGetter())
function lerp(from: number, to: number, t: number) {
return from + (to - from) * t
}
function easeOutCubic(t: number) {
return 1 - ((1 - t) ** 3)
}
function getStyleConfig(): BeatStyleConfig {
return styleMap[style.value] || defaultStyles['punchy-v']
}
function getTopPose(side: 'left' | 'right') {
const { topYaw, topRoll, swingLift, pattern } = getStyleConfig()
const direction = side === 'left' ? -1 : 1
const zOffset = (pattern === 'swing' || pattern === 'sway') ? (swingLift ?? topRoll) : topRoll
const z = baseAngles.value.z + (pattern === 'swing' || pattern === 'sway' ? zOffset : direction * zOffset)
return {
y: baseAngles.value.y + (direction * topYaw),
z,
}
}
function getBottomPose() {
const { bottomDip } = getStyleConfig()
return {
y: baseAngles.value.y,
z: baseAngles.value.z - bottomDip,
}
}
function updateTargets(now: number) {
let currentY: number | undefined = targetY.value
let currentZ: number | undefined = targetZ.value
if (!primed.value && !segments.value.length) {
currentY = baseAngles.value.y
currentZ = baseAngles.value.z
}
if (currentY == null)
currentY = baseAngles.value.y
if (currentZ == null)
currentZ = baseAngles.value.z
while (segments.value.length) {
const segment = segments.value[0]
if (now < segment.start) {
currentY = segment.fromY
currentZ = segment.fromZ
break
}
const progress = Math.min(1, (now - segment.start) / Math.max(segment.duration, 1))
const eased = easeOutCubic(progress)
currentY = lerp(segment.fromY, segment.toY, eased)
currentZ = lerp(segment.fromZ, segment.toZ, eased)
if (progress >= 1) {
segments.value.shift()
continue
}
break
}
const lastBeat = lastBeatTimestamp.value
const timeSinceBeat = primed.value && lastBeat != null ? (now - lastBeat) : Infinity
const shouldRelease = primed.value && !segments.value.length && timeSinceBeat > releaseDelayMs
if (shouldRelease) {
primed.value = false
patternStarted.value = false
currentTopSide.value = 'left'
segments.value = []
lastBeatTimestamp.value = null
currentY = baseAngles.value.y
currentZ = baseAngles.value.z
velocityY.value *= 0.5
velocityZ.value *= 0.5
}
targetY.value = currentY
targetZ.value = currentZ
}
function scheduleBeat(timestamp?: number | null) {
const now = timestamp != null && Number.isFinite(timestamp)
? Number(timestamp)
: (typeof performance !== 'undefined' ? performance.now() : Date.now())
updateTargets(now)
if (!primed.value) {
primed.value = true
lastBeatTimestamp.value = now
return
}
const interval = Math.min(2000, Math.max(220, lastBeatTimestamp.value != null ? (now - lastBeatTimestamp.value) : defaultIntervalMs))
lastBeatTimestamp.value = now
lastInterval.value = interval
avgInterval.value = avgInterval.value == null ? interval : (avgInterval.value * 0.7 + interval * 0.3)
if (autoShift.value && avgInterval.value) {
const bpm = 60000 / avgInterval.value
const targetStyle: BeatSyncStyleName = bpm < 120 ? 'swing-lr' : bpm < 180 ? 'balanced-v' : 'punchy-v'
if (targetStyle !== style.value)
style.value = targetStyle
}
const halfDuration = Math.max(80, interval / 2)
const startPose = { y: targetY.value, z: targetZ.value }
segments.value = []
const styleConfig = getStyleConfig()
const nextSide = currentTopSide.value === 'left' ? 'right' : 'left'
if (styleConfig.pattern === 'v') {
if (!patternStarted.value) {
const topPose = getTopPose('left')
segments.value.push({
start: now,
duration: halfDuration,
fromY: startPose.y,
fromZ: startPose.z,
toY: topPose.y,
toZ: topPose.z,
})
patternStarted.value = true
currentTopSide.value = 'left'
return
}
const bottomPose = getBottomPose()
const nextTopPose = getTopPose(nextSide)
segments.value.push({
start: now,
duration: halfDuration,
fromY: startPose.y,
fromZ: startPose.z,
toY: bottomPose.y,
toZ: bottomPose.z,
})
segments.value.push({
start: now + halfDuration,
duration: halfDuration,
fromY: bottomPose.y,
fromZ: bottomPose.z,
toY: nextTopPose.y,
toZ: nextTopPose.z,
})
currentTopSide.value = nextSide
}
else if (styleConfig.pattern === 'swing') {
// swing-lr pattern: beat pulls to current side, then cross to the other side within the interval (A-shape)
const currentSide = currentTopSide.value
const sidePose = getTopPose(currentSide)
const oppositePose = getTopPose(nextSide)
const sidePortion = 0.35
const sideDuration = Math.max(60, interval * sidePortion)
const crossDuration = Math.max(60, interval - sideDuration)
segments.value.push({
start: now,
duration: sideDuration,
fromY: startPose.y,
fromZ: startPose.z,
toY: sidePose.y,
toZ: sidePose.z,
})
segments.value.push({
start: now + sideDuration,
duration: crossDuration,
fromY: sidePose.y,
fromZ: sidePose.z,
toY: oppositePose.y,
toZ: oppositePose.z,
})
patternStarted.value = true
currentTopSide.value = nextSide
}
else if (styleConfig.pattern === 'sway') {
// sway pattern: side A -> lifted mid-arc -> side B (downward parabola / A-shape)
const currentSide = currentTopSide.value
const sidePose = getTopPose(currentSide)
const oppositePose = getTopPose(nextSide)
const centerPose = { y: baseAngles.value.y, z: baseAngles.value.z }
const lift = styleConfig.swingLift ?? 10
// First beat after prime: move to initial side anchor
if (!patternStarted.value) {
segments.value.push({
start: now,
duration: halfDuration,
fromY: startPose.y,
fromZ: startPose.z,
toY: sidePose.y,
toZ: sidePose.z,
})
patternStarted.value = true
currentTopSide.value = currentSide
return
}
const apexPose = {
y: 0,
z: centerPose.z + lift,
}
const leg1 = Math.max(60, interval * 0.5)
const leg2 = Math.max(60, interval - leg1)
segments.value.push({
start: now,
duration: leg1,
fromY: startPose.y,
fromZ: startPose.z,
toY: apexPose.y,
toZ: apexPose.z,
})
segments.value.push({
start: now + leg1,
duration: leg2,
fromY: apexPose.y,
fromZ: apexPose.z,
toY: oppositePose.y,
toZ: oppositePose.z,
})
patternStarted.value = true
currentTopSide.value = nextSide
}
}
return {
targetX,
targetY,
targetZ,
velocityX,
velocityY,
velocityZ,
updateTargets,
scheduleBeat,
setStyle: (s: BeatSyncStyleName) => { style.value = s },
getStyle: () => style.value,
setAutoStyleShift: (enabled: boolean) => { autoShift.value = enabled },
debugState: () => ({
primed: primed.value,
patternStarted: patternStarted.value,
lastBeatTimestamp: lastBeatTimestamp.value,
lastInterval: lastInterval.value,
avgInterval: avgInterval.value,
bpm: avgInterval.value ? 60000 / avgInterval.value : null,
style: style.value,
segments: [...segments.value],
}),
}
}
@@ -1 +1,3 @@
export * from './animation'
export * from './beat-sync'
export * from './motion-manager'
@@ -0,0 +1,252 @@
import type { Cubism4InternalModel, InternalModel } from 'pixi-live2d-display/cubism4'
import type { Ref } from 'vue'
import type { BeatSyncController } from './beat-sync'
import { useLive2DIdleEyeFocus } from './animation'
type CubismModel = Cubism4InternalModel['coreModel']
type CubismEyeBlink = Cubism4InternalModel['eyeBlink']
export type PixiLive2DInternalModel = InternalModel & {
eyeBlink?: CubismEyeBlink
coreModel: CubismModel
}
export interface MotionManagerUpdateContext {
model: CubismModel
now: number
timeDelta: number
hookedUpdate?: (model: CubismModel, now: number) => boolean
}
export type MotionManagerPluginContext = MotionManagerUpdateContext & {
internalModel: PixiLive2DInternalModel
motionManager: PixiLive2DInternalModel['motionManager']
modelParameters: Ref<any>
live2dIdleAnimationEnabled: Ref<boolean>
isIdleMotion: boolean
handled: boolean
markHandled: () => void
}
export type MotionManagerPlugin = (ctx: MotionManagerPluginContext) => void
export interface UseLive2DMotionManagerUpdateOptions {
internalModel: PixiLive2DInternalModel
motionManager: PixiLive2DInternalModel['motionManager']
modelParameters: Ref<any>
live2dIdleAnimationEnabled: Ref<boolean>
lastUpdateTime: Ref<number>
}
export function useLive2DMotionManagerUpdate(options: UseLive2DMotionManagerUpdateOptions) {
const {
internalModel,
motionManager,
modelParameters,
live2dIdleAnimationEnabled,
lastUpdateTime,
} = options
const prePlugins: MotionManagerPlugin[] = []
const postPlugins: MotionManagerPlugin[] = []
function register(plugin: MotionManagerPlugin, stage: 'pre' | 'post' = 'pre') {
if (stage === 'pre')
prePlugins.push(plugin)
else
postPlugins.push(plugin)
}
function runPlugins(plugins: MotionManagerPlugin[], ctx: MotionManagerPluginContext) {
for (const plugin of plugins) {
if (ctx.handled)
break
plugin(ctx)
}
}
function hookUpdate(model: CubismModel, now: number, hookedUpdate?: (model: CubismModel, now: number) => boolean) {
const timeDelta = lastUpdateTime.value ? now - lastUpdateTime.value : 0
const selectedMotionGroup = localStorage.getItem('selected-runtime-motion-group')
const isIdleMotion = !motionManager.state.currentGroup
|| motionManager.state.currentGroup === motionManager.groups.idle
|| (!!selectedMotionGroup && motionManager.state.currentGroup === selectedMotionGroup)
const ctx: MotionManagerPluginContext = {
model,
now,
timeDelta,
hookedUpdate,
internalModel,
motionManager,
modelParameters,
live2dIdleAnimationEnabled,
isIdleMotion,
handled: false,
markHandled: () => {
ctx.handled = true
},
}
runPlugins(prePlugins, ctx)
if (!ctx.handled && ctx.hookedUpdate) {
const result = ctx.hookedUpdate.call(motionManager, model, now)
if (Boolean(result))
ctx.handled = true
}
runPlugins(postPlugins, ctx)
lastUpdateTime.value = now
return ctx.handled
}
return {
register,
hookUpdate,
}
}
// -- Plugins ---------------------------------------------------------------
export function useMotionUpdatePluginBeatSync(beatSync: BeatSyncController): MotionManagerPlugin {
return (ctx) => {
beatSync.updateTargets(ctx.now)
// Semi-implicit Euler approach
const stiffness = 120 // Higher -> Snappier
const damping = 16 // Higher -> Less bounce
const mass = 1
let paramAngleX = ctx.model.getParameterValueById('ParamAngleX') as number
let paramAngleY = ctx.model.getParameterValueById('ParamAngleY') as number
let paramAngleZ = ctx.model.getParameterValueById('ParamAngleZ') as number
// X
{
const target = beatSync.targetX.value
const pos = paramAngleX
const vel = beatSync.velocityX.value
const accel = (stiffness * (target - pos) - damping * vel) / mass
beatSync.velocityX.value = vel + accel * ctx.timeDelta
paramAngleX = pos + beatSync.velocityX.value * ctx.timeDelta
if (Math.abs(target - paramAngleX) < 0.01 && Math.abs(beatSync.velocityX.value) < 0.01) {
paramAngleX = target
beatSync.velocityX.value = 0
}
}
// Y
{
const target = beatSync.targetY.value
const pos = paramAngleY
const vel = beatSync.velocityY.value
const accel = (stiffness * (target - pos) - damping * vel) / mass
beatSync.velocityY.value = vel + accel * ctx.timeDelta
paramAngleY = pos + beatSync.velocityY.value * ctx.timeDelta
// Snap
if (Math.abs(target - paramAngleY) < 0.01 && Math.abs(beatSync.velocityY.value) < 0.01) {
paramAngleY = target
beatSync.velocityY.value = 0
}
}
// Z
{
const target = beatSync.targetZ.value
const pos = paramAngleZ
const vel = beatSync.velocityZ.value
const accel = (stiffness * (target - pos) - damping * vel) / mass
beatSync.velocityZ.value = vel + accel * ctx.timeDelta
paramAngleZ = pos + beatSync.velocityZ.value * ctx.timeDelta
// Snap
if (Math.abs(target - paramAngleZ) < 0.01 && Math.abs(beatSync.velocityZ.value) < 0.01) {
paramAngleZ = target
beatSync.velocityZ.value = 0
}
}
ctx.model.setParameterValueById('ParamAngleX', paramAngleX)
ctx.model.setParameterValueById('ParamAngleY', paramAngleY)
ctx.model.setParameterValueById('ParamAngleZ', paramAngleZ)
}
}
export function useMotionUpdatePluginIdleDisable(idleEyeFocus = useLive2DIdleEyeFocus()): MotionManagerPlugin {
return (ctx) => {
if (ctx.handled)
return
// Stop idle motions if they're disabled
if (!ctx.live2dIdleAnimationEnabled.value && ctx.isIdleMotion) {
ctx.motionManager.stopAllMotions()
// Still update eye focus and blink even if idle motion is stopped
idleEyeFocus.update(ctx.internalModel, ctx.now)
if (ctx.internalModel.eyeBlink != null) {
ctx.internalModel.eyeBlink.updateParameters(ctx.model, ctx.timeDelta / 1000)
}
// Apply manual eye parameters after auto eye blink
ctx.model.setParameterValueById('ParamEyeLOpen', ctx.modelParameters.value.leftEyeOpen)
ctx.model.setParameterValueById('ParamEyeROpen', ctx.modelParameters.value.rightEyeOpen)
ctx.markHandled()
}
}
}
export function useMotionUpdatePluginIdleFocus(idleEyeFocus = useLive2DIdleEyeFocus()): MotionManagerPlugin {
return (ctx) => {
if (!ctx.isIdleMotion || ctx.handled)
return
idleEyeFocus.update(ctx.internalModel, ctx.now)
}
}
export function useMotionUpdatePluginAutoEyeBlink(): MotionManagerPlugin {
return (ctx) => {
// Possibility 1: Only update eye focus when the model is idle
// Possibility 2: For models having no motion groups, currentGroup will be undefined while groups can be { idle: ... }
if (!ctx.isIdleMotion || ctx.handled)
return
// If the model has eye blink parameters
if (ctx.internalModel.eyeBlink != null) {
// For the part of the auto eye blink implementation in pixi-live2d-display
//
// this.emit("beforeMotionUpdate");
// const motionUpdated = this.motionManager.update(this.coreModel, now);
// this.emit("afterMotionUpdate");
// model.saveParameters();
// this.motionManager.expressionManager?.update(model, now);
// if (!motionUpdated) {
// this.eyeBlink?.updateParameters(model, dt);
// }
//
// https://github.com/guansss/pixi-live2d-display/blob/31317b37d5e22955a44d5b11f37f421e94a11269/src/cubism4/Cubism4InternalModel.ts#L202-L214
//
// If the this.motionManager.update returns true, as motion updated flag on,
// the eye blink parameters will not be updated, in another hand, the auto eye blink is disabled
//
// Since we are hooking the motionManager.update method currently,
// and previously a always `true` was returned, eye blink parameters were never updated.
//
// Thous we are here to manually update the eye blink parameters within this hooked method
ctx.internalModel.eyeBlink.updateParameters(ctx.model, ctx.timeDelta / 1000)
}
// Apply manual eye parameters after auto eye blink
ctx.model.setParameterValueById('ParamEyeLOpen', ctx.modelParameters.value.leftEyeOpen)
ctx.model.setParameterValueById('ParamEyeROpen', ctx.modelParameters.value.rightEyeOpen)
ctx.markHandled()
}
}