feat(stage-ui): Now the VRM model can always look to the camera! (#322)

This commit is contained in:
Lilia_Chen
2025-07-25 19:03:50 +08:00
committed by GitHub
parent 4b0577ddcf
commit 1d486655ed
4 changed files with 80 additions and 40 deletions
+38 -20
View File
@@ -2,7 +2,7 @@
import { TresCanvas } from '@tresjs/core'
import { useElementBounding } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { ref, shallowRef, watch } from 'vue'
import { onUnmounted, ref, shallowRef, watch } from 'vue'
import * as THREE from 'three'
@@ -49,8 +49,38 @@ watch(cameraFOV, (newFov) => {
// })
// If controls are ready
watch(() => controlsRef.value?.controls, (ctrl) => {
if (ctrl)
if (ctrl && camera.value) {
controlsReady.value = true
const updateCameraFromControls = () => {
if (isUpdatingCamera)
return
isUpdatingCamera = true
const newPos = camera.value!.position
const newDist = controlsRef.value!.controls!.getDistance()
const posChanged
= Math.abs(cameraPosition.value.x - newPos.x) > 1e-6
|| Math.abs(cameraPosition.value.y - newPos.y) > 1e-6
|| Math.abs(cameraPosition.value.z - newPos.z) > 1e-6
const distChanged = Math.abs(cameraDistance.value - newDist) > 1e-6
if (posChanged || distChanged) {
cameraPosition.value = { x: newPos.x, y: newPos.y, z: newPos.z }
cameraDistance.value = newDist
}
isUpdatingCamera = false
}
ctrl.addEventListener('change', updateCameraFromControls)
onUnmounted(() => {
ctrl.removeEventListener('change', updateCameraFromControls)
})
}
})
// If model is ready
function handleLoadModelProgress() {
@@ -75,6 +105,7 @@ watch(
)
camera.value.updateProjectionMatrix()
controlsRef.value.controls.update()
cameraDistance.value = controlsRef.value!.controls!.getDistance()
}
finally {
isUpdatingCamera = false
@@ -83,24 +114,7 @@ watch(
}
},
)
// Bidirectional watch between slider and OrbitControls
watch(() => controlsRef.value?.getDistance(), (newDistance) => {
if (!isUpdatingCamera && newDistance !== undefined && camera.value) {
// To avoid floating point inaccuracies causing a feedback loop with the other watcher,
// we can check if the distance has changed significantly.
isUpdatingCamera = true
if (Math.abs(cameraDistance.value - newDistance) > 1e-6) {
cameraDistance.value = newDistance
cameraPosition.value = {
x: camera.value.position.x,
y: camera.value.position.y,
z: camera.value.position.z,
}
}
isUpdatingCamera = false
}
})
watch(cameraDistance, (newDistance) => {
if (!isUpdatingCamera && camera.value && controlsRef.value && controlsRef.value.controls) {
isUpdatingCamera = true
@@ -120,9 +134,13 @@ watch(cameraDistance, (newDistance) => {
z: newPosition.z,
}
}
isUpdatingCamera = false
})
watch(cameraPosition, (newPosition) => {
if (modelRef.value) {
modelRef.value.lookAtUpdate(newPosition)
}
}, { deep: true })
defineExpose({
setExpression: (expression: string) => {
@@ -33,7 +33,6 @@ const vrmAnimationMixer = ref<AnimationMixer>()
const { scene } = useTresContext()
const { onBeforeRender } = useLoop()
const blink = useBlink()
const idleEyeSaccades = useIdleEyeSaccades()
const vrmEmote = ref<ReturnType<typeof useVRMEmote>>()
const vrmStore = useVRM()
@@ -46,6 +45,7 @@ const {
modelRotationY,
} = storeToRefs(vrmStore)
const vrmGroup = ref<Group>()
const idleEyeSaccades = useIdleEyeSaccades()
onMounted(async () => {
if (!scene.value) {
@@ -95,10 +95,10 @@ onMounted(async () => {
// Set model facing direction
const targetDirection = new Vector3(0, 0, -1) // Default facing direction
const lookAtTarget = _vrm.lookAt
if (lookAtTarget) {
const facingDirection = lookAtTarget.faceFront
const quaternion = new Quaternion()
const lookAt = _vrm.lookAt
const quaternion = new Quaternion()
if (lookAt) {
const facingDirection = lookAt.faceFront
quaternion.setFromUnitVectors(facingDirection.normalize(), targetDirection.normalize())
_vrmGroup.quaternion.premultiply(quaternion)
_vrmGroup.updateMatrixWorld(true)
@@ -156,6 +156,7 @@ onMounted(async () => {
}
// Reanchor the root position track to the model origin
reanchorRootPositionTrack(clip)
// rotateRotationTracksInClip(clip, quaternion)
// play animation
vrmAnimationMixer.value = new AnimationMixer(_vrm.scene)
@@ -171,8 +172,9 @@ onMounted(async () => {
disposeBeforeRenderLoop = onBeforeRender(({ delta }) => {
vrmAnimationMixer.value?.update(delta)
vrm.value?.update(delta)
vrm.value?.lookAt?.update?.(delta)
blink.update(vrm.value, delta)
idleEyeSaccades.update(vrm.value, delta)
idleEyeSaccades.update(vrm.value, cameraPosition, delta)
vrmEmote.value?.update(delta)
}).off
}
@@ -204,7 +206,9 @@ defineExpose({
vrmEmote.value?.setEmotionWithResetAfter(expression, 1000)
},
scene: computed(() => vrm.value?.scene),
lookAt: computed(() => vrm.value?.lookAt),
lookAtUpdate(target: { x: number, y: number, z: number }) {
idleEyeSaccades.instantUpdate(vrm.value, target)
},
})
const { pause, resume } = useLoop()
@@ -1,5 +1,6 @@
import type { VRMAnimation } from '@pixiv/three-vrm-animation'
import type { VRMCore } from '@pixiv/three-vrm-core'
import type { Ref } from 'vue'
import { createVRMAnimationClip } from '@pixiv/three-vrm-animation'
import { Object3D, Vector3 } from 'three'
@@ -100,42 +101,56 @@ export function useBlink() {
*/
export function useIdleEyeSaccades() {
let nextSaccadeAfter = -1
let fixationTarget: Vector3 | undefined
const fixationTarget = new Vector3()
let timeSinceLastSaccade = 0
// Just a naive vector generator - Simulating random content on a 27in monitor at 65cm distance
function updateFixationTarget() {
if (!fixationTarget) {
fixationTarget = new Vector3(randFloat(-0.25, 0.25), randFloat(-0.2, 0.15), -0.65)
}
else {
fixationTarget.set(randFloat(-0.25, 0.25), randFloat(-0.2, 0.15), -0.65)
}
function updateFixationTarget(lookAtTarget: Ref<{ x: number, y: number, z: number }>) {
fixationTarget.set(
lookAtTarget.value.x + randFloat(-0.25, 0.25),
lookAtTarget.value.y + randFloat(-0.25, 0.25),
lookAtTarget.value.z,
)
}
// Function to handle idle eye saccades
function update(vrm: VRMCore | undefined, delta: number) {
function update(vrm: VRMCore | undefined, lookAtTarget: Ref<{ x: number, y: number, z: number }>, delta: number) {
if (!vrm?.expressionManager || !vrm.lookAt)
return
if (timeSinceLastSaccade >= nextSaccadeAfter) {
updateFixationTarget()
updateFixationTarget(lookAtTarget)
timeSinceLastSaccade = 0
nextSaccadeAfter = randomSaccadeInterval() / 1000
}
else if (!fixationTarget) {
updateFixationTarget()
updateFixationTarget(lookAtTarget)
}
if (!vrm.lookAt.target) {
vrm.lookAt.target = new Object3D()
}
vrm.lookAt.target.position.lerp(fixationTarget!, randFloat(0.2, 0.5))
vrm.lookAt.target.position.lerp(fixationTarget!, 1)
vrm.lookAt?.update(delta)
timeSinceLastSaccade += delta
}
return { update }
function instantUpdate(vrm: VRMCore | undefined, lookAtTarget: { x: number, y: number, z: number }) {
fixationTarget.set(
lookAtTarget.x,
lookAtTarget.y,
lookAtTarget.z,
)
if (!vrm?.expressionManager || !vrm.lookAt)
return
if (!vrm.lookAt.target) {
vrm.lookAt.target = new Object3D()
}
vrm.lookAt.target.position.lerp(fixationTarget!, 1)
vrm.lookAt?.update(0.016)
}
return { update, instantUpdate }
}
+3
View File
@@ -23,6 +23,8 @@ export const useVRM = defineStore('vrm', () => {
const modelRotationY = useLocalStorage('settings/vrm/modelRotationY', 0)
const cameraDistance = useLocalStorage('settings/vrm/cameraDistance', 0)
const isTracking = useLocalStorage('settings/vrm/isTracking', false)
// Manage the object URL lifecycle to prevent memory leaks
watch(modelFile, (newFile) => {
if (modelObjectUrl.value) {
@@ -60,5 +62,6 @@ export const useVRM = defineStore('vrm', () => {
cameraPosition,
modelRotationY,
cameraDistance,
isTracking,
}
})