feat(stage-ui): VRM model setting page: FOV and Rotation (#307)
--------- Co-authored-by: Neko <neko@ayaka.moe>
This commit is contained in:
@@ -562,8 +562,10 @@ vrm:
|
||||
title: Scale And Position
|
||||
scale: Scale
|
||||
x: X Offset
|
||||
y: Y Offset
|
||||
'y': Y Offset
|
||||
z: Z Offset
|
||||
fov: FOV (degree)
|
||||
rotation-y: Rotation (Y-axis)
|
||||
switch-to-vrm:
|
||||
title: Switch to Live2D Avatar?
|
||||
change-to-vrm: Click here to switch to the Live2D avatar setting
|
||||
|
||||
@@ -538,8 +538,10 @@ vrm:
|
||||
title: 缩放与位置
|
||||
scale: 缩放
|
||||
x: X轴偏移
|
||||
y: Y 轴偏移
|
||||
'y': Y 轴偏移
|
||||
z: Z轴偏移
|
||||
fov: 视角调整(度)
|
||||
rotation-y: 模型朝向(Y轴旋转)
|
||||
switch-to-vrm:
|
||||
title: 想切换至Live2D虚拟形象?
|
||||
change-to-vrm: 切换至Live2D虚拟形象设定页面
|
||||
|
||||
@@ -31,9 +31,11 @@ const {
|
||||
loadSource,
|
||||
loadingModel,
|
||||
modelUrl,
|
||||
scale,
|
||||
modelSize,
|
||||
modelOffset,
|
||||
cameraFOV,
|
||||
selectedModel,
|
||||
modelRotationY,
|
||||
} = storeToRefs(vrm)
|
||||
const localModelUrl = ref(modelUrl.value)
|
||||
|
||||
@@ -42,8 +44,28 @@ modelFileDialog.onChange((files) => {
|
||||
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
|
||||
}
|
||||
// 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>
|
||||
|
||||
<template>
|
||||
@@ -60,6 +82,96 @@ modelFileDialog.onChange((files) => {
|
||||
{{ t('settings.vrm.switch-to-vrm.change-to-vrm') }}
|
||||
</Button>
|
||||
</Section>
|
||||
<Section
|
||||
:title="t('settings.vrm.change-model.title')"
|
||||
icon="i-solar:magic-stick-3-bold-duotone"
|
||||
inner-class="text-sm"
|
||||
:class="[
|
||||
'rounded-xl',
|
||||
'bg-white/80 dark:bg-black/75',
|
||||
'backdrop-blur-lg',
|
||||
]"
|
||||
>
|
||||
<Button
|
||||
variant="secondary" @click=" () => {
|
||||
modelFileDialog.reset()
|
||||
modelFileDialog.open()
|
||||
}"
|
||||
>
|
||||
{{ t('settings.vrm.change-model.from-file') }}...
|
||||
</Button>
|
||||
<div flex items-center gap-2>
|
||||
<Input
|
||||
v-model="localModelUrl"
|
||||
:disabled="loadingModel"
|
||||
class="flex-1"
|
||||
:placeholder="t('settings.vrm.change-model.from-url-placeholder')"
|
||||
/>
|
||||
<Button size="sm" variant="secondary" @click="urlUploadClick">
|
||||
{{ t('settings.vrm.change-model.from-url') }}
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
<Section
|
||||
:title="t('settings.vrm.theme-color-from-model.title')"
|
||||
icon="i-solar:magic-stick-3-bold-duotone"
|
||||
inner-class="text-sm"
|
||||
:class="[
|
||||
'rounded-xl',
|
||||
'bg-white/80 dark:bg-black/75',
|
||||
'backdrop-blur-lg',
|
||||
]"
|
||||
>
|
||||
<ColorPalette class="mb-4 mt-2" :colors="palette.map(hex => ({ hex, name: hex }))" mx-auto />
|
||||
<Button variant="secondary" @click="$emit('extractColorsFromModel')">
|
||||
{{ t('settings.vrm.theme-color-from-model.button-extract.title') }}
|
||||
</Button>
|
||||
</Section>
|
||||
<Section
|
||||
:title="t('settings.vrm.change-model.title')"
|
||||
icon="i-solar:magic-stick-3-bold-duotone"
|
||||
inner-class="text-sm"
|
||||
:class="[
|
||||
'rounded-xl',
|
||||
'bg-white/80 dark:bg-black/75',
|
||||
'backdrop-blur-lg',
|
||||
]"
|
||||
>
|
||||
<Button
|
||||
variant="secondary" @click=" () => {
|
||||
modelFileDialog.reset()
|
||||
modelFileDialog.open()
|
||||
}"
|
||||
>
|
||||
{{ t('settings.vrm.change-model.from-file') }}...
|
||||
</Button>
|
||||
<div flex items-center gap-2>
|
||||
<Input
|
||||
v-model="localModelUrl"
|
||||
:disabled="loadingModel"
|
||||
class="flex-1"
|
||||
:placeholder="t('settings.vrm.change-model.from-url-placeholder')"
|
||||
/>
|
||||
<Button size="sm" variant="secondary" @click="urlUploadClick">
|
||||
{{ t('settings.vrm.change-model.from-url') }}
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
<Section
|
||||
:title="t('settings.vrm.theme-color-from-model.title')"
|
||||
icon="i-solar:magic-stick-3-bold-duotone"
|
||||
inner-class="text-sm"
|
||||
:class="[
|
||||
'rounded-xl',
|
||||
'bg-white/80 dark:bg-black/75',
|
||||
'backdrop-blur-lg',
|
||||
]"
|
||||
>
|
||||
<ColorPalette class="mb-4 mt-2" :colors="palette.map(hex => ({ hex, name: hex }))" mx-auto />
|
||||
<Button variant="secondary" @click="$emit('extractColorsFromModel')">
|
||||
{{ t('settings.vrm.theme-color-from-model.button-extract.title') }}
|
||||
</Button>
|
||||
</Section>
|
||||
<Section
|
||||
:title="t('settings.vrm.scale-and-position.title')"
|
||||
icon="i-solar:scale-bold-duotone"
|
||||
@@ -69,10 +181,7 @@ modelFileDialog.onChange((files) => {
|
||||
'backdrop-blur-lg',
|
||||
]"
|
||||
>
|
||||
<Callout
|
||||
:label="t('settings.vrm.scale-and-position.model-info-title')"
|
||||
w-full
|
||||
>
|
||||
<Callout :label="t('settings.vrm.scale-and-position.model-info-title')">
|
||||
<div>
|
||||
<div class="text-sm text-neutral-600 space-y-1">
|
||||
<div class="flex justify-between">
|
||||
@@ -93,37 +202,16 @@ modelFileDialog.onChange((files) => {
|
||||
<Callout
|
||||
theme="lime"
|
||||
label="Tips!"
|
||||
w-full
|
||||
>
|
||||
<div class="text-sm text-neutral-600 space-y-1">
|
||||
{{ t('settings.vrm.scale-and-position.tips') }}
|
||||
</div>
|
||||
</Callout>
|
||||
<FieldRange
|
||||
v-model="scale"
|
||||
as="div"
|
||||
:min="0"
|
||||
:max="5"
|
||||
:step="scale / 100"
|
||||
:label="t('settings.vrm.scale-and-position.x')"
|
||||
:format-value="val => val.toFixed(4)"
|
||||
>
|
||||
<template #label>
|
||||
<div flex items-center>
|
||||
<div>
|
||||
{{ t('settings.vrm.scale-and-position.scale') }}
|
||||
</div>
|
||||
<button px-2 text-xs outline-none title="Reset value to default" @click="() => scale = 1">
|
||||
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</FieldRange>
|
||||
<FieldRange
|
||||
v-model="modelOffset.x"
|
||||
as="div"
|
||||
:min="-modelSize.x - 10"
|
||||
:max="modelSize.x + 10"
|
||||
:min="-modelSize.x * 2"
|
||||
:max="modelSize.x * 2"
|
||||
:step="modelSize.x / 100"
|
||||
:label="t('settings.vrm.scale-and-position.x')"
|
||||
:format-value="val => val.toFixed(4)"
|
||||
@@ -142,8 +230,8 @@ modelFileDialog.onChange((files) => {
|
||||
<FieldRange
|
||||
v-model="modelOffset.y"
|
||||
as="div"
|
||||
:min="-modelSize.y - 10"
|
||||
:max="modelSize.y + 10"
|
||||
:min="-modelSize.y * 2"
|
||||
:max="modelSize.y * 2"
|
||||
:step="modelSize.y / 100"
|
||||
:label="t('settings.vrm.scale-and-position.y')"
|
||||
:format-value="val => val.toFixed(4)"
|
||||
@@ -158,10 +246,10 @@ modelFileDialog.onChange((files) => {
|
||||
</template>
|
||||
</FieldRange>
|
||||
<FieldRange
|
||||
v-model="modelOffset.z"
|
||||
v-model="modelOffset.y"
|
||||
as="div"
|
||||
:min="-modelSize.z - 10"
|
||||
:max="modelSize.z + 10"
|
||||
:min="-modelSize.z * 2"
|
||||
:max="modelSize.z * 2"
|
||||
:step="modelSize.z / 100"
|
||||
:label="t('settings.vrm.scale-and-position.z')"
|
||||
:format-value="val => val.toFixed(4)"
|
||||
@@ -175,45 +263,41 @@ modelFileDialog.onChange((files) => {
|
||||
</div>
|
||||
</template>
|
||||
</FieldRange>
|
||||
</Section>
|
||||
<Section
|
||||
:title="t('settings.vrm.change-model.title')"
|
||||
icon="i-solar:magic-stick-3-bold-duotone"
|
||||
inner-class="text-sm"
|
||||
:class="[
|
||||
'rounded-xl',
|
||||
'bg-white/80 dark:bg-black/75',
|
||||
'backdrop-blur-lg',
|
||||
]"
|
||||
>
|
||||
<Button variant="secondary" @click="modelFileDialog.open()">
|
||||
{{ t('settings.vrm.change-model.from-file') }}...
|
||||
</Button>
|
||||
<div flex items-center gap-2>
|
||||
<Input
|
||||
v-model="localModelUrl"
|
||||
:disabled="loadingModel"
|
||||
class="flex-1"
|
||||
:placeholder="t('settings.vrm.change-model.from-url-placeholder')"
|
||||
/>
|
||||
<Button size="sm" variant="secondary" @click="() => { modelUrl = localModelUrl; loadSource = 'url'; loadingModel = true }">
|
||||
{{ t('settings.vrm.change-model.from-url') }}
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
<Section
|
||||
:title="t('settings.vrm.theme-color-from-model.title')"
|
||||
icon="i-solar:magic-stick-3-bold-duotone"
|
||||
inner-class="text-sm"
|
||||
:class="[
|
||||
'rounded-xl',
|
||||
'bg-white/80 dark:bg-black/75',
|
||||
'backdrop-blur-lg',
|
||||
]"
|
||||
>
|
||||
<ColorPalette class="mb-4 mt-2" :colors="palette.map(hex => ({ hex, name: hex }))" mx-auto />
|
||||
<Button variant="secondary" @click="$emit('extractColorsFromModel')">
|
||||
{{ t('settings.vrm.theme-color-from-model.button-extract.title') }}
|
||||
</Button>
|
||||
<FieldRange
|
||||
:model-value="cameraFOV"
|
||||
as="div"
|
||||
:min="1"
|
||||
:max="180"
|
||||
:step="1"
|
||||
:label="t('settings.vrm.scale-and-position.fov')"
|
||||
@update:model-value="val => cameraFOV = val"
|
||||
>
|
||||
<template #label>
|
||||
<div flex items-center>
|
||||
<div>{{ t('settings.vrm.scale-and-position.fov') }}</div>
|
||||
<button px-2 text-xs outline-none title="Reset value to default" @click="() => cameraFOV = 40">
|
||||
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</FieldRange>
|
||||
<FieldRange
|
||||
:model-value="modelRotationY"
|
||||
as="div"
|
||||
:min="-180"
|
||||
:max="180"
|
||||
:step="1"
|
||||
:label="t('settings.vrm.scale-and-position.rotation-y')"
|
||||
@update:model-value="val => modelRotationY = val"
|
||||
>
|
||||
<template #label>
|
||||
<div flex items-center>
|
||||
<div>{{ t('settings.vrm.scale-and-position.rotation-y') }}</div>
|
||||
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelRotationY = 0">
|
||||
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</FieldRange>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
@@ -1,34 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { OrbitControls } from '@tresjs/cientos'
|
||||
import { TresCanvas } from '@tresjs/core'
|
||||
import { useElementBounding } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { onMounted, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import * as THREE from 'three'
|
||||
|
||||
import { useVRM } from '../../stores'
|
||||
import { Screen } from '../Misc'
|
||||
import { VRMModel } from '../Scenes'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
paused?: boolean
|
||||
showAxes?: boolean
|
||||
}>(), {
|
||||
paused: false,
|
||||
showAxes: false,
|
||||
})
|
||||
import { OrbitControls, VRMModel } from '../Scenes'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'loadModelProgress', value: number): void
|
||||
(e: 'error', value: unknown): void
|
||||
}>()
|
||||
const { selectedModel, scale } = storeToRefs(useVRM())
|
||||
const vrmContainerRef = ref<HTMLDivElement>()
|
||||
const { width, height } = useElementBounding(vrmContainerRef)
|
||||
const {
|
||||
selectedModel,
|
||||
cameraFOV,
|
||||
initialCameraPosition,
|
||||
} = storeToRefs(useVRM())
|
||||
|
||||
const cameraPositionX = ref(0.17)
|
||||
const cameraPositionY = ref(0.5)
|
||||
const cameraPositionZ = ref(-1)
|
||||
const modelRef = ref<InstanceType<typeof VRMModel>>()
|
||||
|
||||
const modelRef = ref<{
|
||||
setExpression: (expression: string) => void
|
||||
}>()
|
||||
const camera = shallowRef(new THREE.PerspectiveCamera())
|
||||
|
||||
onMounted(() => {
|
||||
if (vrmContainerRef.value) {
|
||||
camera.value.aspect = width.value / height.value
|
||||
camera.value.fov = cameraFOV.value
|
||||
camera.value.position.set(
|
||||
initialCameraPosition.value.x,
|
||||
initialCameraPosition.value.y,
|
||||
initialCameraPosition.value.z,
|
||||
)
|
||||
camera.value.updateProjectionMatrix()
|
||||
}
|
||||
})
|
||||
|
||||
watch(cameraFOV, (newFov) => {
|
||||
if (camera.value) {
|
||||
camera.value.fov = newFov
|
||||
camera.value.updateProjectionMatrix()
|
||||
}
|
||||
})
|
||||
|
||||
// not sure if we need this
|
||||
// watch([width, height], () => {
|
||||
// if (camera.value) {
|
||||
// camera.value.aspect = width.value / height.value
|
||||
// camera.value.updateProjectionMatrix()
|
||||
// }
|
||||
// })
|
||||
|
||||
watch(selectedModel, () => {
|
||||
if (camera.value) {
|
||||
camera.value.position.set(
|
||||
initialCameraPosition.value.x,
|
||||
initialCameraPosition.value.y,
|
||||
initialCameraPosition.value.z,
|
||||
)
|
||||
camera.value.updateProjectionMatrix()
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
setExpression: (expression: string) => {
|
||||
@@ -38,11 +72,9 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Screen v-slot="{ width, height }" relative>
|
||||
<TresCanvas :alpha="true" :antialias="true" :width="width" :height="height">
|
||||
<OrbitControls :enable-zoom="false" />
|
||||
<TresAxesHelper v-if="props.showAxes" :size="1" />
|
||||
<TresPerspectiveCamera :position="[cameraPositionX, cameraPositionY, cameraPositionZ]" :zoom="scale" />
|
||||
<div ref="vrmContainerRef" w="100%" h="100%">
|
||||
<TresCanvas v-if="camera" :camera="camera" :alpha="true" :antialias="true" :width="width" :height="height">
|
||||
<TresAxesHelper :size="1" />
|
||||
<TresDirectionalLight :color="0xFFFFFF" :intensity="1.2" :position="[1, 1, 1]" />
|
||||
<TresAmbientLight :color="0xFFFFFF" :intensity="1.5" />
|
||||
<VRMModel
|
||||
@@ -50,10 +82,12 @@ defineExpose({
|
||||
:key="selectedModel"
|
||||
:model="selectedModel"
|
||||
idle-animation="/assets/vrm/animations/idle_loop.vrma"
|
||||
:paused="props.paused"
|
||||
:paused="false"
|
||||
@load-model-progress="(val) => emit('loadModelProgress', val)"
|
||||
@error="(val) => emit('error', val)"
|
||||
/>
|
||||
<!-- <TresPerspectiveCamera :position="[initialCameraPosition.x, initialCameraPosition.y, initialCameraPosition.z]" :fov="cameraFOV"/> -->
|
||||
<OrbitControls />
|
||||
</TresCanvas>
|
||||
</Screen>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { VRMCore } from '@pixiv/three-vrm-core'
|
||||
import type { AnimationClip } from 'three'
|
||||
import type { AnimationClip, Group } from 'three'
|
||||
|
||||
import { VRMUtils } from '@pixiv/three-vrm'
|
||||
import { useLoop, useTresContext } from '@tresjs/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { AnimationMixer } from 'three'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { AnimationMixer, MathUtils, Quaternion, Vector3 } from 'three'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { clipFromVRMAnimation, loadVRMAnimation, useBlink, useIdleEyeSaccades } from '../../../composables/vrm/animation'
|
||||
import { loadVrm } from '../../../composables/vrm/core'
|
||||
@@ -41,21 +41,15 @@ const {
|
||||
modelOrigin,
|
||||
modelSize,
|
||||
position,
|
||||
scale,
|
||||
initialCameraPosition,
|
||||
loadingModel,
|
||||
modelRotationY,
|
||||
} = storeToRefs(vrmStore)
|
||||
|
||||
watch(modelOffset, () => {
|
||||
if (vrm.value) {
|
||||
vrm.value.scene.position.set(
|
||||
position.value.x,
|
||||
position.value.y,
|
||||
position.value.z,
|
||||
)
|
||||
}
|
||||
}, { deep: true })
|
||||
const vrmGroup = ref<Group>()
|
||||
|
||||
onMounted(async () => {
|
||||
if (!scene.value) {
|
||||
console.warn('Scene is not ready, cannot load VRM model.')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -66,12 +60,21 @@ onMounted(async () => {
|
||||
positionOffset: [modelOffset.value.x, modelOffset.value.y, modelOffset.value.z],
|
||||
onProgress: progress => emit('loadModelProgress', Number((100 * progress.loaded / progress.total).toFixed(2))),
|
||||
})
|
||||
if (!_vrmInfo) {
|
||||
if (!_vrmInfo || !_vrmInfo._vrm) {
|
||||
console.warn('No VRM model loaded')
|
||||
return
|
||||
}
|
||||
const {
|
||||
_vrm,
|
||||
_vrmGroup,
|
||||
modelCenter: vrmModelCenter,
|
||||
modelSize: vrmModelSize,
|
||||
initialCameraPosition: vrmInitialCameraPosition,
|
||||
} = _vrmInfo
|
||||
|
||||
const { _vrm, modelCenter: vrmModelCenter, modelSize: vrmModelSize } = _vrmInfo
|
||||
vrmGroup.value = _vrmGroup
|
||||
// Set initial camera position
|
||||
initialCameraPosition.value = vrmInitialCameraPosition
|
||||
|
||||
// Set initial positions for model
|
||||
modelOrigin.value = {
|
||||
@@ -85,6 +88,22 @@ onMounted(async () => {
|
||||
z: vrmModelSize.z,
|
||||
}
|
||||
|
||||
// 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()
|
||||
quaternion.setFromUnitVectors(facingDirection.normalize(), targetDirection.normalize())
|
||||
_vrm.scene.quaternion.premultiply(quaternion)
|
||||
_vrm.scene.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 removeRootPositionTrack(clip: AnimationClip) {
|
||||
clip.tracks = clip.tracks.filter((track) => {
|
||||
@@ -108,7 +127,8 @@ onMounted(async () => {
|
||||
vrmEmote.value = useVRMEmote(_vrm)
|
||||
|
||||
vrm.value = _vrm
|
||||
vrm.value.scene.scale.set(scale.value, scale.value, scale.value)
|
||||
|
||||
loadingModel.value = false
|
||||
|
||||
disposeBeforeRenderLoop = onBeforeRender(({ delta }) => {
|
||||
vrmAnimationMixer.value?.update(delta)
|
||||
@@ -119,10 +139,28 @@ onMounted(async () => {
|
||||
}).off
|
||||
}
|
||||
catch (err) {
|
||||
// This is needed otherwise the URL input will be locked forever...
|
||||
loadingModel.value = false
|
||||
emit('error', err)
|
||||
}
|
||||
})
|
||||
|
||||
watch(modelOffset, () => {
|
||||
if (vrmGroup.value) {
|
||||
vrmGroup.value.position.set(
|
||||
position.value.x,
|
||||
position.value.y,
|
||||
position.value.z,
|
||||
)
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
watch(modelRotationY, (newRotationY) => {
|
||||
if (vrm.value && vrmGroup.value) {
|
||||
vrmGroup.value.rotation.y = MathUtils.degToRad(newRotationY)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disposeBeforeRenderLoop?.()
|
||||
if (vrm.value) {
|
||||
@@ -135,6 +173,7 @@ defineExpose({
|
||||
setExpression(expression: string) {
|
||||
vrmEmote.value?.setEmotionWithResetAfter(expression, 1000)
|
||||
},
|
||||
scene: computed(() => vrm.value?.scene),
|
||||
})
|
||||
|
||||
const { pause, resume } = useLoop()
|
||||
@@ -142,12 +181,6 @@ const { pause, resume } = useLoop()
|
||||
watch(() => props.paused, (value) => {
|
||||
value ? pause() : resume()
|
||||
})
|
||||
|
||||
watch(scale, () => {
|
||||
if (vrm.value) {
|
||||
vrm.value.scene.scale.set(scale.value, scale.value, scale.value)
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { extend, useTresContext } from '@tresjs/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
|
||||
import { onMounted, shallowRef } from 'vue'
|
||||
|
||||
import * as THREE from 'three'
|
||||
|
||||
import { useVRM } from '../../../stores'
|
||||
|
||||
extend({ OrbitControls })
|
||||
|
||||
const { camera, renderer } = useTresContext()
|
||||
const controls = shallowRef<OrbitControls>()
|
||||
const {
|
||||
modelSize,
|
||||
} = storeToRefs(useVRM())
|
||||
|
||||
// initialize the OrbitControls
|
||||
onMounted(() => {
|
||||
if (camera.value && renderer.value?.domElement) {
|
||||
controls.value = new OrbitControls(camera.value, renderer.value.domElement)
|
||||
controls.value.enableDamping = true
|
||||
controls.value.dampingFactor = 0.5
|
||||
|
||||
// Align to tresjs conventions
|
||||
controls.value.mouseButtons = {
|
||||
LEFT: THREE.MOUSE.ROTATE,
|
||||
MIDDLE: THREE.MOUSE.DOLLY,
|
||||
RIGHT: THREE.MOUSE.PAN,
|
||||
}
|
||||
controls.value.touches = {
|
||||
ONE: THREE.TOUCH.ROTATE,
|
||||
TWO: THREE.TOUCH.DOLLY_PAN,
|
||||
}
|
||||
|
||||
controls.value.enablePan = false
|
||||
controls.value.minDistance = modelSize.value.z
|
||||
|
||||
controls.value.update()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<slot />
|
||||
</template>
|
||||
@@ -1 +1,2 @@
|
||||
export { default as VRMModel } from './Model.vue'
|
||||
export { default as OrbitControls } from './OrbitControls.vue'
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Object3D, Scene } from 'three'
|
||||
|
||||
import { VRMUtils } from '@pixiv/three-vrm'
|
||||
import { VRMLookAtQuaternionProxy } from '@pixiv/three-vrm-animation'
|
||||
import { Box3, Vector3 } from 'three'
|
||||
import { Box3, Group, Vector3 } from 'three'
|
||||
|
||||
import { useVRMLoader } from './loader'
|
||||
|
||||
@@ -16,7 +16,7 @@ export async function loadVrm(model: string, options?: {
|
||||
scene?: Scene
|
||||
lookAt?: boolean
|
||||
onProgress?: (progress: ProgressEvent<EventTarget>) => void | Promise<void>
|
||||
}): Promise<{ _vrm: VRMCore, modelCenter: Vector3, modelSize: Vector3 } | undefined> {
|
||||
}): Promise<{ _vrm: VRMCore, _vrmGroup: Group, modelCenter: Vector3, modelSize: Vector3, initialCameraPosition: Vector3 } | undefined> {
|
||||
const loader = useVRMLoader()
|
||||
const gltf = await loader.loadAsync(model, progress => options?.onProgress?.(progress))
|
||||
|
||||
@@ -43,9 +43,12 @@ export async function loadVrm(model: string, options?: {
|
||||
_vrm.scene.add(lookAtQuatProxy)
|
||||
}
|
||||
|
||||
const _vrmGroup = new Group()
|
||||
_vrmGroup.add(_vrm.scene)
|
||||
// Add to scene
|
||||
if (options?.scene)
|
||||
options.scene.add(_vrm.scene)
|
||||
if (options?.scene) {
|
||||
options.scene.add(_vrmGroup)
|
||||
}
|
||||
|
||||
// Move the VRM model centre to the (0, 0, 0)
|
||||
const box = new Box3().setFromObject(_vrm.scene)
|
||||
@@ -54,23 +57,35 @@ export async function loadVrm(model: string, options?: {
|
||||
box.getSize(modelSize)
|
||||
box.getCenter(modelCenter)
|
||||
modelCenter.negate()
|
||||
modelCenter.y -= modelSize.y / 8 // 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) {
|
||||
_vrm.scene.position.set(
|
||||
_vrmGroup.position.set(
|
||||
modelCenter.x + options.positionOffset[0],
|
||||
modelCenter.y + options.positionOffset[1],
|
||||
modelCenter.z + options.positionOffset[2],
|
||||
)
|
||||
}
|
||||
else {
|
||||
_vrm.scene.position.set(modelCenter.x, modelCenter.y, modelCenter.z)
|
||||
_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(
|
||||
modelSize.x / 16,
|
||||
modelSize.y / 6,
|
||||
-(modelSize.y / 3) / Math.tan(radians), // default z value
|
||||
)
|
||||
|
||||
return {
|
||||
_vrm,
|
||||
_vrmGroup,
|
||||
modelCenter,
|
||||
modelSize,
|
||||
initialCameraPosition,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import { computed, ref, watch } from 'vue'
|
||||
|
||||
export const useVRM = defineStore('vrm', () => {
|
||||
const modelFile = ref<File>()
|
||||
const modelUrl = ref<string>('/assets/vrm/models/AvatarSample-B/AvatarSample_B.vrm')
|
||||
|
||||
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)
|
||||
|
||||
@@ -23,8 +26,11 @@ export const useVRM = defineStore('vrm', () => {
|
||||
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 })
|
||||
|
||||
const modelObjectUrl = ref<string>()
|
||||
const modelRotationY = useLocalStorage('settings/vrm/modelRotationY', 0)
|
||||
|
||||
// Manage the object URL lifecycle to prevent memory leaks
|
||||
watch(modelFile, (newFile) => {
|
||||
@@ -37,7 +43,6 @@ export const useVRM = defineStore('vrm', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Centralized computed property for the model source
|
||||
const selectedModel = computed(() => {
|
||||
if (loadSource.value === 'file' && modelObjectUrl.value) {
|
||||
return modelObjectUrl.value
|
||||
@@ -46,11 +51,12 @@ export const useVRM = defineStore('vrm', () => {
|
||||
return modelUrl.value
|
||||
}
|
||||
// Fallback model
|
||||
return '/assets/vrm/models/AvatarSample-B/AvatarSample_B.vrm'
|
||||
return defaultModelUrl
|
||||
})
|
||||
|
||||
return {
|
||||
modelFile,
|
||||
defaultModelUrl,
|
||||
modelUrl,
|
||||
loadSource,
|
||||
loadingModel,
|
||||
@@ -60,6 +66,9 @@ export const useVRM = defineStore('vrm', () => {
|
||||
modelOffset,
|
||||
position,
|
||||
positionInPercentageString,
|
||||
selectedModel, // Expose the new computed property
|
||||
selectedModel,
|
||||
cameraFOV,
|
||||
initialCameraPosition,
|
||||
modelRotationY,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user