refactor(stage-ui): model file & model url loading for live2d and vrm

This commit is contained in:
Neko Ayaka
2025-08-07 00:00:35 +08:00
parent 2aa37d2a07
commit acda49dc66
12 changed files with 320 additions and 305 deletions
+1
View File
@@ -192,6 +192,7 @@ words:
- pthread
- quanlai
- qwen
- Raycaster
- rdev
- rehype
- reka
@@ -36,7 +36,6 @@ const live2d = useLive2d()
const {
modelFile,
motionMap,
loadSource,
loadingModel,
availableMotions,
modelUrl,
@@ -50,7 +49,6 @@ modelFileDialog.onChange((files) => {
if (files && files.length > 0) {
motionMap.value = {}
modelFile.value = files[0]
loadSource.value = 'file'
loadingModel.value = true
}
})
@@ -60,7 +58,7 @@ watch(loadingModel, (value) => {
return
}
if (loadSource.value !== 'file') {
if (!modelFile.value) {
return
}
@@ -116,7 +114,6 @@ async function saveMotionMap() {
const patchedFile = await patchMotionMap(fileFromIndexedDB, motionMap.value)
modelFile.value = patchedFile
loadSource.value = 'file'
loadingModel.value = true
}
@@ -178,7 +175,7 @@ const exportObjectUrl = useObjectUrl(modelFile)
</Button>
</Section>
<Section
v-if="loadSource === 'file'"
v-if="modelFile"
:title="t('settings.live2d.edit-motion-map.title')"
icon="i-solar:face-scan-circle-bold-duotone"
:class="[
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { Input } from '@proj-airi/ui'
import { useFileDialog } from '@vueuse/core'
import { useFileDialog, useObjectUrl } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useVRM } from '../../../../stores'
@@ -29,18 +29,15 @@ const modelFileDialog = useFileDialog({
const vrm = useVRM()
const {
modelFile,
loadSource,
loadingModel,
modelUrl,
modelSize,
modelOffset,
cameraFOV,
selectedModel,
modelRotationY,
cameraDistance,
trackingMode,
} = storeToRefs(vrm)
const localModelUrl = ref(modelUrl.value)
const trackingOptions = computed(() => [
{ value: 'camera', label: t('settings.vrm.scale-and-position.eye-tracking-mode.options.option.camera'), class: 'col-start-3' },
{ value: 'mouse', label: t('settings.vrm.scale-and-position.eye-tracking-mode.options.option.mouse'), class: 'col-start-4' },
@@ -50,29 +47,22 @@ const trackingOptions = computed(() => [
modelFileDialog.onChange((files) => {
if (files && files.length > 0) {
modelFile.value = files[0]
loadSource.value = 'file'
loadingModel.value = true
localModelUrl.value = ''
}
})
function urlUploadClick() {
modelUrl.value = localModelUrl.value
// same URL will let the loader be lazy and forgot to reset loading state
// If the loading state is still true, then the URL input will be locked
if (modelUrl.value === selectedModel.value) {
console.warn('Model URL is the same as the selected model, no need to reload.')
return
const urlFile = useObjectUrl(modelFile)
const urlRef = computed({
get: () => urlFile.value || modelUrl.value || '',
set: (value) => {
modelUrl.value = value
},
})
function handleUrlLoad() {
const parsedUrl = new URL(urlRef.value, 'https://example.com')
if (parsedUrl.origin === 'https://example.com') {
modelUrl.value = urlRef.value
}
// Can't let the default model URL be reentered into the loader, otherwise it will still be too lazy to reset the loading state
if (!modelUrl.value && selectedModel.value === vrm.defaultModelUrl) {
localModelUrl.value = vrm.defaultModelUrl
return
}
// Only when real different URL is entered, then the loader will be triggered
loadSource.value = 'url'
loadingModel.value = true
localModelUrl.value = selectedModel.value
}
</script>
@@ -156,12 +146,11 @@ function urlUploadClick() {
</Button>
<div flex items-center gap-2>
<Input
v-model="localModelUrl"
:disabled="loadingModel"
v-model="urlRef"
class="flex-1"
:placeholder="t('settings.vrm.change-model.from-url-placeholder')"
/>
<Button size="sm" variant="secondary" @click="urlUploadClick">
<Button size="sm" variant="secondary" @click="handleUrlLoad">
{{ t('settings.vrm.change-model.from-url') }}
</Button>
</div>
@@ -6,6 +6,7 @@ import VRMScene from '../../../Scenes/VRM.vue'
import Live2D from './Live2D.vue'
import VRM from './VRM.vue'
import { useLive2d, useVRM } from '../../../../stores'
import { useSettings } from '../../../../stores/settings'
const props = defineProps<{
@@ -21,6 +22,8 @@ defineEmits<{
}>()
const { stageView } = storeToRefs(useSettings())
const { modelFile: live2dModelFile, modelUrl: live2dModelUrl } = storeToRefs(useLive2d())
const { modelFile: vrmModelFile, modelUrl: vrmModelUrl } = storeToRefs(useVRM())
</script>
<template>
@@ -33,7 +36,7 @@ const { stageView } = storeToRefs(useSettings())
: []),
]"
>
<Live2DScene />
<Live2DScene :model-src="live2dModelUrl" :model-file="live2dModelFile" />
</div>
<div
flex="~ col gap-2" :class="[
@@ -54,7 +57,7 @@ const { stageView } = storeToRefs(useSettings())
: []),
]"
>
<VRMScene />
<VRMScene :model-src="vrmModelUrl" :model-file="vrmModelFile" />
</div>
<div h-full w-full p-2>
<div
@@ -6,6 +6,9 @@ import Live2DModel from './Live2D/Model.vue'
import '../../utils/live2d-zip-loader'
withDefaults(defineProps<{
modelSrc?: string
modelFile?: File | null
paused?: boolean
mouthOpenSize?: number
focusAt?: { x: number, y: number }
@@ -31,6 +34,8 @@ withDefaults(defineProps<{
max-h="100dvh"
>
<Live2DModel
:model-src="modelSrc"
:model-file="modelFile"
:app="app"
:mouth-open-size="mouthOpenSize"
:width="width"
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { Application } from '@pixi/app'
import { extensions } from '@pixi/extensions'
import { InteractionManager } from '@pixi/interaction'
import { Ticker, TickerPlugin } from '@pixi/ticker'
import { Live2DModel } from 'pixi-live2d-display/cubism4'
import { onMounted, onUnmounted, ref, watch } from 'vue'
@@ -14,13 +15,17 @@ const props = withDefaults(defineProps<{
})
const containerRef = ref<HTMLDivElement>()
const pixiAppReady = ref(false)
const pixiApp = ref<Application>()
const pixiAppCanvas = ref<HTMLCanvasElement>()
async function initLive2DPixiStage(parent: HTMLDivElement) {
pixiAppReady.value = true
// https://guansss.github.io/pixi-live2d-display/#package-importing
Live2DModel.registerTicker(Ticker)
extensions.add(TickerPlugin)
extensions.add(InteractionManager)
pixiApp.value = new Application({
width: props.width * props.resolution,
@@ -38,6 +43,7 @@ async function initLive2DPixiStage(parent: HTMLDivElement) {
pixiAppCanvas.value.style.display = 'block'
parent.appendChild(pixiApp.value.view)
pixiAppReady.value = true
}
function handleResize() {
@@ -84,6 +90,6 @@ defineExpose({
<template>
<div ref="containerRef" h-full w-full>
<slot :app="pixiApp" />
<slot v-if="pixiAppReady" :app="pixiApp" />
</div>
</template>
@@ -4,10 +4,7 @@ import type { Cubism4InternalModel, InternalModel } from 'pixi-live2d-display/cu
import localforage from 'localforage'
import { extensions } from '@pixi/extensions'
import { InteractionManager } from '@pixi/interaction'
import { Ticker, TickerPlugin } from '@pixi/ticker'
import { breakpointsTailwind, useBreakpoints, useDark, useDebounceFn, watchDebounced } from '@vueuse/core'
import { breakpointsTailwind, useBreakpoints, useDark, useDebounceFn, useObjectUrl, watchDebounced } from '@vueuse/core'
import { formatHex } from 'culori'
import { storeToRefs } from 'pinia'
import { DropShadowFilter } from 'pixi-filters'
@@ -25,6 +22,9 @@ type PixiLive2DInternalModel = InternalModel & {
}
const props = withDefaults(defineProps<{
modelSrc?: string
modelFile?: File | null
app?: Application
mouthOpenSize?: number
width: number
@@ -64,6 +64,19 @@ function parsePropsOffset() {
}
}
const modelSrcRef = toRef(() => props.modelSrc)
const modelFileRef = toRef(() => props.modelFile)
const modelFileSrc = useObjectUrl(modelFileRef)
const modelSrcNormalized = computed(() => {
if (modelFileSrc.value)
return modelFileSrc.value
if (modelSrcRef.value)
return modelSrcRef.value
return ''
})
const offset = computed(() => parsePropsOffset())
const pixiApp = toRef(() => props.app)
@@ -114,8 +127,6 @@ const {
loadingModel,
currentMotion,
availableMotions,
loadSource,
modelUrl,
} = storeToRefs(useLive2d())
const {
@@ -134,21 +145,18 @@ async function loadModel() {
model.value.destroy()
model.value = undefined
}
if (!modelSrcNormalized.value) {
console.warn('No Live2D model source provided.')
return
}
const modelInstance = new Live2DModel<PixiLive2DInternalModel>()
if (loadSource.value === 'file') {
await Live2DFactory.setupLive2DModel(modelInstance, [modelFile.value], { autoInteract: false })
}
else if (loadSource.value === 'url') {
await Live2DFactory.setupLive2DModel(modelInstance, modelUrl.value, { autoInteract: false })
}
await Live2DFactory.setupLive2DModel(modelInstance, modelSrcNormalized.value, { autoInteract: false })
model.value = modelInstance
pixiApp.value.stage.addChild(model.value as any)
pixiApp.value.stage.addChild(model.value)
initialModelWidth.value = model.value.width
initialModelHeight.value = model.value.height
model.value.anchor.set(0.5, 0.5)
setScaleAndPosition()
@@ -190,10 +198,7 @@ async function loadModel() {
// This is hacky too
const hookedUpdate = motionManager.update as (model: CubismModel, now: number) => boolean
motionManager.update = function (model: CubismModel, now: number) {
// Let's initialize the last update time first
if (lastUpdateTime.value === 0) {
lastUpdateTime.value = now
}
lastUpdateTime.value = now
hookedUpdate?.call(this, model, now)
// Possibility 1: Only update eye focus when the model is idle
@@ -230,8 +235,6 @@ async function loadModel() {
return true
}
lastUpdateTime.value = now
return false
}
@@ -248,33 +251,6 @@ async function loadModel() {
loadingModel.value = false
}
async function initLive2DPixiStage() {
if (!pixiApp.value)
return
// https://guansss.github.io/pixi-live2d-display/#package-importing
Live2DModel.registerTicker(Ticker)
extensions.add(TickerPlugin)
extensions.add(InteractionManager)
// load indexdb model first
const live2dModelFromIndexedDB = await localforage.getItem<File>('live2dModel')
if (live2dModelFromIndexedDB) {
modelFile.value = live2dModelFromIndexedDB
loadSource.value = 'file'
loadingModel.value = true
return
}
if (modelUrl.value) {
loadSource.value = 'url'
loadingModel.value = true
return
}
loadingModel.value = false
}
async function setMotion(motionName: string, index?: number) {
// TODO: motion? Not every Live2D model has motion, we do need to help users to set motion
await model.value?.motion(motionName, index, MotionPriority.FORCE)
@@ -294,16 +270,11 @@ function updateDropShadowFilter() {
}
watch([() => props.width, () => props.height], () => handleResize())
watch(modelSrcNormalized, () => loadModel(), { immediate: true })
watch(dark, updateDropShadowFilter, { immediate: true })
watch([model, themeColorsHue], updateDropShadowFilter)
watch(offset, setScaleAndPosition)
watch(() => props.scale, setScaleAndPosition)
watch(modelFile, () => {
if (modelFile.value) {
loadingModel.value = true
loadModel()
}
}, { immediate: true })
// TODO: This is hacky!
function updateDropShadowFilterLoop() {
@@ -322,7 +293,6 @@ watch(themeColorsHueDynamic, () => {
}, { immediate: true })
watch(mouthOpenSize, value => getCoreModel().setParameterValueById('ParamMouthOpenY', value))
watch(pixiApp, initLive2DPixiStage)
watch(currentMotion, value => setMotion(value.group, value.index))
watch(paused, value => value ? pixiApp.value?.stop() : pixiApp.value?.start())
@@ -342,7 +312,7 @@ watchDebounced(loadingModel, (value) => {
loadModel()
}, { debounce: 1000 })
onMounted(updateDropShadowFilter)
onMounted(() => updateDropShadowFilter())
function componentCleanUp() {
cancelAnimationFrame(dropShadowAnimationId.value)
@@ -21,7 +21,7 @@ import { useQueue } from '../../composables/queue'
import { useDelayMessageQueue, useEmotionsMessageQueue, useMessageContentQueue } from '../../composables/queues'
import { llmInferenceEndToken } from '../../constants'
import { EMOTION_EmotionMotionName_value, EMOTION_VRMExpressionName_value, EmotionThinkMotionName } from '../../constants/emotions'
import { useLive2d } from '../../stores'
import { useLive2d, useVRM } from '../../stores'
import { useAudioContext, useSpeakingStore } from '../../stores/audio'
import { useChatStore } from '../../stores/chat'
import { useSpeechStore } from '../../stores/modules/speech'
@@ -46,6 +46,8 @@ const { mouthOpenSize } = storeToRefs(useSpeakingStore())
const { audioContext, calculateVolume } = useAudioContext()
const { onBeforeMessageComposed, onBeforeSend, onTokenLiteral, onTokenSpecial, onStreamEnd, onAssistantResponseEnd } = useChatStore()
const providersStore = useProvidersStore()
const { modelFile, modelUrl } = storeToRefs(useLive2d())
const { modelFile: vrmModelFile, modelUrl: vrmModelUrl } = storeToRefs(useVRM())
const audioAnalyser = ref<AnalyserNode>()
const nowSpeaking = ref(false)
@@ -230,9 +232,11 @@ onMounted(async () => {
<div h-full w-full>
<Live2DScene
v-if="stageView === '2d'"
min-w="50% <lg:full" min-h="100 sm:100" h-full w-full flex-1
:model-src="modelUrl"
:model-file="modelFile"
:focus-at="focusAt"
:mouth-open-size="mouthOpenSize"
min-w="50% <lg:full" min-h="100 sm:100" h-full w-full flex-1
:paused="paused"
:x-offset="xOffset"
:y-offset="yOffset"
@@ -242,7 +246,8 @@ onMounted(async () => {
<VRMScene
v-else-if="stageView === '3d'"
ref="vrmViewerRef"
model="/assets/vrm/models/AvatarSample-B/AvatarSample_B.vrm"
:model-src="vrmModelUrl"
:model-file="vrmModelFile"
idle-animation="/assets/vrm/animations/idle_loop.vrma"
min-w="50% <lg:full" min-h="100 sm:100" h-full w-full flex-1
:paused="paused"
@@ -9,6 +9,11 @@ import * as THREE from 'three'
import { useVRM } from '../../stores'
import { OrbitControls, VRMModel } from '../Scenes'
const props = defineProps<{
modelSrc?: string
modelFile?: File | null
}>()
const emit = defineEmits<{
(e: 'loadModelProgress', value: number): void
(e: 'error', value: unknown): void
@@ -19,7 +24,6 @@ const { x: mouseX, y: mouseY } = useMouse()
const vrmContainerRef = ref<HTMLDivElement>()
const { width, height } = useElementBounding(vrmContainerRef)
const {
selectedModel,
cameraFOV,
cameraPosition,
cameraDistance,
@@ -153,22 +157,28 @@ function lookAtCamera(newPosition: { x: number, y: number, z: number }) {
function lookAtMouse(mouseX: number, mouseY: number) {
mouse.x = (mouseX / window.innerWidth) * 2 - 1
mouse.y = -(mouseY / window.innerHeight) * 2 + 1
// Raycast from the mouse position
raycaster.setFromCamera(mouse, camera.value)
// Create a plane in front of the camera
const cameraDirection = new THREE.Vector3()
camera.value.getWorldDirection(cameraDirection) // Get camera's forward direction
const plane = new THREE.Plane()
plane.setFromNormalAndCoplanarPoint(
cameraDirection,
camera.value.position.clone().add(cameraDirection.multiplyScalar(1)), // 1 unit in front of the camera
)
const intersection = new THREE.Vector3()
raycaster.ray.intersectPlane(plane, intersection)
lookAtTarget.value = { x: intersection.x, y: intersection.y, z: intersection.z }
// Pass the target to the model
modelRef.value?.lookAtUpdate(lookAtTarget.value)
}
watch(cameraPosition, (newPosition) => {
if (!sceneReady.value || !modelRef.value)
return
@@ -176,6 +186,7 @@ watch(cameraPosition, (newPosition) => {
lookAtCamera(newPosition)
}
}, { deep: true })
watch([mouseX, mouseY], () => {
if (!sceneReady.value || !modelRef.value)
return
@@ -183,6 +194,7 @@ watch([mouseX, mouseY], () => {
lookAtMouse(mouseX.value, mouseY.value)
}
})
watch(trackingMode, (newMode) => {
if (!sceneReady.value || !modelRef.value)
return
@@ -210,14 +222,14 @@ defineExpose({
<template>
<div ref="vrmContainerRef" w="100%" h="100%">
<TresCanvas v-if="camera" v-show="sceneReady" :camera="camera" :alpha="true" :antialias="true" :width="width" :height="height">
<TresCanvas v-if="camera" v-show="sceneReady" :camera="camera" :antialias="true" :width="width" :height="height">
<OrbitControls ref="controlsRef" />
<TresDirectionalLight :color="0xFFFFFF" :intensity="1.8" :position="[1, 1, -10]" />
<TresAmbientLight :color="0xFFFFFF" :intensity="1.2" />
<VRMModel
ref="modelRef"
:key="selectedModel"
:model="selectedModel"
:model-src="props.modelSrc"
:model-file="props.modelFile"
idle-animation="/assets/vrm/animations/idle_loop.vrma"
:paused="false"
@load-model-progress="(val) => emit('loadModelProgress', val)"
@@ -4,9 +4,10 @@ import type { AnimationClip, Group } from 'three'
import { VRMUtils } from '@pixiv/three-vrm'
import { useLoop, useTresContext } from '@tresjs/core'
import { until, useObjectUrl } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { AnimationMixer, MathUtils, Quaternion, Vector3, VectorKeyframeTrack } from 'three'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, toRef, watch } from 'vue'
import { clipFromVRMAnimation, loadVRMAnimation, useBlink, useIdleEyeSaccades } from '../../../composables/vrm/animation'
import { loadVrm } from '../../../composables/vrm/core'
@@ -14,7 +15,9 @@ import { useVRMEmote } from '../../../composables/vrm/expression'
import { useVRM } from '../../../stores'
const props = defineProps<{
model: string
modelSrc?: string
modelFile?: File | null
idleAnimation: string
loadAnimations?: string[]
paused: boolean
@@ -28,6 +31,20 @@ const emit = defineEmits<{
let disposeBeforeRenderLoop: (() => void | undefined)
const modelCreating = ref(false)
const modelSrcRef = toRef(() => props.modelSrc)
const modelFileRef = toRef(() => props.modelFile)
const modelFileSrc = useObjectUrl(modelFileRef)
const modelSrcNormalized = computed(() => {
if (modelFileSrc.value)
return modelFileSrc.value
if (modelSrcRef.value)
return modelSrcRef.value
return ''
})
const vrm = ref<VRMCore>()
const vrmAnimationMixer = ref<AnimationMixer>()
const { scene } = useTresContext()
@@ -41,7 +58,6 @@ const {
modelOrigin,
modelSize,
cameraPosition,
loadingModel,
modelRotationY,
lookAtTarget,
eyeHeight,
@@ -50,158 +66,170 @@ const {
const vrmGroup = ref<Group>()
const idleEyeSaccades = useIdleEyeSaccades()
onMounted(async () => {
if (!scene.value) {
console.warn('Scene is not ready, cannot load VRM model.')
return
}
async function loadModel() {
await until(modelCreating).not.toBeTruthy()
modelCreating.value = true
try {
const _vrmInfo = await loadVrm(props.model, {
scene: scene.value,
lookAt: true,
positionOffset: [modelOffset.value.x, modelOffset.value.y, modelOffset.value.z],
onProgress: progress => emit('loadModelProgress', Number((100 * progress.loaded / progress.total).toFixed(2))),
})
if (!_vrmInfo || !_vrmInfo._vrm) {
console.warn('No VRM model loaded')
if (!scene.value) {
console.warn('Scene is not ready, cannot load VRM model.')
return
}
const {
_vrm,
_vrmGroup,
modelCenter: vrmModelCenter,
modelSize: vrmModelSize,
initialCameraOffset: vrmInitialCameraOffset,
} = _vrmInfo
vrmGroup.value = _vrmGroup
// Set initial camera position
cameraPosition.value = {
x: vrmModelCenter.x + vrmInitialCameraOffset.x,
y: vrmModelCenter.y + vrmInitialCameraOffset.y,
z: vrmModelCenter.z + vrmInitialCameraOffset.z,
}
// cameraDistance.value = vrmInitialCameraOffset.length()
// Set initial positions for model
modelOrigin.value = {
x: vrmModelCenter.x,
y: vrmModelCenter.y,
z: vrmModelCenter.z,
}
modelSize.value = {
x: vrmModelSize.x,
y: vrmModelSize.y,
z: vrmModelSize.z,
if (vrm.value) {
componentCleanUp()
}
// Set model facing direction
const targetDirection = new Vector3(0, 0, -1) // Default facing direction
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)
}
else {
console.warn('No look-at target found in VRM model')
}
// Reset model rotation Y
modelRotationY.value = 0
// Set initial positions for animation
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 animeDelta = 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] -= animeDelta.x
track.values[i + 1] -= animeDelta.y
track.values[i + 2] -= animeDelta.z
}
}
try {
const _vrmInfo = await loadVrm(modelSrcNormalized.value, {
scene: scene.value,
lookAt: true,
positionOffset: [modelOffset.value.x, modelOffset.value.y, modelOffset.value.z],
onProgress: progress => emit('loadModelProgress', Number((100 * progress.loaded / progress.total).toFixed(2))),
})
if (!_vrmInfo || !_vrmInfo._vrm) {
console.warn('No VRM model loaded')
return
}
const {
_vrm,
_vrmGroup,
modelCenter: vrmModelCenter,
modelSize: vrmModelSize,
initialCameraOffset: vrmInitialCameraOffset,
} = _vrmInfo
vrmGroup.value = _vrmGroup
// Set initial camera position
cameraPosition.value = {
x: vrmModelCenter.x + vrmInitialCameraOffset.x,
y: vrmModelCenter.y + vrmInitialCameraOffset.y,
z: vrmModelCenter.z + vrmInitialCameraOffset.z,
}
// cameraDistance.value = vrmInitialCameraOffset.length()
// Set initial positions for model
modelOrigin.value = {
x: vrmModelCenter.x,
y: vrmModelCenter.y,
z: vrmModelCenter.z,
}
modelSize.value = {
x: vrmModelSize.x,
y: vrmModelSize.y,
z: vrmModelSize.z,
}
// Set model facing direction
const targetDirection = new Vector3(0, 0, -1) // Default facing direction
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)
}
else {
console.warn('No look-at target found in VRM model')
}
// Reset model rotation Y
modelRotationY.value = 0
// Set initial positions for animation
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 animeDelta = 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] -= animeDelta.x
track.values[i + 1] -= animeDelta.y
track.values[i + 2] -= animeDelta.z
}
}
})
}
const animation = await loadVRMAnimation(props.idleAnimation)
const clip = await clipFromVRMAnimation(_vrm, animation)
if (!clip) {
console.warn('No VRM animation loaded')
return
}
// Reanchor the root position track to the model origin
reanchorRootPositionTrack(clip)
// play animation
vrmAnimationMixer.value = new AnimationMixer(_vrm.scene)
vrmAnimationMixer.value.clipAction(clip).play()
vrmEmote.value = useVRMEmote(_vrm)
vrm.value = _vrm
emit('modelReady')
function getEyePosition(): number | null {
const eye = vrm.value?.humanoid?.getNormalizedBoneNode('head')
if (!eye)
return null
const eyePos = new Vector3()
eye.getWorldPosition(eyePos)
return eyePos.y
}
eyeHeight.value = getEyePosition()
trackingMode.value = 'none'
lookAtTarget.value = {
x: 0,
y: eyeHeight.value,
z: -1000,
}
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, lookAtTarget, delta)
vrmEmote.value?.update(delta)
}).off
}
const animation = await loadVRMAnimation(props.idleAnimation)
const clip = await clipFromVRMAnimation(_vrm, animation)
if (!clip) {
console.warn('No VRM animation loaded')
return
catch (err) {
// This is needed otherwise the URL input will be locked forever...
emit('error', err)
}
// Reanchor the root position track to the model origin
reanchorRootPositionTrack(clip)
// play animation
vrmAnimationMixer.value = new AnimationMixer(_vrm.scene)
vrmAnimationMixer.value.clipAction(clip).play()
vrmEmote.value = useVRMEmote(_vrm)
vrm.value = _vrm
loadingModel.value = false
emit('modelReady')
function getEyePosition(): number | null {
const eye = vrm.value?.humanoid?.getNormalizedBoneNode('head')
if (!eye)
return null
const eyePos = new Vector3()
eye.getWorldPosition(eyePos)
return eyePos.y
}
eyeHeight.value = getEyePosition()
trackingMode.value = 'none'
lookAtTarget.value = {
x: 0,
y: eyeHeight.value,
z: -1000,
}
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, lookAtTarget, delta)
vrmEmote.value?.update(delta)
}).off
}
catch (err) {
// This is needed otherwise the URL input will be locked forever...
loadingModel.value = false
emit('error', err)
console.error(err)
}
})
finally {
modelCreating.value = false
}
}
watch(modelOffset, () => {
if (vrmGroup.value) {
@@ -218,6 +246,33 @@ watch(modelRotationY, (newRotationY) => {
vrmGroup.value.rotation.y = MathUtils.degToRad(newRotationY)
}
})
watch(modelSrcNormalized, (newSrc) => {
if (newSrc) {
loadModel()
}
})
const { pause, resume } = useLoop()
watch(() => props.paused, value => value ? pause() : resume())
function componentCleanUp() {
disposeBeforeRenderLoop?.()
if (vrm.value) {
vrm.value.scene.removeFromParent()
VRMUtils.deepDispose(vrm.value.scene)
}
}
onMounted(async () => await loadModel())
onUnmounted(() => componentCleanUp())
if (import.meta.hot) {
// Ensure cleanup on HMR
import.meta.hot.dispose(() => {
componentCleanUp()
})
}
defineExpose({
setExpression(expression: string) {
@@ -228,31 +283,6 @@ defineExpose({
idleEyeSaccades.instantUpdate(vrm.value, target)
},
})
const { pause, resume } = useLoop()
watch(() => props.paused, (value) => {
value ? pause() : resume()
})
function componentCleanUp() {
disposeBeforeRenderLoop?.()
if (vrm.value) {
vrm.value.scene.removeFromParent()
VRMUtils.deepDispose(vrm.value.scene)
}
}
onUnmounted(() => {
componentCleanUp()
})
if (import.meta.hot) {
// Ensure cleanup on HMR
import.meta.hot.dispose(() => {
componentCleanUp()
})
}
</script>
<template>
+7 -5
View File
@@ -1,11 +1,14 @@
import { useLocalStorage } from '@vueuse/core'
import localforage from 'localforage'
import { computedAsync, useLocalStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export const useLive2d = defineStore('live2d', () => {
const modelFile = ref<File>()
const modelUrl = ref<string>('/assets/live2d/models/hiyori_pro_zh.zip')
const loadSource = ref<'file' | 'url'>('url')
const defaultModelUrl = '/assets/live2d/models/hiyori_pro_zh.zip'
const modelUrl = useLocalStorage<string>('settings/live2d/model-src', defaultModelUrl)
const modelFile = computedAsync(async () => localforage.getItem<File>('assets-models-live2d'))
const loadingModel = ref(false) // if set to true, the model will be loaded
const position = useLocalStorage('settings/live2d/position', { x: 0, y: 0 }) // position is relative to the center of the screen, units are %
const positionInPercentageString = computed(() => ({
@@ -20,7 +23,6 @@ export const useLive2d = defineStore('live2d', () => {
return {
modelFile,
modelUrl,
loadSource,
loadingModel,
position,
positionInPercentageString,
+28 -33
View File
@@ -1,17 +1,39 @@
import localforage from 'localforage'
import { useLocalStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { computed, onMounted, ref } from 'vue'
export const useVRM = defineStore('vrm', () => {
const modelFile = ref<File>()
const indexedDbModelFile = ref<File | null>(null)
onMounted(async () => {
const file = await localforage.getItem<File>('assets-models-vrm')
if (file) {
indexedDbModelFile.value = file
}
})
const modelFile = computed({
get: () => {
return indexedDbModelFile.value
},
set: (file: File | null) => {
if (file) {
localforage.setItem('assets-models-vrm', file)
}
else {
localforage.removeItem('assets-models-vrm')
}
indexedDbModelFile.value = file
},
})
const defaultModelUrl = '/assets/vrm/models/AvatarSample-B/AvatarSample_B.vrm'
const modelUrl = useLocalStorage('settings/vrm/modelURL', defaultModelUrl)
const loadSource = ref<'file' | 'url'>('url')
const loadingModel = ref(false)
const scale = useLocalStorage('settings/live2d/cameraScale', 1)
const scale = useLocalStorage('settings/vrm/cameraScale', 1)
const modelSize = useLocalStorage('settings/vrm/modelSize', { x: 0, y: 0, z: 0 })
const modelOrigin = useLocalStorage('settings/vrm/modelOrigin', { x: 0, y: 0, z: 0 })
const modelOffset = useLocalStorage('settings/vrm/modelOffset', { x: 0, y: 0, z: 0 })
@@ -19,7 +41,6 @@ export const useVRM = defineStore('vrm', () => {
const cameraFOV = useLocalStorage('settings/vrm/cameraFOV', 40)
const cameraPosition = useLocalStorage('settings/vrm/camera-position', { x: 0, y: 0, z: -1 })
const modelObjectUrl = ref<string>()
const modelRotationY = useLocalStorage('settings/vrm/modelRotationY', 0)
const cameraDistance = useLocalStorage('settings/vrm/cameraDistance', 0)
@@ -28,39 +49,13 @@ export const useVRM = defineStore('vrm', () => {
const lookAtTarget = useLocalStorage('settings/vrm/lookAtTarget', { x: 0, y: 0, z: 0 })
const eyeHeight = useLocalStorage('settings/vrm/eyeHeight', 0)
// Manage the object URL lifecycle to prevent memory leaks
watch(modelFile, (newFile) => {
if (modelObjectUrl.value) {
URL.revokeObjectURL(modelObjectUrl.value)
modelObjectUrl.value = undefined
}
if (newFile) {
modelObjectUrl.value = URL.createObjectURL(newFile)
}
})
const selectedModel = computed(() => {
if (loadSource.value === 'file' && modelObjectUrl.value) {
return modelObjectUrl.value
}
if (loadSource.value === 'url' && modelUrl.value) {
return modelUrl.value
}
// Fallback model
return defaultModelUrl
})
return {
modelFile,
defaultModelUrl,
modelUrl,
loadSource,
loadingModel,
modelSize,
scale,
modelOrigin,
modelOffset,
selectedModel,
cameraFOV,
cameraPosition,
modelRotationY,