refactor(stage-ui): model file & model url loading for live2d and vrm
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user