fix(stage-ui-three): model offset and camera position setting is now persistent in stage-web app (#1543)

This commit is contained in:
Lilia_Chen
2026-04-02 07:04:03 +08:00
committed by GitHub
parent bf3c778cb0
commit 522e48b063
4 changed files with 103 additions and 23 deletions
@@ -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)
}
@@ -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<SceneTracePhaseCause>('props:model-src')
const latestSceneTransactionReason = ref<SceneTraceTransactionReason>('unknown')
const activeModelSrc = ref<string>()
const bindingRevision = ref(0)
const pendingCommittedModelSrc = ref<string>()
const pendingCommittedModelRevision = ref<number>()
const pendingSceneBootstrap = shallowRef<SceneBootstrap>()
function emitThreeSceneTrace(label: string, event: any, payload: Record<string, unknown>) {
@@ -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<ModelPhase>(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<VrmLifecycleReason>(() => {
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"
@@ -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,
@@ -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 {