From b2dfe03d11c4773b90cbcc4261fdcc45a39927c7 Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Thu, 21 May 2026 02:53:02 +0800 Subject: [PATCH] fix(stage-ui-live2d): better blink interval --- .../composables/live2d/motion-manager.test.ts | 44 +++++++++++++++++++ .../src/composables/live2d/motion-manager.ts | 8 +++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/stage-ui-live2d/src/composables/live2d/motion-manager.test.ts b/packages/stage-ui-live2d/src/composables/live2d/motion-manager.test.ts index ba98bbbed..71577d055 100644 --- a/packages/stage-ui-live2d/src/composables/live2d/motion-manager.test.ts +++ b/packages/stage-ui-live2d/src/composables/live2d/motion-manager.test.ts @@ -135,4 +135,48 @@ describe('live2d motion manager plugins', () => { expect(context.internalModel.eyeBlink?.updateParameters).not.toHaveBeenCalled() expect(context.handled).toBe(true) }) + + /** + * @example + * expect(context.model.getParameterValueById('ParamEyeLOpen')).toBeLessThan(1) + */ + it('opens force blink over a randomized 150ms to 300ms duration', () => { + const randomSpy = vi.spyOn(Math, 'random') + .mockReturnValueOnce(0) + .mockReturnValueOnce(0.5) + + const context = createContext({ + live2dAutoBlinkEnabled: ref(true), + live2dForceAutoBlinkEnabled: ref(true), + timeDelta: 3, + }) + const plugin = useMotionUpdatePluginAutoEyeBlink(ref(false)) + + // ROOT CAUSE: + // + // Force blink previously reopened at one fixed speed. + // That made closed eyes feel either too quick or too slow depending on + // the model's eye-open parameter range. + // + // We fixed this by randomizing each opening phase between 150ms and 300ms. + plugin(context) + context.timeDelta = 0.075 + context.handled = false + plugin(context) + context.timeDelta = 0.15 + context.handled = false + plugin(context) + + expect(context.model.getParameterValueById('ParamEyeLOpen')).toBeCloseTo(4 / 9) + expect(context.model.getParameterValueById('ParamEyeROpen')).toBeCloseTo(4 / 9) + + context.timeDelta = 0.075 + context.handled = false + plugin(context) + + expect(context.model.getParameterValueById('ParamEyeLOpen')).toBe(1) + expect(context.model.getParameterValueById('ParamEyeROpen')).toBe(1) + + randomSpy.mockRestore() + }) }) diff --git a/packages/stage-ui-live2d/src/composables/live2d/motion-manager.ts b/packages/stage-ui-live2d/src/composables/live2d/motion-manager.ts index 181573f86..1c9a13010 100644 --- a/packages/stage-ui-live2d/src/composables/live2d/motion-manager.ts +++ b/packages/stage-ui-live2d/src/composables/live2d/motion-manager.ts @@ -255,6 +255,7 @@ export function useMotionUpdatePluginAutoEyeBlink( startLeft: 1, startRight: 1, delayMs: 0, + openDurationMs: 300, } // Eye values captured at blink start. Used as the base during @@ -263,11 +264,13 @@ export function useMotionUpdatePluginAutoEyeBlink( let preBlinkLeft = 1.0 let preBlinkRight = 1.0 const blinkCloseDuration = 75 // ms - const blinkOpenDuration = 75 // ms + const minBlinkOpenDuration = 150 // ms + const maxBlinkOpenDuration = 300 // ms const minDelay = 3000 const maxDelay = 8000 const clamp01 = (value: number) => Math.min(1, Math.max(0, value)) + const randomBlinkOpenDuration = () => minBlinkOpenDuration + Math.random() * (maxBlinkOpenDuration - minBlinkOpenDuration) function resetBlinkState() { blinkState.phase = 'idle' @@ -307,13 +310,14 @@ export function useMotionUpdatePluginAutoEyeBlink( if (blinkState.progress >= 1) { blinkState.phase = 'opening' blinkState.progress = 0 + blinkState.openDurationMs = randomBlinkOpenDuration() } return { eyeLOpen, eyeROpen } } // Opening: move back to the base with ease-in. - blinkState.progress = Math.min(1, blinkState.progress + dt / blinkOpenDuration) + blinkState.progress = Math.min(1, blinkState.progress + dt / blinkState.openDurationMs) const eased = easeInQuad(blinkState.progress) const eyeLOpen = clamp01(blinkState.startLeft * eased) const eyeROpen = clamp01(blinkState.startRight * eased)