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