From 7f5a244fbffa519c3f0646498ad73dc11aafce8d Mon Sep 17 00:00:00 2001 From: Lilia_Chen Date: Wed, 23 Jul 2025 18:07:50 +0100 Subject: [PATCH] feat(stage-ui): animation start coordinate anchor + vrm position logic fix (#314) --------- Co-authored-by: Neko --- .../Scenarios/Settings/ModelSettings/VRM.vue | 11 +++- .../stage-ui/src/components/Scenes/VRM.vue | 26 +++++--- .../src/components/Scenes/VRM/Model.vue | 63 ++++++++++++++----- packages/stage-ui/src/composables/vrm/core.ts | 22 ++----- packages/stage-ui/src/stores/vrm.ts | 12 ---- 5 files changed, 81 insertions(+), 53 deletions(-) diff --git a/packages/stage-ui/src/components/Scenarios/Settings/ModelSettings/VRM.vue b/packages/stage-ui/src/components/Scenarios/Settings/ModelSettings/VRM.vue index b898c410e..09051b2ad 100644 --- a/packages/stage-ui/src/components/Scenarios/Settings/ModelSettings/VRM.vue +++ b/packages/stage-ui/src/components/Scenarios/Settings/ModelSettings/VRM.vue @@ -2,6 +2,7 @@ import { FieldRange, Input } from '@proj-airi/ui' import { useFileDialog } from '@vueuse/core' import { storeToRefs } from 'pinia' +import { Vector3 } from 'three' import { ref } from 'vue' import { useI18n } from 'vue-i18n' @@ -71,7 +72,12 @@ function urlUploadClick() { function resetCameraDistance() { // Calculate the camera distance that can fit the up-2/3 part of the model in the view const radians = (cameraFOV.value / 2 * Math.PI) / 180 - cameraDistance.value = (modelSize.value.y / 3) / Math.tan(radians) + const initialCameraOffset = new Vector3( + modelSize.value.x / 16, + modelSize.value.y / 6, // default y value + -(modelSize.value.y / 3) / Math.tan(radians), // default z value + ) + cameraDistance.value = initialCameraOffset.length() } @@ -208,7 +214,7 @@ function resetCameraDistance() { diff --git a/packages/stage-ui/src/components/Scenes/VRM/Model.vue b/packages/stage-ui/src/components/Scenes/VRM/Model.vue index 50e081eb1..451dea0ef 100644 --- a/packages/stage-ui/src/components/Scenes/VRM/Model.vue +++ b/packages/stage-ui/src/components/Scenes/VRM/Model.vue @@ -5,7 +5,7 @@ import type { AnimationClip, Group } from 'three' import { VRMUtils } from '@pixiv/three-vrm' import { useLoop, useTresContext } from '@tresjs/core' import { storeToRefs } from 'pinia' -import { AnimationMixer, MathUtils, Quaternion, Vector3 } from 'three' +import { AnimationMixer, MathUtils, Quaternion, Vector3, VectorKeyframeTrack } from 'three' import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { clipFromVRMAnimation, loadVRMAnimation, useBlink, useIdleEyeSaccades } from '../../../composables/vrm/animation' @@ -40,7 +40,6 @@ const { modelOffset, modelOrigin, modelSize, - position, initialCameraPosition, loadingModel, modelRotationY, @@ -69,12 +68,16 @@ onMounted(async () => { _vrmGroup, modelCenter: vrmModelCenter, modelSize: vrmModelSize, - initialCameraPosition: vrmInitialCameraPosition, + initialCameraOffset: vrmInitialCameraOffset, } = _vrmInfo vrmGroup.value = _vrmGroup // Set initial camera position - initialCameraPosition.value = vrmInitialCameraPosition + initialCameraPosition.value = { + x: vrmModelCenter.x + vrmInitialCameraOffset.x, + y: vrmModelCenter.y + vrmInitialCameraOffset.y, + z: vrmModelCenter.z + vrmInitialCameraOffset.z, + } // Set initial positions for model modelOrigin.value = { @@ -95,8 +98,8 @@ onMounted(async () => { const facingDirection = lookAtTarget.faceFront const quaternion = new Quaternion() quaternion.setFromUnitVectors(facingDirection.normalize(), targetDirection.normalize()) - _vrm.scene.quaternion.premultiply(quaternion) - _vrm.scene.updateMatrixWorld(true) + _vrmGroup.quaternion.premultiply(quaternion) + _vrmGroup.updateMatrixWorld(true) } else { console.warn('No look-at target found in VRM model') @@ -105,9 +108,41 @@ onMounted(async () => { modelRotationY.value = 0 // Set initial positions for animation - function removeRootPositionTrack(clip: AnimationClip) { - clip.tracks = clip.tracks.filter((track) => { - return !track.name.endsWith('.position') + function reanchorRootPositionTrack(clip: AnimationClip) { + // Get the hips node to reanchor the root position track + const hipNode = _vrm.humanoid?.getNormalizedBoneNode('hips') + if (!hipNode) { + console.warn('No hips node found in VRM model.') + return + } + hipNode.updateMatrixWorld(true) + const defaultHipPos = new Vector3() + hipNode.getWorldPosition(defaultHipPos) + + // Calculate the offset from the hips node to the hips's first frame position + const hipsTrack = clip.tracks.find(track => + track.name.endsWith('Hips.position'), + ) + if (!(hipsTrack instanceof VectorKeyframeTrack)) { + console.warn('No Hips.position track of type VectorKeyframeTrack found in animation.') + return + } + + const animeHipPos = new Vector3( + hipsTrack.values[0], + hipsTrack.values[1], + hipsTrack.values[2], + ) + const delta = new Vector3().subVectors(animeHipPos, defaultHipPos) + + clip.tracks.forEach((track) => { + if (track.name.endsWith('.position') && track instanceof VectorKeyframeTrack) { + for (let i = 0; i < track.values.length; i += 3) { + track.values[i] -= delta.x + track.values[i + 1] -= delta.y + track.values[i + 2] -= delta.z + } + } }) } @@ -117,8 +152,8 @@ onMounted(async () => { console.warn('No VRM animation loaded') return } - - removeRootPositionTrack(clip) + // Reanchor the root position track to the model origin + reanchorRootPositionTrack(clip) // play animation vrmAnimationMixer.value = new AnimationMixer(_vrm.scene) @@ -148,9 +183,9 @@ onMounted(async () => { watch(modelOffset, () => { if (vrmGroup.value) { vrmGroup.value.position.set( - position.value.x, - position.value.y, - position.value.z, + modelOffset.value.x, + modelOffset.value.y, + modelOffset.value.z, ) } }, { deep: true }) diff --git a/packages/stage-ui/src/composables/vrm/core.ts b/packages/stage-ui/src/composables/vrm/core.ts index 7098ca594..1f59af615 100644 --- a/packages/stage-ui/src/composables/vrm/core.ts +++ b/packages/stage-ui/src/composables/vrm/core.ts @@ -16,7 +16,7 @@ export async function loadVrm(model: string, options?: { scene?: Scene lookAt?: boolean onProgress?: (progress: ProgressEvent) => void | Promise -}): Promise<{ _vrm: VRMCore, _vrmGroup: Group, modelCenter: Vector3, modelSize: Vector3, initialCameraPosition: Vector3 } | undefined> { +}): Promise<{ _vrm: VRMCore, _vrmGroup: Group, modelCenter: Vector3, modelSize: Vector3, initialCameraOffset: Vector3 } | undefined> { const loader = useVRMLoader() const gltf = await loader.loadAsync(model, progress => options?.onProgress?.(progress)) @@ -50,34 +50,22 @@ export async function loadVrm(model: string, options?: { options.scene.add(_vrmGroup) } - // Move the VRM model centre to the (0, 0, 0) const box = new Box3().setFromObject(_vrm.scene) const modelSize = new Vector3() const modelCenter = new Vector3() box.getSize(modelSize) box.getCenter(modelCenter) - modelCenter.negate() - modelCenter.y -= modelSize.y / 5 // Adjust pivot to align chest with the origin + modelCenter.y += modelSize.y / 5 // Adjust pivot to align chest with the origin // Set position - if (options?.positionOffset) { - _vrmGroup.position.set( - modelCenter.x + options.positionOffset[0], - modelCenter.y + options.positionOffset[1], - modelCenter.z + options.positionOffset[2], - ) - } - else { - _vrmGroup.position.set(modelCenter.x, modelCenter.y, modelCenter.z) - } // Compute the initial camera position (once per loaded model) // In order to see the up-2/3 part fo the model, z = (y/3) / tan(fov/2) const fov = 40 // default fov = 40 degrees const radians = (fov / 2 * Math.PI) / 180 - const initialCameraPosition = new Vector3( + const initialCameraOffset = new Vector3( modelSize.x / 16, - modelSize.y / 6, + modelSize.y / 6, // default y value -(modelSize.y / 3) / Math.tan(radians), // default z value ) @@ -86,6 +74,6 @@ export async function loadVrm(model: string, options?: { _vrmGroup, modelCenter, modelSize, - initialCameraPosition, + initialCameraOffset, } } diff --git a/packages/stage-ui/src/stores/vrm.ts b/packages/stage-ui/src/stores/vrm.ts index 13dc1f50b..091c7a5c7 100644 --- a/packages/stage-ui/src/stores/vrm.ts +++ b/packages/stage-ui/src/stores/vrm.ts @@ -16,16 +16,6 @@ export const useVRM = defineStore('vrm', () => { const modelOrigin = useLocalStorage('settings/vrm/modelOrigin', { x: 0, y: 0, z: 0 }) const modelOffset = useLocalStorage('settings/vrm/modelOffset', { x: 0, y: 0, z: 0 }) - const position = computed(() => ({ - x: modelOrigin.value.x + modelOffset.value.x, - y: modelOrigin.value.y + modelOffset.value.y, - z: modelOrigin.value.z + modelOffset.value.z, - })) - const positionInPercentageString = computed(() => ({ - x: `${position.value.x}%`, - y: `${position.value.y}%`, - z: `${position.value.z}%`, - })) const cameraFOV = useLocalStorage('settings/vrm/cameraFOV', 40) const initialCameraPosition = useLocalStorage('settings/vrm/initialCameraPosition', { x: 0, y: 0, z: -1 }) @@ -65,8 +55,6 @@ export const useVRM = defineStore('vrm', () => { scale, modelOrigin, modelOffset, - position, - positionInPercentageString, selectedModel, cameraFOV, initialCameraPosition,