diff --git a/packages/stage-ui-three/src/components/Model/VRMModel.vue b/packages/stage-ui-three/src/components/Model/VRMModel.vue index 92cddcb6b..a280257ed 100644 --- a/packages/stage-ui-three/src/components/Model/VRMModel.vue +++ b/packages/stage-ui-three/src/components/Model/VRMModel.vue @@ -110,6 +110,7 @@ import { const props = withDefaults(defineProps<{ currentAudioSource?: AudioBufferSourceNode modelSrc?: string + loadReason: VrmLifecycleReason idleAnimation: string // loadAnimations?: string[] paused?: boolean @@ -149,6 +150,7 @@ const emit = defineEmits<{ const { currentAudioSource, modelSrc, + loadReason, idleAnimation, // loadAnimations, // TBC paused, @@ -660,6 +662,11 @@ function buildSceneBootstrap(activeVrm: VRM, cacheHit: boolean): SceneBootstrap cameraPosition: { x: cameraPosition.x, y: cameraPosition.y, z: cameraPosition.z }, eyeHeight: eyePositionY, lookAtTarget: defaultTookAt(eyePositionY), + modelOffset: { + x: bootstrapRoot.position.x, + y: bootstrapRoot.position.y, + z: bootstrapRoot.position.z, + }, modelOrigin: { x: modelCenter.x, y: modelCenter.y, z: modelCenter.z }, modelSize: { x: modelSize.x, y: modelSize.y, z: modelSize.z }, } @@ -667,7 +674,7 @@ function buildSceneBootstrap(activeVrm: VRM, cacheHit: boolean): SceneBootstrap async function loadModel() { const requestId = invalidatePendingLoads() - const loadReason: VrmLifecycleReason = vrmGroup.value ? 'model-switch' : 'initial-load' + const currentLoadReason = loadReason.value const loadStartedAt = performance.now() let nextVrm: VRM | undefined let nextVrmGroup: Group | undefined @@ -689,7 +696,7 @@ async function loadModel() { if (isStageThreeRuntimeTraceEnabled()) { stageThreeRuntimeTraceContext.emit(stageThreeTraceVrmLoadStartEvent, { modelSrc: modelSrc.value, - reason: loadReason, + reason: currentLoadReason, rendererMemory: createThreeRendererMemorySnapshot(getRendererInstance()), sceneSummary: createVrmSceneSummarySnapshot(), ts: loadStartedAt, @@ -701,11 +708,11 @@ async function loadModel() { const reusableInstance = takeManagedVrmInstance(getManagedVrmScopeKey(), modelSrc.value) if (reusableInstance) { if (!isManagedVrmInstanceReusable(reusableInstance)) { - destroyManagedVrmInstanceWithHooks(reusableInstance, loadReason) + destroyManagedVrmInstanceWithHooks(reusableInstance, currentLoadReason) } else { if (!isLoadRequestCurrent(requestId)) { - destroyManagedVrmInstanceWithHooks(stashManagedVrmInstance(reusableInstance), loadReason) + destroyManagedVrmInstanceWithHooks(stashManagedVrmInstance(reusableInstance), currentLoadReason) return } @@ -717,14 +724,14 @@ async function loadModel() { if (!airiIblProbe && scene.value) airiIblProbe = createIblProbeController(scene.value) - if (loadReason === 'model-switch') { + if (currentLoadReason === 'model-switch') { componentCleanUp('model-switch', { invalidate: false }) } runVrmLoadHooks({ cacheHit: true, camera: camera.value, - reason: loadReason, + reason: currentLoadReason, vrm: reusableInstance.vrm, vrmGroup: reusableInstance.group, }) @@ -736,7 +743,7 @@ async function loadModel() { stageThreeRuntimeTraceContext.emit(stageThreeTraceVrmLoadEndEvent, { durationMs: performance.now() - loadStartedAt, modelSrc: modelSrc.value, - reason: loadReason, + reason: currentLoadReason, rendererMemory: createThreeRendererMemorySnapshot(getRendererInstance()), sceneSummary: createVrmSceneSummarySnapshot({ mixer: reusableInstance.mixer, vrm: reusableInstance.vrm }), ts: performance.now(), @@ -755,7 +762,7 @@ async function loadModel() { }) if (!_vrmInfo || !_vrmInfo._vrm || !_vrmInfo._vrmGroup) { if (isLoadRequestCurrent(requestId)) { - emitVrmLoadError(loadReason, loadStartedAt, 'VRM model loading failure') + emitVrmLoadError(currentLoadReason, loadStartedAt, 'VRM model loading failure') console.warn('VRM model loading failure!') emit('error', new Error('VRM model loading failure')) } @@ -776,7 +783,7 @@ async function loadModel() { runVrmLoadHooks({ cacheHit: false, camera: camera.value, - reason: loadReason, + reason: currentLoadReason, vrm: _vrm, vrmGroup: _vrmGroup, }) @@ -792,7 +799,7 @@ async function loadModel() { } if (!clip) { disposeDetachedVrm(nextVrm, nextVrmGroup) - emitVrmLoadError(loadReason, loadStartedAt, 'No VRM animation loaded') + emitVrmLoadError(currentLoadReason, loadStartedAt, 'No VRM animation loaded') console.warn('No VRM animation loaded') if (isLoadRequestCurrent(requestId)) emit('error', new Error('No VRM animation loaded')) @@ -865,7 +872,7 @@ async function loadModel() { material: mat, materialIndex, mesh: child, - reason: loadReason, + reason: currentLoadReason, vrm: _vrm, vrmGroup: _vrmGroup, }) @@ -873,7 +880,7 @@ async function loadModel() { } }) - if (loadReason === 'model-switch') { + if (currentLoadReason === 'model-switch') { componentCleanUp('model-switch', { invalidate: false }) } @@ -891,7 +898,7 @@ async function loadModel() { stageThreeRuntimeTraceContext.emit(stageThreeTraceVrmLoadEndEvent, { durationMs: performance.now() - loadStartedAt, modelSrc: modelSrc.value, - reason: loadReason, + reason: currentLoadReason, rendererMemory: createThreeRendererMemorySnapshot(getRendererInstance()), sceneSummary: createVrmSceneSummarySnapshot({ mixer: vrmAnimationMixer.value, vrm: _vrm }), ts: performance.now(), @@ -903,7 +910,7 @@ async function loadModel() { if (nextVrm && nextVrmGroup) { runVrmDisposeHooks({ camera: camera.value, - reason: loadReason, + reason: currentLoadReason, vrm: nextVrm, vrmGroup: nextVrmGroup, }) @@ -916,7 +923,7 @@ async function loadModel() { if (!isLoadRequestCurrent(requestId)) return - emitVrmLoadError(loadReason, loadStartedAt, err) + emitVrmLoadError(currentLoadReason, loadStartedAt, err) console.error(err) emit('error', err) } diff --git a/packages/stage-ui-three/src/components/ThreeScene.vue b/packages/stage-ui-three/src/components/ThreeScene.vue index 8516b0dee..6b3aca006 100644 --- a/packages/stage-ui-three/src/components/ThreeScene.vue +++ b/packages/stage-ui-three/src/components/ThreeScene.vue @@ -12,6 +12,7 @@ import type { TresContext } from '@tresjs/core' import type { DirectionalLight, SphericalHarmonics3, Texture, WebGLRenderer, WebGLRenderTarget } from 'three' import type { SceneBootstrap, ScenePhase, Vec3 } from '../stores/model-store' +import type { VrmLifecycleReason } from '../trace' import { Screen } from '@proj-airi/ui' import { TresCanvas } from '@tresjs/core' @@ -77,7 +78,7 @@ type SceneTracePhaseCause | 'vrm:error' | 'vrm:load-start' | 'vrm:loaded' -type SceneTraceTransactionReason = 'component-unmount' | 'initial-load' | 'model-switch' | 'no-model' | 'subtree-remount' | 'unknown' +type SceneTraceTransactionReason = 'component-unmount' | 'initial-load' | 'model-reload' | 'model-switch' | 'no-model' | 'subtree-remount' | 'unknown' const componentState = defineModel<'pending' | 'loading' | 'mounted'>('state', { default: 'pending' }) @@ -93,6 +94,7 @@ const { scenePhase, sceneTransactionDepth, + lastCommittedModelSrc, modelSize, modelOrigin, modelOffset, @@ -140,6 +142,9 @@ const stageThreeSceneTraceOriginId = `three-scene:${Math.random().toString(36).s const latestScenePhaseTraceCause = ref('props:model-src') const latestSceneTransactionReason = ref('unknown') const activeModelSrc = ref() +const bindingRevision = ref(0) +const pendingCommittedModelSrc = ref() +const pendingCommittedModelRevision = ref() const pendingSceneBootstrap = shallowRef() function emitThreeSceneTrace(label: string, event: any, payload: Record) { @@ -222,6 +227,16 @@ function toVec3(value: Vector3): Vec3 { return { x: value.x, y: value.y, z: value.z } } +function clearPendingCommittedModel() { + pendingCommittedModelSrc.value = undefined + pendingCommittedModelRevision.value = undefined +} + +function invalidateBindingRevision() { + bindingRevision.value += 1 + clearPendingCommittedModel() +} + function applySceneBootstrap(value: SceneBootstrap) { const reason = latestSceneTransactionReason.value const previousOrigin = toVector3(modelOrigin.value) @@ -234,7 +249,8 @@ function applySceneBootstrap(value: SceneBootstrap) { modelSize.value = { ...value.modelSize } eyeHeight.value = value.eyeHeight - if (reason === 'initial-load' || reason === 'unknown' || reason === 'no-model') { + if (reason === 'initial-load' || reason === 'unknown' || reason === 'no-model' || reason === 'model-switch') { + modelOffset.value = { ...value.modelOffset } cameraDistance.value = value.cameraDistance cameraPosition.value = { ...value.cameraPosition } lookAtTarget.value = { ...value.lookAtTarget } @@ -304,6 +320,7 @@ const modelPhase = ref(props.modelSrc ? 'loading' : 'no-model') function beginSceneBindingCycle(reason: SceneTraceTransactionReason) { latestSceneTransactionReason.value = reason + invalidateBindingRevision() resetSceneBindingTransactions() emitSceneTransactionTrace('reset', reason) beginSceneBindingTransaction() @@ -313,6 +330,7 @@ function beginSceneBindingCycle(reason: SceneTraceTransactionReason) { function beginSceneRebind(reason: SceneTraceTransactionReason = 'subtree-remount') { latestSceneTransactionReason.value = reason + invalidateBindingRevision() if (sceneTransactionDepth.value === 0) { beginSceneBindingTransaction() @@ -331,15 +349,52 @@ function resolveLoadTransactionReason(): SceneTraceTransactionReason { if (!props.modelSrc) return 'no-model' - if (!activeModelSrc.value) + if (activeModelSrc.value === props.modelSrc) + return 'subtree-remount' + + if (!lastCommittedModelSrc.value) return 'initial-load' - if (props.modelSrc !== activeModelSrc.value) + if (lastCommittedModelSrc.value !== props.modelSrc) return 'model-switch' - return 'subtree-remount' + return 'model-reload' } +function commitLastCommittedModelSrc(expectedRevision: number, nextPhase: ScenePhase) { + if (nextPhase !== 'mounted') + return + + if (expectedRevision !== bindingRevision.value) + return + + if (!pendingCommittedModelSrc.value || pendingCommittedModelRevision.value !== expectedRevision) + return + + if (!activeModelSrc.value || pendingCommittedModelSrc.value !== activeModelSrc.value) + return + + if (props.modelSrc !== activeModelSrc.value) + return + + lastCommittedModelSrc.value = pendingCommittedModelSrc.value + clearPendingCommittedModel() +} + +const resolvedVrmLoadReason = computed(() => { + switch (latestSceneTransactionReason.value) { + case 'component-unmount': + case 'initial-load': + case 'model-switch': + return latestSceneTransactionReason.value + case 'model-reload': + case 'subtree-remount': + return 'model-reload' + default: + return 'initial-load' + } +}) + function resolveScenePhaseAfterBinding(): ScenePhase { if (!canvasReady.value) return 'pending' @@ -356,7 +411,7 @@ function resolveScenePhaseAfterBinding(): ScenePhase { return 'loading' } -async function completeSceneBinding() { +async function completeSceneBinding(expectedRevision = bindingRevision.value) { if (isCompletingBinding.value) return isCompletingBinding.value = true @@ -370,6 +425,10 @@ async function completeSceneBinding() { } await nextTick() + + if (expectedRevision !== bindingRevision.value) + return + controlsRef.value?.update() if (sceneTransactionDepth.value > 0) { @@ -377,7 +436,9 @@ async function completeSceneBinding() { emitSceneTransactionTrace('end', latestSceneTransactionReason.value) } - setScenePhaseWithTrace(resolveScenePhaseAfterBinding(), 'binding:complete') + const nextPhase = resolveScenePhaseAfterBinding() + setScenePhaseWithTrace(nextPhase, 'binding:complete') + commitLastCommittedModelSrc(expectedRevision, nextPhase) } finally { isCompletingBinding.value = false @@ -414,10 +475,13 @@ function onVRMModelLookAtTarget(value: Vec3) { } function onVRMModelLoaded(value: string) { activeModelSrc.value = value + pendingCommittedModelSrc.value = value + pendingCommittedModelRevision.value = bindingRevision.value modelPhase.value = 'ready' - void completeSceneBinding() + void completeSceneBinding(bindingRevision.value) } function onVRMModelError(error: unknown) { + invalidateBindingRevision() pendingSceneBootstrap.value = undefined modelPhase.value = props.modelSrc ? 'error' : 'no-model' resetSceneBindingTransactions() @@ -471,6 +535,7 @@ onMounted(() => { }) onUnmounted(() => { + invalidateBindingRevision() if (tresCanvasRef.value) emitSceneSubtreeTrace('tresCanvasRef', 'detached') @@ -498,6 +563,7 @@ watch(() => props.modelSrc, (modelSrc) => { modelPhase.value = modelSrc ? 'loading' : 'no-model' if (!modelSrc) { + invalidateBindingRevision() activeModelSrc.value = undefined pendingSceneBootstrap.value = undefined resetSceneBindingTransactions() @@ -710,6 +776,7 @@ defineExpose({ ref="modelRef" :current-audio-source="props.currentAudioSource" :model-src="props.modelSrc" + :load-reason="resolvedVrmLoadReason" :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 61bed5fbd..e908783b0 100644 --- a/packages/stage-ui-three/src/stores/model-store.ts +++ b/packages/stage-ui-three/src/stores/model-store.ts @@ -15,6 +15,7 @@ export interface SceneBootstrap { cameraPosition: Vec3 eyeHeight: number lookAtTarget: Vec3 + modelOffset: Vec3 modelOrigin: Vec3 modelSize: Vec3 } @@ -133,6 +134,7 @@ export const useModelStore = defineStore('modelStore', () => { // REVIEW: `scale` is still shared with non-VRM view controls. The VRM path should // gradually move to `cameraDistance` as the primary user-facing zoom concept. const scale = useLocalStorage('settings/stage-ui-three/scale', 1) + const lastCommittedModelSrc = useLocalStorage('settings/stage-ui-three/lastModelSrc', '') // === Model lifecycle / bootstrap === // These values are recalculated from the currently bound model instance whenever @@ -160,6 +162,7 @@ export const useModelStore = defineStore('modelStore', () => { scenePhase.value = 'pending' sceneTransactionDepth.value = 0 + lastCommittedModelSrc.value = '' modelSize.value = { x: 0, y: 0, z: 0 } modelOrigin.value = { x: 0, y: 0, z: 0 } modelOffset.value = { x: 0, y: 0, z: 0 } @@ -216,6 +219,7 @@ export const useModelStore = defineStore('modelStore', () => { sceneMutationLocked, scale, + lastCommittedModelSrc, modelSize, modelOrigin, diff --git a/packages/stage-ui-three/src/trace/types.ts b/packages/stage-ui-three/src/trace/types.ts index b5eff39fd..07708cc9c 100644 --- a/packages/stage-ui-three/src/trace/types.ts +++ b/packages/stage-ui-three/src/trace/types.ts @@ -70,6 +70,7 @@ export type ThreeSceneTraceTransactionAction = 'begin' | 'end' | 'reset' export type ThreeSceneTraceTransactionReason = | 'component-unmount' | 'initial-load' + | 'model-reload' | 'model-switch' | 'no-model' | 'subtree-remount' @@ -126,6 +127,7 @@ export type VrmLifecycleReason = | 'component-unmount' | 'initial-load' | 'manual-reload' + | 'model-reload' | 'model-switch' export interface VrmLifecycleTracePayload extends StageThreeRuntimeTraceBasePayload {