From 55ef3d416e88e1f48d16d597711da0326d4780c9 Mon Sep 17 00:00:00 2001 From: leafyy Date: Tue, 1 Sep 2026 10:31:12 +0800 Subject: [PATCH] fix(stage-ui-three): preserve imported VRM view settings (#2423) --- .../pages/devtools/performance-playground.vue | 1 + .../pages/devtools/model-driver-mediapipe.vue | 1 + .../pages/devtools/performance-playground.vue | 1 + .../vrm-model-position.browser.test.ts | 145 ++++++++++++++++++ package.json | 3 +- .../src/components/Model/VRMModel.vue | 80 ++++++---- .../src/components/ThreeScene.vue | 49 ++++-- .../stage-ui-three/src/stores/model-store.ts | 28 +++- .../settings/model-settings/preview-stage.vue | 2 +- .../stage-ui/src/components/scenes/Stage.vue | 1 + .../src/stores/settings/stage-model.test.ts | 55 ++++++- .../src/stores/settings/stage-model.ts | 41 +++-- 12 files changed, 343 insertions(+), 64 deletions(-) create mode 100644 apps/stage-web/src/pages/devtools/vrm-model-position.browser.test.ts diff --git a/apps/stage-pocket/src/pages/devtools/performance-playground.vue b/apps/stage-pocket/src/pages/devtools/performance-playground.vue index 685d2dd1e..4c1c316c7 100644 --- a/apps/stage-pocket/src/pages/devtools/performance-playground.vue +++ b/apps/stage-pocket/src/pages/devtools/performance-playground.vue @@ -278,6 +278,7 @@ onUnmounted(() => { { { { + const pinia = createPinia() + const displayModels = useDisplayModelsStore(pinia) + const preset = await displayModels.getDisplayModel('preset-vrm-1') + if (preset?.type !== 'url') + throw new Error('The VRM test preset is unavailable.') + + vrmModelUrl = preset.url + const response = await fetch(preset.url) + if (!response.ok) + throw new Error(`Failed to load the VRM test preset: ${response.status}`) + + vrmModel = await response.blob() +}) + +describe('imported VRM view settings', () => { + // https://github.com/moeru-ai/airi/issues/1806 + it('resets the legacy view once and preserves later reloads for Issue #1806', async () => { + // ROOT CAUSE: + // + // An imported VRM receives a new Blob URL when AIRI reloads the selected model. + // The scene treats that URL change as a model switch and replaces the saved view settings. + // + // The selected display model ID is stable across reloads. The scene must use that ID + // for model identity and keep the Blob URL only for resource loading. + // A new model ID can arrive before its URL. The old load must not commit the new ID. + // Existing installations only have the old runtime URL identity key. AIRI cannot + // safely map that URL back to a persisted file after restart. The first new-version + // load must reset once and establish a stable model ID for later reloads. + // A reload also creates a new VRM group. The saved offset must be applied to that + // group even when the store value does not change and its watcher does not run. + // The old group must leave the scene before AIRI commits the replacement group. + const pinia = createPinia() + const modelStore = useModelStore(pinia) + modelStore.resetModelStore() + localStorage.setItem('settings/stage-ui-three/lastModelSrc', vrmModelUrl) + modelStore.modelOffset = { x: 0.25, y: 0.25, z: 0 } + const modelId = shallowRef('display-model-issue-1806') + const modelSrc = shallowRef(vrmModelUrl) + const sceneComponent = shallowRef>() + modelStore.resetLegacyModelIdentity() + const container = document.createElement('div') + container.style.height = '600px' + container.style.width = '800px' + document.body.appendChild(container) + + const TestHarness = defineComponent(() => () => h(ThreeScene, { + ref: sceneComponent, + modelId: modelId.value, + modelSrc: modelSrc.value, + style: { height: '600px', width: '800px' }, + })) + + function renderedModelOffset() { + const position = sceneComponent.value?.scene()?.parent?.position + if (!position) + return undefined + + return { x: position.x, y: position.y, z: position.z } + } + + const app = createApp(TestHarness) + app.use(pinia) + app.mount(container) + + await expect.poll(() => { + const bounds = container.firstElementChild?.getBoundingClientRect() + return { height: bounds?.height, width: bounds?.width } + }).toEqual({ height: 600, width: 800 }) + + // NOTICE: + // Keep the app mounted until Vitest closes the browser page. + // Vue DevTools schedules inspector work after app.unmount(), which rejects after teardown. + // Source/context: the Stage Web Vite configuration used by this browser test. + // Removal condition: Vue DevTools supports component-test app teardown. + + await expect.poll(() => modelStore.scenePhase, { timeout: 20_000 }).toBe('mounted') + expect(modelStore.lastCommittedModelId).toBe('display-model-issue-1806') + expect(localStorage.getItem('settings/stage-ui-three/lastModelId')).toBe('display-model-issue-1806') + expect(modelStore.modelOffset).toEqual({ x: 0, y: 0, z: 0 }) + expect(localStorage.getItem('settings/stage-ui-three/lastModelSrc')).toBeNull() + + modelStore.modelOffset = { x: 0.35, y: 0.35, z: 0 } + modelStore.cameraDistance = 1.75 + await expect.poll(renderedModelOffset).toEqual({ x: 0.35, y: 0.35, z: 0 }) + const previousModelGroup = sceneComponent.value?.scene()?.parent + expect(previousModelGroup).toBeTruthy() + const previousModelSrc = modelSrc.value + modelSrc.value = URL.createObjectURL(vrmModel) + await nextTick() + + await expect.poll(() => modelStore.scenePhase, { timeout: 20_000 }).toBe('loading') + await expect.poll(() => modelStore.scenePhase, { timeout: 20_000 }).toBe('mounted') + + expect(modelSrc.value).not.toBe(previousModelSrc) + expect(modelStore.modelOffset).toEqual({ x: 0.35, y: 0.35, z: 0 }) + expect(renderedModelOffset()).toEqual({ x: 0.35, y: 0.35, z: 0 }) + expect(modelStore.cameraDistance).toBe(1.75) + expect(sceneComponent.value?.scene()?.parent === previousModelGroup).toBe(false) + expect(previousModelGroup?.parent === null).toBe(true) + + modelId.value = 'display-model-other' + modelSrc.value = URL.createObjectURL(vrmModel) + await nextTick() + + await expect.poll(() => modelStore.scenePhase, { timeout: 20_000 }).toBe('loading') + await expect.poll(() => modelStore.scenePhase, { timeout: 20_000 }).toBe('mounted') + + expect(modelStore.lastCommittedModelId).toBe('display-model-other') + expect(modelStore.modelOffset).toEqual({ x: 0, y: 0, z: 0 }) + + modelStore.modelOffset = { x: 0.45, y: 0.45, z: 0 } + const outgoingModelId = modelId.value + modelSrc.value = URL.createObjectURL(vrmModel) + await nextTick() + + await expect.poll(() => modelStore.scenePhase, { timeout: 20_000 }).toBe('loading') + modelId.value = 'display-model-incoming' + await nextTick() + + await expect.poll(() => modelStore.scenePhase, { timeout: 20_000 }).toBe('mounted') + expect(modelStore.lastCommittedModelId).toBe(outgoingModelId) + expect(modelStore.modelOffset).toEqual({ x: 0.45, y: 0.45, z: 0 }) + + modelSrc.value = URL.createObjectURL(vrmModel) + await nextTick() + + await expect.poll(() => modelStore.scenePhase, { timeout: 20_000 }).toBe('loading') + await expect.poll(() => modelStore.scenePhase, { timeout: 20_000 }).toBe('mounted') + + expect(modelStore.lastCommittedModelId).toBe('display-model-incoming') + expect(modelStore.modelOffset).toEqual({ x: 0, y: 0, z: 0 }) + }, 45_000) +}) diff --git a/package.json b/package.json index fac4a2e06..2f275c235 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "build:packages": "turbo run build -F=\"./packages/*\"", "build:engines": "turbo run build -F=\"./engines/*\"", "test": "vitest --coverage", - "test:run": "vitest run && pnpm run test-stage-tamagotchi:run && pnpm run test-ui:run", + "test:run": "vitest run && pnpm run test-stage-tamagotchi:run && pnpm run test-stage-web:run && pnpm run test-ui:run", + "test-stage-web:run": "pnpm -F @proj-airi/stage-web exec vitest run --project browser src/pages/devtools/vrm-model-position.browser.test.ts", "test-stage-tamagotchi:run": "vitest run --config apps/stage-tamagotchi/vitest.config.ts --project browser", "test-ui:run": "vitest run --config packages/stage-ui/vitest.config.ts", "lint": "moeru-lint .", diff --git a/packages/stage-ui-three/src/components/Model/VRMModel.vue b/packages/stage-ui-three/src/components/Model/VRMModel.vue index 458d0c98a..1886914c4 100644 --- a/packages/stage-ui-three/src/components/Model/VRMModel.vue +++ b/packages/stage-ui-three/src/components/Model/VRMModel.vue @@ -98,6 +98,7 @@ import { /* * Props: + * - modelId: stable display model identity * - modelSrc: model src string to load model asset * - idleAnimation: animation src for model * - loadAnimations: TBC @@ -112,7 +113,10 @@ const props = withDefaults(defineProps<{ audioContext?: AudioContext currentAudioSource?: AudioBufferSourceNode cursorPosition?: { x: number, y: number } - lastCommittedModelSrc?: string + /** The stable identity of the last model that completed scene binding. */ + lastCommittedModelId?: string + /** Stable display model identity. Runtime resource URLs can change across reloads. */ + modelId: string modelSrc?: string idleAnimation: string // loadAnimations?: string[] @@ -152,7 +156,8 @@ const emit = defineEmits<{ const { audioContext, currentAudioSource, - lastCommittedModelSrc, + lastCommittedModelId, + modelId, modelSrc, idleAnimation, // loadAnimations, // TBC @@ -221,6 +226,13 @@ function getRendererInstance() { return renderer?.instance as WebGLRenderer | undefined } +function updateIblProbe(mode = normalizeEnvMode(envSelect.value)) { + if (!airiIblProbe && scene.value) + airiIblProbe = createIblProbeController(scene.value) + + airiIblProbe?.update(mode, skyBoxIntensity.value, nprIrrSH.value ?? null) +} + function toErrorMessage(error: unknown) { if (error instanceof Error) return error.message @@ -303,7 +315,19 @@ function clearActiveManagedVrmRefs() { interactionColliders.value = undefined } +function applyModelTransform(group: Group) { + group.position.set( + modelOffset.value.x, + modelOffset.value.y, + modelOffset.value.z, + ) + group.rotation.y = MathUtils.degToRad(modelRotationY.value) +} + function applyManagedVrmInstance(instance: ManagedVrmInstance) { + // A reload creates a new group while the saved transform can stay unchanged. + // Apply it during every commit because the value watchers will not run again. + applyModelTransform(instance.group) vrm.value = instance.vrm vrmGroup.value = instance.group vrmAnimationMixer.value = instance.mixer @@ -493,7 +517,16 @@ function bindManagedVrmInstanceRenderLoop() { }).off } -function commitManagedVrmInstance(instance: ManagedVrmInstance) { +function commitManagedVrmInstance( + instance: ManagedVrmInstance, + reason: 'initial-load' | 'model-reload' | 'model-switch', +) { + // Keep the active model visible until its replacement is ready. No asynchronous + // work occurs between this cleanup and the replacement scene commit. + if (reason !== 'initial-load') + componentCleanUp(reason, { invalidate: false }) + + updateIblProbe() scene.value?.add(instance.group) applyManagedVrmInstance(instance) bindManagedVrmInstanceRenderLoop() @@ -640,10 +673,10 @@ function buildSceneBootstrap(activeVrm: VRM, cacheHit: boolean): SceneBootstrap } function resolveVrmLoadReason(): 'initial-load' | 'model-reload' | 'model-switch' { - if (!lastCommittedModelSrc.value) + if (!lastCommittedModelId.value) return 'initial-load' - if (lastCommittedModelSrc.value !== modelSrc.value) + if (lastCommittedModelId.value !== modelId.value) return 'model-switch' return 'model-reload' @@ -699,13 +732,6 @@ async function loadModel() { nextVrmAnimationMixer = reusableInstance.mixer nextVrmEmote = reusableInstance.emote - if (!airiIblProbe && scene.value) - airiIblProbe = createIblProbeController(scene.value) - - if (currentLoadReason === 'model-switch') { - componentCleanUp('model-switch', { invalidate: false }) - } - runVrmLoadHooks({ cacheHit: true, camera: camera.value, @@ -714,7 +740,7 @@ async function loadModel() { vrmGroup: reusableInstance.group, }) emit('sceneBootstrap', buildSceneBootstrap(reusableInstance.vrm, true)) - commitManagedVrmInstance(reusableInstance) + commitManagedVrmInstance(reusableInstance, currentLoadReason) didCommitLoad = true if (isStageThreeRuntimeTraceEnabled()) { @@ -817,10 +843,6 @@ async function loadModel() { injectDiffuseIBL(mat) } - // MToon material sky box lightProbe setting - if (!airiIblProbe && scene.value) - airiIblProbe = createIblProbeController(scene.value) - // Material traverse setting _vrm.scene.traverse((child) => { if (child instanceof Mesh && child.material) { @@ -858,10 +880,6 @@ async function loadModel() { } }) - if (currentLoadReason === 'model-switch') { - componentCleanUp('model-switch', { invalidate: false }) - } - emit('sceneBootstrap', buildSceneBootstrap(_vrm, false)) const nextInteractionColliders = createVrmInteractionColliders(_vrm) @@ -872,7 +890,7 @@ async function loadModel() { interactionColliders: nextInteractionColliders, mixer: nextVrmAnimationMixer, vrm: _vrm, - })) + }), currentLoadReason) didCommitLoad = true if (isStageThreeRuntimeTraceEnabled()) { @@ -951,19 +969,13 @@ onMounted(async () => { }, { immediate: true }) // update model position watch(modelOffset, () => { - if (vrmGroup.value) { - vrmGroup.value.position.set( - modelOffset.value.x, - modelOffset.value.y, - modelOffset.value.z, - ) - } + if (vrmGroup.value) + applyModelTransform(vrmGroup.value) }, { immediate: true, deep: true }) // update model rotation - watch(modelRotationY, (newRotationY) => { - if (vrmGroup.value) { - vrmGroup.value.rotation.y = MathUtils.degToRad(newRotationY) - } + watch(modelRotationY, () => { + if (vrmGroup.value) + applyModelTransform(vrmGroup.value) }, { immediate: true }) // update NPR sky box watch([envSelect, skyBoxIntensity, nprIrrSH], async () => { @@ -1003,7 +1015,7 @@ onMounted(async () => { intensity: skyBoxIntensity.value, sh: nprIrrSH.value ?? null, }) - airiIblProbe?.update(mode, skyBoxIntensity.value, nprIrrSH.value ?? null) + updateIblProbe(mode) }, { immediate: true }) watch(focusPos, (newPos) => { idleEyeSaccades.instantUpdate(vrm.value, newPos) diff --git a/packages/stage-ui-three/src/components/ThreeScene.vue b/packages/stage-ui-three/src/components/ThreeScene.vue index abda4e113..b4ac05408 100644 --- a/packages/stage-ui-three/src/components/ThreeScene.vue +++ b/packages/stage-ui-three/src/components/ThreeScene.vue @@ -56,6 +56,8 @@ const props = withDefaults(defineProps<{ audioContext?: AudioContext currentAudioSource?: AudioBufferSourceNode cursorPosition?: { x: number, y: number } + /** Stable display model identity. Runtime resource URLs can change across reloads. */ + modelId: string modelSrc?: string skyBoxSrc?: string /** @@ -90,6 +92,10 @@ const emit = defineEmits<{ }>() type ModelPhase = 'no-model' | 'loading' | 'ready' | 'error' +interface ModelLoadIdentity { + modelId: string + modelSrc: string +} type SceneTracePhaseCause = | 'binding:complete' | 'binding:start' @@ -118,7 +124,7 @@ const { scenePhase, sceneTransactionDepth, - lastCommittedModelSrc, + lastCommittedModelId, modelSize, modelOrigin, modelOffset, @@ -167,7 +173,11 @@ const latestScenePhaseTraceCause = ref('props:model-src') const latestSceneTransactionReason = ref('unknown') const activeModelSrc = ref() const bindingRevision = ref(0) -const pendingCommittedModelSrc = ref() +// A selection ID can change while its URL is still resolving. The URL change owns +// the request snapshot, so an in-flight load keeps the ID that requested its URL. +const requestedModelIdentity = shallowRef() +const loadingModelIdentity = shallowRef() +const pendingCommittedModelIdentity = shallowRef() const pendingCommittedModelRevision = ref() const pendingSceneBootstrap = shallowRef() @@ -255,12 +265,13 @@ function toVec3(value: Vector3): Vec3 { } function clearPendingCommittedModel() { - pendingCommittedModelSrc.value = undefined + pendingCommittedModelIdentity.value = undefined pendingCommittedModelRevision.value = undefined } function invalidateBindingRevision() { bindingRevision.value += 1 + loadingModelIdentity.value = undefined clearPendingCommittedModel() } @@ -372,23 +383,25 @@ function setScenePhaseWithTrace(phase: ScenePhase, cause: SceneTracePhaseCause) setScenePhase(phase) } -function commitLastCommittedModelSrc(expectedRevision: number, nextPhase: ScenePhase) { +function commitLastCommittedModelId(expectedRevision: number, nextPhase: ScenePhase) { if (nextPhase !== 'mounted') return if (expectedRevision !== bindingRevision.value) return - if (!pendingCommittedModelSrc.value || pendingCommittedModelRevision.value !== expectedRevision) + const completedModel = pendingCommittedModelIdentity.value + if (!completedModel || pendingCommittedModelRevision.value !== expectedRevision) return - if (!activeModelSrc.value || pendingCommittedModelSrc.value !== activeModelSrc.value) + if (!activeModelSrc.value || completedModel.modelSrc !== activeModelSrc.value) return - if (props.modelSrc !== activeModelSrc.value) + const activeRequest = requestedModelIdentity.value + if (activeRequest?.modelId !== completedModel.modelId || activeRequest.modelSrc !== completedModel.modelSrc) return - lastCommittedModelSrc.value = pendingCommittedModelSrc.value + lastCommittedModelId.value = completedModel.modelId clearPendingCommittedModel() } @@ -446,7 +459,7 @@ async function completeSceneBinding(expectedRevision = bindingRevision.value) { const nextPhase = resolveScenePhaseAfterBinding() setScenePhaseWithTrace(nextPhase, 'binding:complete') - commitLastCommittedModelSrc(expectedRevision, nextPhase) + commitLastCommittedModelId(expectedRevision, nextPhase) } finally { isCompletingBinding.value = false @@ -470,6 +483,7 @@ function onVRMModelLoadStart(reason: VrmLifecycleReason) { modelPhase.value = 'loading' pendingSceneBootstrap.value = undefined beginSceneBindingCycle(toSceneLoadTransactionReason(reason)) + loadingModelIdentity.value = requestedModelIdentity.value } function onVRMSceneBootstrap(value: SceneBootstrap) { @@ -478,8 +492,12 @@ function onVRMSceneBootstrap(value: SceneBootstrap) { function onVRMModelLoaded(value: string) { activeModelSrc.value = value - pendingCommittedModelSrc.value = value + const completedModel = loadingModelIdentity.value + pendingCommittedModelIdentity.value = completedModel?.modelSrc === value + ? completedModel + : undefined pendingCommittedModelRevision.value = bindingRevision.value + loadingModelIdentity.value = undefined modelPhase.value = 'ready' void completeSceneBinding(bindingRevision.value) } @@ -620,6 +638,12 @@ function applyVrmFrameRuntimeHook() { modelRef.value?.setVrmFrameHook(vrmFrameRuntimeHook.value) } +watch(() => props.modelSrc, (modelSrc) => { + requestedModelIdentity.value = modelSrc + ? { modelId: props.modelId, modelSrc } + : undefined +}, { flush: 'sync', immediate: true }) + watch(() => props.modelSrc, (modelSrc) => { modelPhase.value = modelSrc ? 'loading' : 'no-model' @@ -848,8 +872,9 @@ defineExpose({ :audio-context="props.audioContext" :current-audio-source="props.currentAudioSource" :cursor-position="props.cursorPosition" - :last-committed-model-src="lastCommittedModelSrc" - :model-src="props.modelSrc" + :last-committed-model-id="lastCommittedModelId" + :model-id="requestedModelIdentity?.modelId ?? props.modelId" + :model-src="requestedModelIdentity?.modelSrc" :idle-animation="props.idleAnimation" :paused="props.paused" :env-select="envSelect" diff --git a/packages/stage-ui-three/src/stores/model-store.ts b/packages/stage-ui-three/src/stores/model-store.ts index bcdc0941f..b3cf83936 100644 --- a/packages/stage-ui-three/src/stores/model-store.ts +++ b/packages/stage-ui-three/src/stores/model-store.ts @@ -138,8 +138,26 @@ export const useModelStore = defineStore('modelStore', () => { sceneTransactionDepth.value = 0 } - // === Legacy / shared controls === - const lastCommittedModelSrc = useLocalStorage('settings/stage-ui-three/lastModelSrc', '') + // === Model identity === + // The display model ID is stable across application restarts. Runtime URLs are not. + const lastCommittedModelId = useLocalStorage('settings/stage-ui-three/lastModelId', '') + /** The storage key from releases that used runtime URLs as model identity. */ + const legacyLastCommittedModelSrcStorageKey = 'settings/stage-ui-three/lastModelSrc' + + /** + * Clears model identity from releases that stored only a runtime URL. + * + * A regenerated Blob URL cannot identify the persisted file that owns the saved view. + * The first VRM load after this reset establishes the stable model ID for later starts. + */ + function resetLegacyModelIdentity() { + const legacyModelSrc = window.localStorage.getItem(legacyLastCommittedModelSrcStorageKey) + if (!legacyModelSrc) + return + + lastCommittedModelId.value = '' + window.localStorage.removeItem(legacyLastCommittedModelSrcStorageKey) + } // === Model lifecycle / bootstrap === // These values are recalculated from the currently bound model instance whenever @@ -156,7 +174,8 @@ export const useModelStore = defineStore('modelStore', () => { scenePhase.value = 'pending' sceneTransactionDepth.value = 0 - lastCommittedModelSrc.value = '' + lastCommittedModelId.value = '' + window.localStorage.removeItem(legacyLastCommittedModelSrcStorageKey) modelSize.value = { x: 0, y: 0, z: 0 } modelOrigin.value = { x: 0, y: 0, z: 0 } modelRotationY.value = 0 @@ -210,7 +229,7 @@ export const useModelStore = defineStore('modelStore', () => { sceneTransactionDepth, sceneMutationLocked, - lastCommittedModelSrc, + lastCommittedModelId, modelSize, modelOrigin, @@ -250,6 +269,7 @@ export const useModelStore = defineStore('modelStore', () => { beginSceneBindingTransaction, endSceneBindingTransaction, resetSceneBindingTransactions, + resetLegacyModelIdentity, resetModelStore, } diff --git a/packages/stage-ui/src/components/scenarios/settings/model-settings/preview-stage.vue b/packages/stage-ui/src/components/scenarios/settings/model-settings/preview-stage.vue index 3eda80ade..bbbb5accd 100644 --- a/packages/stage-ui/src/components/scenarios/settings/model-settings/preview-stage.vue +++ b/packages/stage-ui/src/components/scenarios/settings/model-settings/preview-stage.vue @@ -217,7 +217,7 @@ const cursorPosition = computed(() => ({