fix(stage-*): many live2d and vrm issues, default values were missing

This commit is contained in:
Neko Ayaka
2026-05-21 02:25:04 +08:00
parent 7f9eeb715d
commit 92b20b0dd2
27 changed files with 807 additions and 317 deletions
+22 -11
View File
@@ -11,14 +11,14 @@ import { BackgroundProvider } from '@proj-airi/stage-layouts/components/Backgrou
import { useBackgroundThemeColor } from '@proj-airi/stage-layouts/composables/theme-color'
import { useBackgroundStore } from '@proj-airi/stage-layouts/stores/background'
import { IS_DEV } from '@proj-airi/stage-shared'
import { WidgetStage } from '@proj-airi/stage-ui/components/scenes'
import { ViewControlSlider, WidgetStage } from '@proj-airi/stage-ui/components/scenes'
import { useAudioRecorder } from '@proj-airi/stage-ui/composables/audio/audio-recorder'
import { useVAD } from '@proj-airi/stage-ui/stores/ai/models/vad'
import { useChatOrchestratorStore } from '@proj-airi/stage-ui/stores/chat'
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
import { useHearingSpeechInputPipeline } from '@proj-airi/stage-ui/stores/modules/hearing'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { breakpointsTailwind, useBreakpoints, useMouse } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
@@ -38,6 +38,7 @@ const isMobile = breakpoints.smaller('md')
const backgroundStore = useBackgroundStore()
const { selectedOption, sampledColor } = storeToRefs(backgroundStore)
const backgroundSurface = useTemplateRef<InstanceType<typeof BackgroundProvider>>('backgroundSurface')
const { stageModelRenderer } = storeToRefs(useSettings())
const { syncBackgroundTheme } = useBackgroundThemeColor({ backgroundSurface, selectedOption, sampledColor })
onMounted(() => syncBackgroundTheme())
@@ -189,15 +190,25 @@ watch([stream, () => vadLoaded.value], async ([s, loaded]) => {
</div>
<!-- page -->
<div relative flex="~ 1 row gap-y-0 gap-x-2 <md:col" min-h-0>
<WidgetStage
min-w="1/2"
min-h-0 flex-1
:paused="paused"
:focus-at="{
x: positionCursor.x.value,
y: positionCursor.y.value,
}"
/>
<div relative min-w="1/2" min-h-0 flex-1>
<div
absolute left-0 z-15 px-3
:class="[
stageModelRenderer === 'live2d' ? 'top-0 h-full py-[20vh]' : 'top-1/2 -translate-y-1/2',
]"
>
<ViewControlSlider />
</div>
<WidgetStage
h-full w-full
:enable-orbit-controls="!isMobile"
:paused="paused"
:focus-at="{
x: positionCursor.x.value,
y: positionCursor.y.value,
}"
/>
</div>
<InteractiveArea v-if="!isMobile" h="85dvh" absolute right-4 flex flex-1 flex-col max-w="500px" min-w="30%" />
<MobileInteractiveArea v-if="isMobile" @settings-open="handleSettingsOpen">
<template v-if="IS_DEV" #status>
@@ -15,7 +15,6 @@ import {
useElectronRelativeMouse,
} from '@proj-airi/electron-vueuse'
import { IS_DEV } from '@proj-airi/stage-shared'
import { useSettingsLive2d } from '@proj-airi/stage-ui-live2d'
import { useModelStore, useThreeSceneIsTransparentAtPoint } from '@proj-airi/stage-ui-three'
import { HoloCoupon } from '@proj-airi/stage-ui/components'
import {
@@ -456,13 +455,7 @@ watch([stream, () => vadLoaded.value], async ([s, loaded]) => {
// Assistant caption is broadcast from Stage.vue via the same channel
const { live2dEyeTrackingSource } = storeToRefs(useSettingsLive2d())
live2dEyeTrackingSource.value = computed(() => ({
x: relativeMouseX.value,
y: relativeMouseY.value,
}))
const { trackingSource } = storeToRefs(useModelStore())
trackingSource.value = computed(() => ({
const cursorPosition = computed(() => ({
x: relativeMouseX.value,
y: relativeMouseY.value,
}))
@@ -500,6 +493,7 @@ trackingSource.value = computed(() => ({
v-model:state="componentStateStage"
h-full w-full
flex-1
:cursor-position="cursorPosition"
:paused="stagePaused"
/>
<HoloCoupon />
+20 -15
View File
@@ -10,17 +10,15 @@ import workletUrl from '@proj-airi/stage-ui/workers/vad/process.worklet?worker&u
import { BackgroundProvider } from '@proj-airi/stage-layouts/components/Backgrounds'
import { useBackgroundThemeColor } from '@proj-airi/stage-layouts/composables/theme-color'
import { useBackgroundStore } from '@proj-airi/stage-layouts/stores/background'
import { useSettingsLive2d } from '@proj-airi/stage-ui-live2d'
import { useModelStore } from '@proj-airi/stage-ui-three'
import { HoloCoupon } from '@proj-airi/stage-ui/components'
import { WidgetStage } from '@proj-airi/stage-ui/components/scenes'
import { ViewControlSlider, WidgetStage } from '@proj-airi/stage-ui/components/scenes'
import { useAudioRecorder } from '@proj-airi/stage-ui/composables/audio/audio-recorder'
import { useVAD } from '@proj-airi/stage-ui/stores/ai/models/vad'
import { useChatOrchestratorStore } from '@proj-airi/stage-ui/stores/chat'
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
import { useHearingSpeechInputPipeline } from '@proj-airi/stage-ui/stores/modules/hearing'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { breakpointsTailwind, useBreakpoints, useMouse } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
@@ -37,6 +35,7 @@ const isMobile = breakpoints.smaller('md')
const backgroundStore = useBackgroundStore()
const { selectedOption, sampledColor } = storeToRefs(backgroundStore)
const backgroundSurface = useTemplateRef<InstanceType<typeof BackgroundProvider>>('backgroundSurface')
const { stageModelRenderer } = storeToRefs(useSettings())
const { syncBackgroundTheme } = useBackgroundThemeColor({ backgroundSurface, selectedOption, sampledColor })
onMounted(() => syncBackgroundTheme())
@@ -149,14 +148,8 @@ watch([stream, () => vadLoaded.value], async ([s, loaded]) => {
}
})
const { live2dEyeTrackingSource } = storeToRefs(useSettingsLive2d())
const { x: mouseX, y: mouseY } = useMouse()
live2dEyeTrackingSource.value = computed(() => ({
x: mouseX.value,
y: mouseY.value,
}))
const { trackingSource } = storeToRefs(useModelStore())
trackingSource.value = computed(() => ({
const cursorPosition = computed(() => ({
x: mouseX.value,
y: mouseY.value,
}))
@@ -177,10 +170,22 @@ trackingSource.value = computed(() => ({
</div>
<!-- page -->
<div relative flex="~ 1 row gap-y-0 gap-x-2 <md:col">
<WidgetStage
flex-1 min-w="1/2"
:paused="paused"
/>
<div relative flex-1 min-w="1/2">
<div
absolute left-0 z-15 px-3
:class="[
stageModelRenderer === 'live2d' ? 'top-0 h-full py-[20vh]' : 'top-1/2 -translate-y-1/2',
]"
>
<ViewControlSlider />
</div>
<WidgetStage
h-full w-full
:cursor-position="cursorPosition"
:enable-orbit-controls="!isMobile"
:paused="paused"
/>
</div>
<InteractiveArea v-if="!isMobile" h="85dvh" absolute right-4 flex flex-1 flex-col max-w="500px" min-w="30%" />
<MobileInteractiveArea v-if="isMobile" @settings-open="handleSettingsOpen" />
</div>
+9 -3
View File
@@ -165,17 +165,23 @@ live2d:
title: Animation
focus:
title: Enable mouse tracking
description: Track current mouse position. May conflict with force idle eye animation
description: Look at the cursor while it is moving. Idle eye movement resumes after the cursor stops.
offset: Eye offset(%)
force-idle-eye-animation:
title: Force eye animation when idle
description: Randomly look around when idle
title: Idle eye movement
description: Randomly look around when there is no active cursor focus.
blink-enable:
title: Enable blink
description: Let the model blink.
auto-blink:
title: Auto blink
description: Use default blinking animation of the model
force-auto-blink:
title: Force auto blink
description: Force blinking animation with external timer
blink-mode:
title: Blink mode
description: Choose the model built-in blink or AIRI's forced blink timer.
idle-motion:
title: Idle animation
no-motion: No animation available
@@ -154,17 +154,23 @@ live2d:
title: 动画
focus:
title: 启用鼠标追踪
description: 让模型看向当前光标所在位置。可能与强制眼球动画冲突
description: 光标移动时让模型看向光标;光标停止后恢复待机眼球动画。
offset: 眼部偏移量(%)
force-idle-eye-animation:
title: 强制眼球动画
description: 待机时随机看向屏幕各处
title: 待机眼球动画
description: 没有活跃光标焦点时随机看向屏幕各处
blink-enable:
title: 启用眨眼
description: 让模型眨眼。
auto-blink:
title: 使用眨眼动画
description: 使用模型默认眨眼动画
force-auto-blink:
title: 强制眨眼动画
description: 强制模型眨眼,模型无眨眼动画时可用。
blink-mode:
title: 眨眼模式
description: 选择模型内置眨眼,或使用 AIRI 的强制眨眼计时器。
idle-motion:
title: 待机动作
no-motion: 无可用动作
@@ -1,70 +0,0 @@
<script setup lang="ts">
import { RoundRange } from '@proj-airi/ui'
import { computed, onUnmounted } from 'vue'
import { defaultControlConfig as conf, formatter, useL2dViewControl } from '../../stores'
const { scale, position, viewControlsEnabled, viewControlMode, set: setValue } = useL2dViewControl()
const controlledValue = computed({
get() {
switch (viewControlMode.value) {
case 'x':
return position.value.x
case 'y':
return position.value.y
case 'scale':
return scale.value
default: throw new Error(`Unexpected control key: ${viewControlMode.value}`)
}
},
set(value) {
setValue(viewControlMode.value, value)
},
})
const formattedValue = computed(() => {
return formatter[viewControlMode.value](controlledValue.value)
})
onUnmounted(() => {
viewControlsEnabled.value = false
})
</script>
<template>
<Transition name="fade-side-pops-in">
<div v-if="viewControlsEnabled">
<Transition name="fade-side-pops-in" mode="out-in">
<div :key="viewControlMode" relative class="[&_.round-range-tooltip]:hover:opacity-100">
<RoundRange
v-model="controlledValue" :min="conf[viewControlMode].min" :max="conf[viewControlMode].max" :step="conf[viewControlMode].step" handle-wheel
data-direction="vertical" h="50%" write-vertical-left
/>
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
{{ formattedValue }}
</div>
</div>
</Transition>
</div>
</Transition>
</template>
<style scoped>
.fade-side-pops-in-enter-active,
.fade-side-pops-in-leave-active {
transition: all 0.2s ease-in-out;
}
.fade-side-pops-in-enter-from,
.fade-side-pops-in-leave-to {
opacity: 0;
transform: translateX(-100%) scale(0.8);
}
.fade-side-pops-in-enter-to,
.fade-side-pops-in-leave-from {
opacity: 1;
transform: translateX(0) scale(1);
}
</style>
@@ -1,18 +1,20 @@
<script setup lang="ts">
import type { Live2DEyeFocusSource } from '../../composables/live2d'
import { Screen } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { ref, watch } from 'vue'
import { onUnmounted, ref, watch } from 'vue'
import SliderControls from '../ViewControls/SliderControls.vue'
import Live2DCanvas from './live2d/Canvas.vue'
import Live2DModel from './live2d/Model.vue'
import { useEyeTracking, useSettingsLive2d } from '../../composables/live2d'
import { useLive2DEyeFocusFor, useSettingsLive2d } from '../../composables/live2d'
import '../../utils/live2d-zip-loader'
import '../../utils/live2d-opfs-registration'
withDefaults(defineProps<{
const props = withDefaults(defineProps<{
cursorPosition?: Live2DEyeFocusSource
modelSrc?: string
modelId?: string
@@ -35,6 +37,8 @@ const componentStateModel = defineModel<'pending' | 'loading' | 'mounted'>('mode
const live2dCanvasRef = ref<InstanceType<typeof Live2DCanvas>>()
const live2dModelRef = ref<InstanceType<typeof Live2DModel>>()
const activeCursorPosition = ref<Live2DEyeFocusSource | null>(null)
let clearCursorFocusTimeout: ReturnType<typeof setTimeout> | undefined
const {
live2dEyeTracking,
@@ -47,14 +51,29 @@ const {
live2dRenderScale,
live2dShadowEnabled,
} = storeToRefs(useSettingsLive2d())
const mouseFocus = useEyeTracking(
() => live2dCanvasRef.value?.canvasElement(),
() => ({
const mouseFocus = useLive2DEyeFocusFor({
canvas: () => live2dCanvasRef.value?.canvasElement(),
model: () => ({
normalizedScale: live2dModelRef.value?.modelNormalizeParams.scale ?? 1,
modelWidth: live2dModelRef.value?.initialModelWidth ?? 1000,
modelHeight: live2dModelRef.value?.initialModelHeight ?? 1000,
}),
)
source: activeCursorPosition,
})
watch(() => props.cursorPosition, (cursorPosition) => {
activeCursorPosition.value = cursorPosition ? { ...cursorPosition } : null
if (clearCursorFocusTimeout)
clearTimeout(clearCursorFocusTimeout)
clearCursorFocusTimeout = setTimeout(() => {
activeCursorPosition.value = null
}, 1000)
})
onUnmounted(() => {
if (clearCursorFocusTimeout)
clearTimeout(clearCursorFocusTimeout)
})
watch([componentStateModel, componentStateCanvas], () => {
componentState.value = (componentStateModel.value === 'mounted' && componentStateCanvas.value === 'mounted')
@@ -74,9 +93,6 @@ defineExpose({
<template>
<Screen v-slot="{ width, height }" relative>
<div absolute top-0 z-15 h-full px-3 py="20vh">
<SliderControls />
</div>
<Live2DCanvas
ref="live2dCanvasRef"
v-slot="{ app }"
@@ -100,6 +116,7 @@ defineExpose({
:paused="paused"
:focus-at="mouseFocus"
:eye-tracking="live2dEyeTracking"
:eye-focus-source-active="!!activeCursorPosition"
:theme-colors-hue="themeColorsHue"
:theme-colors-hue-dynamic="themeColorsHueDynamic"
:live2d-idle-animation-enabled="live2dIdleAnimationEnabled"
@@ -17,7 +17,6 @@ import { computed, onMounted, onUnmounted, ref, shallowRef, toRef, watch } from
import {
createBeatSyncController,
useExpressionController,
useLive2DMotionManagerUpdate,
useMotionUpdatePluginAutoEyeBlink,
useMotionUpdatePluginBeatSync,
@@ -42,6 +41,7 @@ const props = withDefaults(defineProps<{
paused?: boolean
focusAt?: { x: number, y: number }
eyeTracking?: boolean
eyeFocusSourceActive?: boolean
themeColorsHue?: number
themeColorsHueDynamic?: boolean
live2dIdleAnimationEnabled?: boolean
@@ -56,6 +56,7 @@ const props = withDefaults(defineProps<{
paused: false,
focusAt: () => ({ x: 0, y: 0 }),
eyeTracking: false,
eyeFocusSourceActive: false,
disableFocusAt: false,
scale: 1,
themeColorsHue: 220.44,
@@ -166,6 +167,8 @@ const {
const themeColorsHue = toRef(() => props.themeColorsHue)
const themeColorsHueDynamic = toRef(() => props.themeColorsHueDynamic)
const live2dIdleAnimationEnabled = toRef(() => props.live2dIdleAnimationEnabled)
const live2dEyeTrackingEnabled = toRef(() => props.eyeTracking)
const live2dEyeFocusSourceActive = toRef(() => props.eyeFocusSourceActive)
const live2dForceIdleEyeAnimation = toRef(() => props.live2dForceIdleEyeAnimation)
const live2dAutoBlinkEnabled = toRef(() => props.live2dAutoBlinkEnabled)
const live2dForceAutoBlinkEnabled = toRef(() => props.live2dForceAutoBlinkEnabled)
@@ -338,6 +341,8 @@ async function loadModel() {
internalModel,
motionManager,
modelParameters,
live2dEyeTrackingEnabled,
live2dEyeFocusSourceActive,
live2dIdleAnimationEnabled,
live2dForceIdleEyeAnimation,
live2dAutoBlinkEnabled,
@@ -0,0 +1,79 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
vi.mock('@proj-airi/stage-shared/composables', () => {
function useLocalStorageManualReset<T>(_key: string, initialValue: T) {
const state = ref(initialValue)
return Object.assign(state, {
reset: () => {
state.value = initialValue
},
})
}
return {
useLocalStorageManualReset,
useVersionedLocalStorageManualReset: useLocalStorageManualReset,
}
})
vi.mock('@vueuse/core', async () => {
const vue = await import('vue')
return {
useLocalStorage: vi.fn((_key, initialValue) => vue.ref(initialValue)),
}
})
describe('useLive2DEyeFocusFor', () => {
beforeEach(() => {
vi.resetModules()
setActivePinia(createPinia())
})
/**
* @example
* expect(focus.value).toEqual({ x: 200, y: 100 })
*/
it('maps a plain tracking source into Live2D render coordinates', async () => {
const { useSettingsLive2d } = await import('./live2d')
const { useLive2DEyeFocusFor } = await import('./eye-tracking')
const settings = useSettingsLive2d()
settings.live2dRenderScale = 2
const canvas = {
getBoundingClientRect: () => ({ left: 10, top: 20 }),
} as HTMLCanvasElement
const focus = useLive2DEyeFocusFor({
canvas: () => canvas,
model: () => ({ normalizedScale: 1, modelWidth: 1000, modelHeight: 1000 }),
source: () => ({ x: 110, y: 70 }),
})
await nextTick()
expect(focus.value).toEqual({ x: 200, y: 100 })
})
/**
* @example
* expect(focus.value).toEqual({ x: 1000, y: 1000 })
*/
it('returns an off-canvas focus target when tracking source is absent', async () => {
const { useLive2DEyeFocusFor } = await import('./eye-tracking')
const canvas = {
getBoundingClientRect: () => ({ left: 10, top: 20 }),
} as HTMLCanvasElement
const focus = useLive2DEyeFocusFor({
canvas: () => canvas,
model: () => ({ normalizedScale: 1, modelWidth: 1000, modelHeight: 1000 }),
source: () => null,
})
await nextTick()
expect(focus.value).toEqual({ x: 1000, y: 1000 })
})
})
@@ -6,29 +6,42 @@ import { computed, toValue } from 'vue'
import { useL2dViewControl } from '../../stores'
import { useSettingsLive2d } from './live2d'
const { live2dRenderScale, live2dModelEyeOffset, live2dEyeTrackingSource } = storeToRefs(useSettingsLive2d())
const { scale } = useL2dViewControl()
export interface Live2DEyeFocusSource {
x: number
y: number
}
/**
* Maps the tracking source's position into the model's rendering space. Does not enable tracking by itself.
* @param canvas the canvas that draws the avatar
* @param model normalized scale and dimensions of the model
* @returns a computed ref that maps the tracking source's position into the model's rendering space
* Maps a cursor position into the Live2D model driver's eye focus coordinates.
*
* Use when:
* - A Live2D scene owns the canvas and fitted model geometry.
* - A parent component provides cursor coordinates in the same client coordinate space as the canvas rect.
*
* Expects:
* - Source coordinates are relative to the browser viewport or Electron window that contains the canvas.
* - The canvas element exposes a client rect in that same coordinate space.
*
* Returns:
* - A computed focus point suitable for `Live2DModel.focus(x, y)`.
*/
export function useEyeTracking(
canvas: MaybeRefOrGetter<HTMLCanvasElement | undefined>,
export function useLive2DEyeFocusFor(options: {
canvas: MaybeRefOrGetter<HTMLCanvasElement | undefined>
model: MaybeRefOrGetter<{
normalizedScale: number
modelWidth: number
modelHeight: number
}>,
): ComputedRef<{ x: number, y: number }> {
}>
source: MaybeRefOrGetter<Live2DEyeFocusSource | null | undefined>
}): ComputedRef<{ x: number, y: number }> {
const { live2dRenderScale, live2dModelEyeOffset } = storeToRefs(useSettingsLive2d())
const { scale } = useL2dViewControl()
const mouseFocus = computed(() => {
const { normalizedScale, modelWidth, modelHeight } = toValue(model)
const { normalizedScale, modelWidth, modelHeight } = toValue(options.model)
const renderScale = live2dRenderScale.value
// does not require further unwrapping for some reason
const trackingSource = live2dEyeTrackingSource.value as { x: number, y: number } | null
const canvasRect = toValue(canvas)?.getBoundingClientRect()
const trackingSource = toValue(options.source)
const canvasRect = toValue(options.canvas)?.getBoundingClientRect()
if (!trackingSource || !(canvasRect)) {
return { x: 1000, y: 1000 }
}
@@ -0,0 +1,63 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
vi.mock('@proj-airi/stage-shared/composables', () => {
function useLocalStorageManualReset<T>(_key: string, initialValue: T) {
const state = ref(initialValue)
return Object.assign(state, {
reset: () => {
state.value = initialValue
},
})
}
return {
useLocalStorageManualReset,
useVersionedLocalStorageManualReset: useLocalStorageManualReset,
}
})
describe('useSettingsLive2d', () => {
beforeEach(() => {
vi.resetModules()
setActivePinia(createPinia())
})
/**
* @example
* expect(settings.live2dEyeTracking).toBe(true)
*/
it('defaults mouse tracking to enabled to preserve the old focus behavior', async () => {
const { useSettingsLive2d } = await import('./live2d')
const settings = useSettingsLive2d()
expect(settings.live2dEyeTracking).toBe(true)
})
/**
* @example
* expect(settings.live2dForceIdleEyeAnimation).toBe(true)
*/
it('defaults idle eye animation to enabled to preserve no-cursor idle behavior', async () => {
const { useSettingsLive2d } = await import('./live2d')
const settings = useSettingsLive2d()
expect(settings.live2dForceIdleEyeAnimation).toBe(true)
})
/**
* @example
* expect(settings.live2dAutoBlinkEnabled).toBe(true)
*/
it('defaults blink to enabled with forced blink mode', async () => {
const { useSettingsLive2d } = await import('./live2d')
const settings = useSettingsLive2d()
expect(settings.live2dAutoBlinkEnabled).toBe(true)
expect(settings.live2dForceAutoBlinkEnabled).toBe(true)
})
})
@@ -1,24 +1,13 @@
import type { ComputedRef, Ref } from 'vue'
import { useLocalStorageManualReset, useVersionedLocalStorageManualReset } from '@proj-airi/stage-shared/composables'
import { defineStore } from 'pinia'
import { ref } from 'vue'
const live2dEyeTracking = useLocalStorageManualReset<boolean>('settings/live2d/eye-tracking', false)
/**
* A position to perform eye-tracking on.
* Should be a position relative to the application window:
* - for browser targets, it should be the top-left corner of the viewport
* - for tamagotchi targets, it should be the top-left corner of
* the application window that renders the model
*/
const live2dEyeTrackingSource: Ref<ComputedRef<{ x: number, y: number }> | null> = ref(null)
const live2dEyeTracking = useLocalStorageManualReset<boolean>('settings/live2d/eye-tracking', true)
/** Offset from model center to the eyes of the model, in percentages of full model width/height */
const live2dModelEyeOffset = useLocalStorageManualReset('settings/live2d/model-eye-offset', { x: 0, y: 0 })
const live2dIdleAnimationEnabled = useLocalStorageManualReset<boolean>('settings/live2d/idle-animation-enabled', true)
/** Force the avatar to look around. May conflict with eye tracking and idle animation. */
const live2dForceIdleEyeAnimation = useLocalStorageManualReset<boolean>('settings/live2d/idle-eye-animation-enabled', false)
const live2dAutoBlinkEnabled = useVersionedLocalStorageManualReset<boolean>('settings/live2d/auto-blink-enabled', false, {
/** Let the avatar look around while no cursor tracking source is active. */
const live2dForceIdleEyeAnimation = useLocalStorageManualReset<boolean>('settings/live2d/idle-eye-animation-enabled', true)
const live2dAutoBlinkEnabled = useVersionedLocalStorageManualReset<boolean>('settings/live2d/auto-blink-enabled', true, {
defaultVersion: '2.0.0',
satisfiesVersionBy(beforeVersion, afterVersion) {
if (beforeVersion === afterVersion) {
@@ -59,7 +48,6 @@ function resetState() {
export const useSettingsLive2d = defineStore('settings-live2d', () => {
return {
live2dEyeTracking,
live2dEyeTrackingSource,
live2dModelEyeOffset,
live2dIdleAnimationEnabled,
live2dForceIdleEyeAnimation,
@@ -0,0 +1,138 @@
import type { MotionManagerPluginContext, PixiLive2DInternalModel } from './motion-manager'
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import {
useMotionUpdatePluginAutoEyeBlink,
useMotionUpdatePluginIdleDisable,
} from './motion-manager'
vi.mock('./animation', () => ({
useLive2DIdleEyeFocus: () => ({ update: vi.fn() }),
}))
function createModel(initialValues: Record<string, number> = {}) {
const values = new Map(Object.entries(initialValues))
return {
getParameterValueById: vi.fn((id: string) => values.get(id) ?? 1),
setParameterValueById: vi.fn((id: string, value: number) => {
values.set(id, value)
}),
values,
}
}
function createContext(overrides: Partial<MotionManagerPluginContext> = {}): MotionManagerPluginContext {
const model = createModel({
ParamEyeLOpen: 1,
ParamEyeROpen: 1,
})
const context = {
model,
now: 1000,
timeDelta: 16,
internalModel: {
eyeBlink: {
updateParameters: vi.fn((targetModel: typeof model) => {
targetModel.setParameterValueById('ParamEyeLOpen', 0.5)
targetModel.setParameterValueById('ParamEyeROpen', 0.25)
}),
},
coreModel: model,
} as unknown as PixiLive2DInternalModel,
motionManager: {
stopAllMotions: vi.fn(),
state: { currentGroup: undefined },
groups: { idle: 'Idle' },
} as unknown as PixiLive2DInternalModel['motionManager'],
modelParameters: ref({
leftEyeOpen: 1,
rightEyeOpen: 1,
}),
live2dEyeTrackingEnabled: ref(false),
live2dEyeFocusSourceActive: ref(false),
live2dIdleAnimationEnabled: ref(true),
live2dForceIdleEyeAnimation: ref(false),
live2dAutoBlinkEnabled: ref(true),
live2dForceAutoBlinkEnabled: ref(false),
isIdleMotion: true,
handled: false as boolean,
markHandled: vi.fn(() => {
context.handled = true
}),
}
return Object.assign(context, overrides) as unknown as MotionManagerPluginContext
}
describe('live2d motion manager plugins', () => {
/**
* @example
* expect(idleEyeFocus.update).toHaveBeenCalled()
*/
it('keeps idle eye focus alive when idle motion is disabled', () => {
const idleEyeFocus = { update: vi.fn() }
const context = createContext({
live2dIdleAnimationEnabled: ref(false),
live2dForceIdleEyeAnimation: ref(true),
})
useMotionUpdatePluginIdleDisable(idleEyeFocus)(context)
expect(idleEyeFocus.update).toHaveBeenCalledWith(context.internalModel, context.now)
})
/**
* @example
* expect(idleEyeFocus.update).not.toHaveBeenCalled()
*/
it('lets mouse tracking own focus while a tracking source is active', () => {
const idleEyeFocus = { update: vi.fn() }
const context = createContext({
live2dEyeTrackingEnabled: ref(true),
live2dEyeFocusSourceActive: ref(true),
live2dIdleAnimationEnabled: ref(false),
live2dForceIdleEyeAnimation: ref(true),
})
useMotionUpdatePluginIdleDisable(idleEyeFocus)(context)
expect(idleEyeFocus.update).not.toHaveBeenCalled()
})
/**
* @example
* expect(context.internalModel.eyeBlink?.updateParameters).toHaveBeenCalled()
*/
it('uses the model built-in blink when auto blink is enabled and force blink is disabled', () => {
const context = createContext({
live2dAutoBlinkEnabled: ref(true),
live2dForceAutoBlinkEnabled: ref(false),
})
useMotionUpdatePluginAutoEyeBlink(ref(false))(context)
expect(context.internalModel.eyeBlink?.updateParameters).toHaveBeenCalled()
expect(context.model.setParameterValueById).toHaveBeenCalledWith('ParamEyeLOpen', 0.5)
expect(context.model.setParameterValueById).toHaveBeenCalledWith('ParamEyeROpen', 0.25)
expect(context.handled).toBe(true)
})
/**
* @example
* expect(context.internalModel.eyeBlink?.updateParameters).not.toHaveBeenCalled()
*/
it('does not call the model built-in blink when force blink is enabled', () => {
const context = createContext({
live2dAutoBlinkEnabled: ref(true),
live2dForceAutoBlinkEnabled: ref(true),
timeDelta: 4000,
})
useMotionUpdatePluginAutoEyeBlink(ref(false))(context)
expect(context.internalModel.eyeBlink?.updateParameters).not.toHaveBeenCalled()
expect(context.handled).toBe(true)
})
})
@@ -27,6 +27,8 @@ export type MotionManagerPluginContext = MotionManagerUpdateContext & {
internalModel: PixiLive2DInternalModel
motionManager: PixiLive2DInternalModel['motionManager']
modelParameters: Ref<any>
live2dEyeTrackingEnabled: Ref<boolean>
live2dEyeFocusSourceActive: Ref<boolean>
live2dIdleAnimationEnabled: Ref<boolean>
live2dForceIdleEyeAnimation: Ref<boolean>
live2dAutoBlinkEnabled: Ref<boolean>
@@ -42,6 +44,8 @@ export interface UseLive2DMotionManagerUpdateOptions {
internalModel: PixiLive2DInternalModel
motionManager: PixiLive2DInternalModel['motionManager']
modelParameters: Ref<any>
live2dEyeTrackingEnabled: Ref<boolean>
live2dEyeFocusSourceActive: Ref<boolean>
live2dIdleAnimationEnabled: Ref<boolean>
live2dForceIdleEyeAnimation: Ref<boolean>
live2dAutoBlinkEnabled: Ref<boolean>
@@ -54,6 +58,8 @@ export function useLive2DMotionManagerUpdate(options: UseLive2DMotionManagerUpda
internalModel,
motionManager,
modelParameters,
live2dEyeTrackingEnabled,
live2dEyeFocusSourceActive,
live2dIdleAnimationEnabled,
live2dForceIdleEyeAnimation,
live2dAutoBlinkEnabled,
@@ -97,6 +103,8 @@ export function useLive2DMotionManagerUpdate(options: UseLive2DMotionManagerUpda
internalModel,
motionManager,
modelParameters,
live2dEyeTrackingEnabled,
live2dEyeFocusSourceActive,
live2dIdleAnimationEnabled,
live2dForceIdleEyeAnimation,
live2dAutoBlinkEnabled,
@@ -210,7 +218,7 @@ export function useMotionUpdatePluginIdleDisable(idleEyeFocus = useLive2DIdleEye
if (!ctx.live2dIdleAnimationEnabled.value && ctx.isIdleMotion) {
ctx.motionManager.stopAllMotions()
if (ctx.live2dForceIdleEyeAnimation.value)
if (ctx.live2dForceIdleEyeAnimation.value && (!ctx.live2dEyeTrackingEnabled.value || !ctx.live2dEyeFocusSourceActive.value))
idleEyeFocus.update(ctx.internalModel, ctx.now)
if (ctx.internalModel.eyeBlink != null) {
ctx.internalModel.eyeBlink.updateParameters(ctx.model, ctx.timeDelta / 1000)
@@ -229,6 +237,10 @@ export function useMotionUpdatePluginIdleFocus(idleEyeFocus = useLive2DIdleEyeFo
return (ctx) => {
if (!ctx.isIdleMotion || ctx.handled)
return
if (!ctx.live2dForceIdleEyeAnimation.value)
return
if (ctx.live2dEyeTrackingEnabled.value && ctx.live2dEyeFocusSourceActive.value)
return
idleEyeFocus.update(ctx.internalModel, ctx.now)
}
@@ -16,10 +16,9 @@ import {
} from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
// From stage-ui-three package
import { inject, onMounted, onUnmounted, shallowRef, toRefs, watch } from 'vue'
import { onMounted, onUnmounted, shallowRef, toRefs, watch } from 'vue'
import { useThreeCamera } from '../../stores/camera'
import { useThreeViewControl } from '../../stores/view-control'
/*
* Props:
@@ -58,8 +57,6 @@ const camera = shallowRef<PerspectiveCamera | null>(null)
let disposeControlsChange: (() => void) | undefined
const { cameraPosition, cameraFOV, cameraDistance } = useThreeCamera()
const { viewControlsEnabled } = useThreeViewControl()
const isPreviewStage = inject<boolean>('previewStage')
// Initialisation on onMounted
function registerInfoFlow() {
@@ -118,11 +115,11 @@ function registerInfoFlow() {
camera.value.updateProjectionMatrix()
controls.value.update()
})
watch([controlEnable, viewControlsEnabled], (newEnable) => {
watch(controlEnable, (newEnable) => {
if (!camera.value || !controls.value)
return
controls.value.enableRotate = isPreviewStage || newEnable.every(Boolean)
controls.value.enableZoom = isPreviewStage || newEnable.every(Boolean)
controls.value.enableRotate = newEnable
controls.value.enableZoom = newEnable
}, { immediate: true })
/*
@@ -1,83 +0,0 @@
<script setup lang="ts">
import { RoundRange } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, onUnmounted } from 'vue'
import { useModelStore } from '../../stores/model-store'
import { defaultControlConfig as conf, formatter, useThreeViewControl } from '../../stores/view-control'
const { cameraDistance, cameraFOV, modelOffset, viewControlsEnabled, viewControlMode, set: setValue } = useThreeViewControl()
const { sceneMutationLocked } = storeToRefs(useModelStore())
const controlledValue = computed({
get() {
switch (viewControlMode.value) {
case 'x':
return modelOffset.value.x
case 'y':
return modelOffset.value.y
case 'z':
return modelOffset.value.z
case 'cameraDistance':
return cameraDistance.value
case 'cameraFOV':
return cameraFOV.value
default: throw new Error(`Unexpected control key: ${viewControlMode.value}`)
}
},
set(value) {
if (sceneMutationLocked.value)
return
setValue(viewControlMode.value, value)
},
})
const formattedValue = computed(() => {
return formatter[viewControlMode.value](controlledValue.value)
})
onUnmounted(() => {
viewControlsEnabled.value = false
})
</script>
<template>
<Transition name="fade-side-pops-in">
<fieldset v-if="viewControlsEnabled">
<Transition name="fade-side-pops-in" mode="out-in">
<div :key="viewControlMode" relative class="[&_.round-range-tooltip]:hover:opacity-100">
<RoundRange
v-model="controlledValue" :min="conf[viewControlMode].min" :max="conf[viewControlMode].max"
:disabled="sceneMutationLocked" :step="conf[viewControlMode].step" handle-wheel data-direction="vertical"
h="50%" write-vertical-left
/>
<div
class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0
transition="all duration-200 ease-in-out"
>
{{ formattedValue }}
</div>
</div>
</Transition>
</fieldset>
</Transition>
</template>
<style scoped>
.fade-side-pops-in-enter-active,
.fade-side-pops-in-leave-active {
transition: all 0.2s ease-in-out;
}
.fade-side-pops-in-enter-from,
.fade-side-pops-in-leave-to {
opacity: 0;
transform: translateX(-100%) scale(0.8);
}
.fade-side-pops-in-enter-to,
.fade-side-pops-in-leave-from {
opacity: 1;
transform: translateX(0) scale(1);
}
</style>
@@ -1,2 +1 @@
export { default as OrbitControls } from './OrbitControls.vue'
export { default as SliderControls } from './SliderControls.vue'
@@ -25,7 +25,7 @@ import type {
VrmLoadHookContext,
VrmMaterialHookContext,
} from '../../composables/vrm/hooks'
import type { SceneBootstrap, Vec3 } from '../../stores/model-store'
import type { SceneBootstrap, TrackingMode, Vec3 } from '../../stores/model-store'
import type { VrmLifecycleReason } from '../../trace'
import type { ManagedVrmInstance } from './vrm-instance-cache'
@@ -57,7 +57,7 @@ import {
} from 'vue'
import { useEyeTracking } from '../../composables/eye-tracking'
import { useVRMEyeFocusFor } from '../../composables/eye-tracking'
import {
createIblProbeController,
injectDiffuseIBL,
@@ -107,6 +107,7 @@ import {
*/
const props = withDefaults(defineProps<{
currentAudioSource?: AudioBufferSourceNode
cursorPosition?: { x: number, y: number }
lastCommittedModelSrc?: string
modelSrc?: string
idleAnimation: string
@@ -119,7 +120,7 @@ const props = withDefaults(defineProps<{
modelOffset: Vec3
modelRotationY: number
trackingMode: string
trackingMode: TrackingMode
eyeHeight: number
screenBoundingBox: () => { top: number, left: number, width: number, height: number }
cameraPosition: Vec3
@@ -895,11 +896,17 @@ async function loadModel() {
}
}
const focusPos = useEyeTracking(() => ({
camera: camera.value,
raycaster,
defaultLookAt: defaultTookAt.value,
}), props.screenBoundingBox)
const focusPos = useVRMEyeFocusFor({
cameraPosition: () => props.cameraPosition,
context: () => ({
camera: camera.value,
raycaster,
defaultLookAt: defaultTookAt.value,
}),
screenBoundingBox: props.screenBoundingBox,
source: () => props.cursorPosition,
trackingMode: () => props.trackingMode,
})
onMounted(async () => {
// watch if the model needs to be reloaded
@@ -43,18 +43,35 @@ import {
stageThreeTraceThreeSceneSubtreeEvent,
stageThreeTraceThreeSceneTransactionEvent,
} from '../trace'
import { OrbitControls, SliderControls } from './Controls'
import { OrbitControls } from './Controls'
import { SkyBox } from './Environment'
import { VRMModel } from './Model'
const props = withDefaults(defineProps<{
currentAudioSource?: AudioBufferSourceNode
cursorPosition?: { x: number, y: number }
modelSrc?: string
skyBoxSrc?: string
/**
* Enables user-driven OrbitControls camera rotation and zoom.
*
* This is controlled by the app shell because orbit gestures conflict with
* mobile view-control overlays, while desktop/tamagotchi stages still expect
* direct scene manipulation.
*
* | Surface | Expected value | Reason |
* | --- | --- | --- |
* | stage-web desktop | `true` | Mouse orbit is the primary camera control. |
* | stage-web mobile | `false` | Touch input is reserved for mobile view controls and chat UI. |
* | stage-pocket mobile | `false` | Touch input is reserved for mobile view controls and chat UI. |
* | stage-tamagotchi | `true` | The transparent desktop stage has no mobile view-control overlay. |
*/
enableOrbitControls?: boolean
showAxes?: boolean
idleAnimation?: string
paused?: boolean
}>(), {
enableOrbitControls: true,
showAxes: false,
idleAnimation: new URL('../assets/vrm/animations/idle_loop.vrma', import.meta.url).href,
paused: false,
@@ -434,7 +451,8 @@ function onOrbitControlsReady() {
}
const controlEnable = computed(() => {
return controlsReady.value
return props.enableOrbitControls
&& controlsReady.value
&& modelPhase.value === 'ready'
&& !sceneMutationLocked.value
})
@@ -448,11 +466,6 @@ function onVRMSceneBootstrap(value: SceneBootstrap) {
pendingSceneBootstrap.value = value
}
function onVRMModelLookAtTarget(value: Vector3) {
lookAtTarget.value.x = value.x
lookAtTarget.value.y = value.y
lookAtTarget.value.z = value.z
}
function onVRMModelLoaded(value: string) {
activeModelSrc.value = value
pendingCommittedModelSrc.value = value
@@ -710,9 +723,6 @@ defineExpose({
<template>
<Screen ref="screenRef" v-slot="{ width, height }" relative>
<div top="50%" translate-y="[-50%]" fixed z-15 px-3>
<SliderControls />
</div>
<TresCanvas
:width="width"
:height="height"
@@ -768,6 +778,7 @@ defineExpose({
<VRMModel
ref="modelRef"
:current-audio-source="props.currentAudioSource"
:cursor-position="props.cursorPosition"
:last-committed-model-src="lastCommittedModelSrc"
:model-src="props.modelSrc"
:idle-animation="props.idleAnimation"
@@ -777,7 +788,6 @@ defineExpose({
:npr-irr-s-h="irrSHTex"
:model-offset="modelOffset"
:model-rotation-y="modelRotationY"
:look-at-target="lookAtTarget"
:tracking-mode="trackingMode"
:eye-height="eyeHeight"
:camera-position="cameraPosition"
@@ -786,7 +796,6 @@ defineExpose({
@loading-progress="(val: number) => emit('loadModelProgress', val)"
@load-start="onVRMModelLoadStart"
@scene-bootstrap="onVRMSceneBootstrap"
@look-at-target="onVRMModelLookAtTarget"
@error="onVRMModelError"
@loaded="onVRMModelLoaded"
/>
@@ -0,0 +1,93 @@
import { createPinia, setActivePinia } from 'pinia'
import { Raycaster, Vector3 } from 'three'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@vueuse/core', async () => {
const vue = await import('vue')
return {
useBroadcastChannel: vi.fn(() => ({
post: vi.fn(),
data: vue.ref(null),
})),
useLocalStorage: vi.fn((_key, initialValue) => vue.ref(initialValue)),
}
})
vi.mock('../stores/camera', async () => {
const vue = await import('vue')
return {
DEFAULT_CAMERA_DISTANCE: 1,
DEFAULT_CAMERA_FOV: 40,
DEFAULT_CAMERA_POSITION: { x: 0, y: 0, z: -1 },
useThreeCamera: vi.fn(() => ({
cameraDistance: vue.ref(1),
cameraFOV: vue.ref(40),
cameraPosition: vue.ref({ x: 0, y: 0, z: -1 }),
})),
}
})
describe('useVRMEyeFocusFor', () => {
beforeEach(() => {
vi.resetModules()
vi.stubGlobal('window', { devicePixelRatio: 1 })
setActivePinia(createPinia())
})
/**
* @example
* expect(focus.value).toBe(defaultLookAt)
*/
it('returns the default target when mouse mode has no tracking source', async () => {
const { useVRMEyeFocusFor } = await import('./eye-tracking')
const defaultLookAt = new Vector3(1, 2, 3)
const focus = useVRMEyeFocusFor({
cameraPosition: () => ({ x: 0, y: 0, z: -1 }),
context: () => ({
raycaster: new Raycaster(),
camera: {
near: 0.1,
} as any,
defaultLookAt,
}),
screenBoundingBox: () => ({ top: 0, left: 0, width: 100, height: 100 }),
source: () => null,
trackingMode: () => 'mouse',
})
expect(focus.value).toBe(defaultLookAt)
})
/**
* @example
* expect(raycaster.setFromCamera).toHaveBeenCalled()
*/
it('uses the plain tracking source when mouse tracking is active', async () => {
const { useVRMEyeFocusFor } = await import('./eye-tracking')
const raycaster = {
ray: {
direction: new Vector3(0, 0, -1),
origin: new Vector3(0, 0, 0),
},
setFromCamera: vi.fn(),
}
const focus = useVRMEyeFocusFor({
cameraPosition: () => ({ x: 0, y: 0, z: -1 }),
context: () => ({
raycaster: raycaster as unknown as Raycaster,
camera: {
near: 0.1,
} as any,
defaultLookAt: new Vector3(1, 2, 3),
}),
screenBoundingBox: () => ({ top: 0, left: 0, width: 100, height: 100 }),
source: () => ({ x: 50, y: 50 }),
trackingMode: () => 'mouse',
})
expect(focus.value).toEqual(new Vector3(0, 0, -0.8))
expect(raycaster.setFromCamera).toHaveBeenCalled()
})
})
@@ -1,40 +1,61 @@
import type { PerspectiveCamera, Raycaster } from 'three'
import type { MaybeRefOrGetter } from 'vue'
import { storeToRefs } from 'pinia'
import type { TrackingMode, Vec3 } from '../stores/model-store'
import { Vector2, Vector3 } from 'three'
import { computed, toValue } from 'vue'
import { useModelStore } from '../stores/model-store'
interface ThreeWorldContext {
interface VRMWorldContext {
raycaster: Raycaster
camera: PerspectiveCamera
defaultLookAt: Vector3
}
const { trackingSource, trackingMode, cameraPosition } = storeToRefs(useModelStore())
export interface VRMEyeFocusSource {
x: number
y: number
}
// look at mouse
export function useEyeTracking(
context: MaybeRefOrGetter<ThreeWorldContext>,
screenBoundingBox: MaybeRefOrGetter<{ top: number, left: number, height: number, width: number }>,
) {
/**
* Maps cursor and camera tracking modes into a VRM world-space eye focus target.
*
* Use when:
* - A VRM model needs a look-at target derived from scene-local cursor coordinates.
* - The scene owns the screen bounds used to normalize pointer coordinates.
*
* Expects:
* - Source coordinates are relative to the browser viewport or Electron window that contains the scene.
* - Screen bounds are in the same client coordinate space as the source.
*
* Returns:
* - A computed Three.js vector suitable for VRM eye focus updates.
*/
export function useVRMEyeFocusFor(options: {
cameraPosition: MaybeRefOrGetter<Vec3>
context: MaybeRefOrGetter<VRMWorldContext>
screenBoundingBox: MaybeRefOrGetter<{ top: number, left: number, height: number, width: number }>
source: MaybeRefOrGetter<VRMEyeFocusSource | null | undefined>
trackingMode: MaybeRefOrGetter<TrackingMode>
}) {
const focusPos = computed<Vector3>(() => {
if (trackingMode.value === 'camera')
return new Vector3(cameraPosition.value.x, cameraPosition.value.y, cameraPosition.value.z)
const ctx = toValue(context)
if (trackingMode.value === 'none' || !trackingSource.value)
const trackingMode = toValue(options.trackingMode)
if (trackingMode === 'camera') {
const cameraPosition = toValue(options.cameraPosition)
return new Vector3(cameraPosition.x, cameraPosition.y, cameraPosition.z)
}
const ctx = toValue(options.context)
const trackingSource = toValue(options.source)
if (trackingMode === 'none' || !trackingSource)
return ctx.defaultLookAt
const screen = toValue(screenBoundingBox)
if (trackingMode.value === 'mouse') {
const trackingPos = trackingSource.value as unknown as { x: number, y: number }
// from tracking origin to camera center(screen center)
const screen = toValue(options.screenBoundingBox)
if (trackingMode === 'mouse') {
const castedPos = castScreenToCam(
ctx,
new Vector2(
((trackingPos.x - screen.left) / screen.width) * 2 - 1,
-((trackingPos.y - screen.top) / screen.height) * 2 + 1,
((trackingSource.x - screen.left) / screen.width) * 2 - 1,
-((trackingSource.y - screen.top) / screen.height) * 2 + 1,
),
)
return castedPos
@@ -45,7 +66,7 @@ export function useEyeTracking(
return focusPos
}
function castScreenToCam(ctx: ThreeWorldContext, point: Vector2): Vector3 {
function castScreenToCam(ctx: VRMWorldContext, point: Vector2): Vector3 {
ctx.raycaster.setFromCamera(point, ctx.camera)
const nearPlaneDistance = ctx.camera.near
const direction = ctx.raycaster.ray.direction.clone().normalize().multiplyScalar(8)
@@ -1,5 +1,4 @@
import type { Vector3 } from 'three'
import type { ComputedRef, Ref } from 'vue'
import { useBroadcastChannel, useLocalStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
@@ -85,15 +84,7 @@ const { modelOffset, set: setViewControl } = useThreeViewControl()
const { cameraDistance, cameraFOV, cameraPosition } = useThreeCamera()
const modelRotationY = useLocalStorage('settings/stage-ui-three/modelRotationY', 0)
const trackingMode = useLocalStorage('settings/stage-ui-three/trackingMode', 'none' as 'camera' | 'mouse' | 'none')
/**
* A position to perform eye-tracking on.
* Should be a position relative to the application window:
* - for browser targets, it should be the top-left corner of the viewport
* - for tamagotchi targets, it should be the top-left corner of
* the application window that renders the model
*/
const trackingSource: Ref<ComputedRef<{ x: number, y: number }> | null> = ref(null)
const trackingMode = useLocalStorage<TrackingMode>('settings/stage-ui-three/trackingMode', 'none')
export const useModelStore = defineStore('modelStore', () => {
const { post, data } = useBroadcastChannel<BroadcastChannelEvents, BroadcastChannelEvents>({ name: 'airi-stores-stage-ui-three-vrm' })
@@ -245,7 +236,6 @@ export const useModelStore = defineStore('modelStore', () => {
lookAtTarget,
trackingMode,
trackingSource,
eyeHeight,
renderScale,
multisampling,
@@ -87,6 +87,27 @@ const fpsOptions = computed(() => [
{ value: 60, label: '60' },
{ value: 0, label: t('settings.live2d.parameters.fps.options.unlimited') },
])
const blinkModeOptions = computed(() => [
{
value: 'auto',
label: t('settings.live2d.animation.auto-blink.title'),
description: t('settings.live2d.animation.auto-blink.description'),
},
{
value: 'force',
label: t('settings.live2d.animation.force-auto-blink.title'),
description: t('settings.live2d.animation.force-auto-blink.description'),
},
])
const live2dBlinkMode = computed<'auto' | 'force'>({
get() {
return live2dForceAutoBlinkEnabled.value ? 'force' : 'auto'
},
set(mode) {
live2dAutoBlinkEnabled.value = true
live2dForceAutoBlinkEnabled.value = mode === 'force'
},
})
watch(() => live2d.availableMotions, (motions) => {
runtimeMotions.value = motions.map(m => ({
@@ -346,16 +367,21 @@ function handleMotionSelect(selectedMotionPath: string | number | undefined) {
/>
<FieldCheckbox
v-model="live2dAutoBlinkEnabled"
:label="t('settings.live2d.animation.auto-blink.title')"
:description="t('settings.live2d.animation.auto-blink.description')"
placement="right"
/>
<FieldCheckbox
v-model="live2dForceAutoBlinkEnabled"
:label="t('settings.live2d.animation.force-auto-blink.title')"
:description="t('settings.live2d.animation.force-auto-blink.description')"
:label="t('settings.live2d.animation.blink-enable.title')"
:description="t('settings.live2d.animation.blink-enable.description')"
placement="right"
/>
<label v-if="live2dAutoBlinkEnabled" class="flex flex-wrap gap-4">
<div class="flex-1">
<div class="flex items-center gap-1 text-sm font-medium">
{{ t('settings.live2d.animation.blink-mode.title') }}
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
{{ t('settings.live2d.animation.blink-mode.description') }}
</div>
</div>
<SelectTab v-model="live2dBlinkMode" :options="blinkModeOptions" size="sm" :class="['shrink-0']" />
</label>
<FieldCombobox
v-model="selectedRuntimeMotion"
:label="t('settings.live2d.animation.idle-motion.title')"
@@ -1,12 +1,12 @@
<script setup lang="ts">
import type { ModelSettingsRuntimeSnapshot } from './runtime'
import { Live2DScene, useSettingsLive2d } from '@proj-airi/stage-ui-live2d'
import { Live2DScene } from '@proj-airi/stage-ui-live2d'
import { SpineScene } from '@proj-airi/stage-ui-spine'
import { ThreeScene, useModelStore } from '@proj-airi/stage-ui-three'
import { useMouse } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, provide, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useSettings } from '../../../../stores/settings'
import {
@@ -33,8 +33,6 @@ const live2dComponentState = ref<'pending' | 'loading' | 'mounted'>('pending')
const spineComponentState = ref<'pending' | 'loading' | 'mounted'>('pending')
const vrmPreviewStageInstanceId = `model-settings-preview-stage:${Math.random().toString(36).slice(2, 10)}`
provide('previewStage', true)
const {
stageModelSelected,
stageModelSelectedUrl,
@@ -152,14 +150,8 @@ defineExpose({
capturePreviewFrame,
})
const { live2dEyeTrackingSource } = storeToRefs(useSettingsLive2d())
const { x: mouseX, y: mouseY } = useMouse()
live2dEyeTrackingSource.value = computed(() => ({
x: mouseX.value,
y: mouseY.value,
}))
const { trackingSource } = storeToRefs(useModelStore())
trackingSource.value = computed(() => ({
const cursorPosition = computed(() => ({
x: mouseX.value,
y: mouseY.value,
}))
@@ -173,6 +165,7 @@ trackingSource.value = computed(() => ({
v-model:state="live2dComponentState"
:model-src="stageModelSelectedUrl"
:model-id="stageModelSelected"
:cursor-position="cursorPosition"
:theme-colors-hue="themeColorsHue"
:theme-colors-hue-dynamic="themeColorsHueDynamic"
/>
@@ -180,7 +173,7 @@ trackingSource.value = computed(() => ({
</template>
<template v-if="stageModelRenderer === 'vrm'">
<div :class="vrmSceneClassList">
<ThreeScene ref="vrmSceneRef" :model-src="stageModelSelectedUrl" />
<ThreeScene ref="vrmSceneRef" :cursor-position="cursorPosition" :model-src="stageModelSelectedUrl" />
</div>
</template>
<template v-if="stageModelRenderer === 'spine'">
@@ -45,8 +45,13 @@ import { useSettings } from '../../stores/settings'
import { useSpeechRuntimeStore } from '../../stores/speech-runtime'
const props = withDefaults(defineProps<{
cursorPosition?: { x: number, y: number }
enableOrbitControls?: boolean
paused?: boolean
}>(), { paused: false, scale: 1 })
}>(), {
enableOrbitControls: true,
paused: false,
})
const componentState = defineModel<'pending' | 'loading' | 'mounted'>('state', { default: 'pending' })
@@ -867,6 +872,7 @@ defineExpose({
h-full w-full flex-1
:model-src="stageModelSelectedUrl"
:model-id="stageModelSelected"
:cursor-position="cursorPosition"
:mouth-open-size="mouthOpenSize"
:now-speaking="nowSpeaking"
:paused="paused"
@@ -882,9 +888,11 @@ defineExpose({
v-model:state="componentState"
min-w="50% <lg:full" min-h="100 sm:100" h-full w-full flex-1
:model-src="stageModelSelectedUrl"
:cursor-position="cursorPosition"
:idle-animation="animations.idleLoop.toString()"
:paused="paused"
:show-axes="stageViewControlsEnabled"
:enable-orbit-controls="props.enableOrbitControls"
:current-audio-source="currentAudioSource"
@error="console.error"
/>
@@ -0,0 +1,162 @@
<script setup lang="ts">
import {
defaultControlConfig as threeControlConfig,
formatter as threeFormatter,
useModelStore,
useThreeViewControl,
} from '@proj-airi/stage-ui-three'
import { RoundRange } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, onUnmounted } from 'vue'
import {
defaultControlConfig as live2dControlConfig,
formatter as live2dFormatter,
useL2dViewControl,
} from '../../stores/live2d'
import { useSettingsStageModel } from '../../stores/settings/stage-model'
const { stageModelRenderer } = storeToRefs(useSettingsStageModel())
const live2d = useL2dViewControl()
const three = useThreeViewControl()
const { sceneMutationLocked } = storeToRefs(useModelStore())
const activeRenderer = computed<'live2d' | 'vrm' | null>(() => {
if (stageModelRenderer.value === 'live2d')
return 'live2d'
if (stageModelRenderer.value === 'vrm')
return 'vrm'
return null
})
const controlEnabled = computed(() => {
if (activeRenderer.value === 'live2d')
return live2d.viewControlsEnabled.value
if (activeRenderer.value === 'vrm')
return three.viewControlsEnabled.value
return false
})
const activeControlKey = computed(() => {
if (activeRenderer.value === 'live2d')
return live2d.viewControlMode.value
if (activeRenderer.value === 'vrm')
return three.viewControlMode.value
return null
})
const activeControlConfig = computed(() => {
if (activeRenderer.value === 'live2d')
return live2dControlConfig[live2d.viewControlMode.value]
if (activeRenderer.value === 'vrm')
return threeControlConfig[three.viewControlMode.value]
return null
})
const controlledValue = computed({
get() {
if (activeRenderer.value === 'live2d') {
switch (live2d.viewControlMode.value) {
case 'x':
return live2d.position.value.x
case 'y':
return live2d.position.value.y
case 'scale':
return live2d.scale.value
}
}
if (activeRenderer.value === 'vrm') {
switch (three.viewControlMode.value) {
case 'x':
return three.modelOffset.value.x
case 'y':
return three.modelOffset.value.y
case 'z':
return three.modelOffset.value.z
case 'cameraDistance':
return three.cameraDistance.value
case 'cameraFOV':
return three.cameraFOV.value
}
}
return 0
},
set(value) {
if (activeRenderer.value === 'live2d') {
live2d.set(live2d.viewControlMode.value, value)
return
}
if (activeRenderer.value === 'vrm') {
if (sceneMutationLocked.value)
return
three.set(three.viewControlMode.value, value)
}
},
})
const formattedValue = computed(() => {
if (activeRenderer.value === 'live2d')
return live2dFormatter[live2d.viewControlMode.value](controlledValue.value)
if (activeRenderer.value === 'vrm')
return threeFormatter[three.viewControlMode.value](controlledValue.value)
return ''
})
onUnmounted(() => {
live2d.viewControlsEnabled.value = false
three.viewControlsEnabled.value = false
})
</script>
<template>
<Transition name="fade-side-pops-in">
<fieldset v-if="controlEnabled && activeControlConfig">
<Transition name="fade-side-pops-in" mode="out-in">
<div :key="activeControlKey ?? 'none'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
<RoundRange
v-model="controlledValue"
:min="activeControlConfig.min"
:max="activeControlConfig.max"
:disabled="activeRenderer === 'vrm' && sceneMutationLocked"
:step="activeControlConfig.step"
handle-wheel
data-direction="vertical"
h="50%"
write-vertical-left
/>
<div
class="round-range-tooltip"
absolute left-10 top="50%"
translate-y="[-50%]"
font-mono op-0
transition="all duration-200 ease-in-out"
>
{{ formattedValue }}
</div>
</div>
</Transition>
</fieldset>
</Transition>
</template>
<style scoped>
.fade-side-pops-in-enter-active,
.fade-side-pops-in-leave-active {
transition: all 0.2s ease-in-out;
}
.fade-side-pops-in-enter-from,
.fade-side-pops-in-leave-to {
opacity: 0;
transform: translateX(-100%) scale(0.8);
}
.fade-side-pops-in-enter-to,
.fade-side-pops-in-leave-from {
opacity: 1;
transform: translateX(0) scale(1);
}
</style>
@@ -1,4 +1,5 @@
export { default as WidgetStage } from './Stage.vue'
export { default as ViewControlSlider } from './ViewControlSlider.vue'
export {
Live2DCanvas,
Live2DModel,