feat(stage-ui): VRM model postioning functionalities (#302)
This commit is contained in:
@@ -57,8 +57,8 @@ live2d:
|
||||
x: X
|
||||
'y': 'Y'
|
||||
switch-to-vrm:
|
||||
title: Switch to 3D Avator?
|
||||
change-to-vrm: Click here to switch to the 3D avator setting (VRM)
|
||||
title: Switch to 3D Avatar?
|
||||
change-to-vrm: Click here to switch to the 3D avatar setting (VRM)
|
||||
theme-color-from-model:
|
||||
title: Extract colors from model
|
||||
button-extract:
|
||||
@@ -549,10 +549,18 @@ vrm:
|
||||
title: Change Model
|
||||
title: VRM Settings
|
||||
scale-and-position:
|
||||
model-info-title: Model Size Information
|
||||
model-info-x: Width (X)
|
||||
model-info-y: Height (Y)
|
||||
model-info-z: Depth (Z)
|
||||
tips: |
|
||||
Edit the initial position the VRM model.
|
||||
Coordinate axes are visualised.
|
||||
title: Scale And Position
|
||||
scale: Scale
|
||||
x: X
|
||||
'y': 'Y'
|
||||
x: X Offset
|
||||
y: Y Offset
|
||||
z: Z Offset
|
||||
switch-to-vrm:
|
||||
title: Switch to Live2D Avatar?
|
||||
change-to-vrm: Click here to switch to the Live2D avatar setting
|
||||
|
||||
@@ -525,10 +525,18 @@ vrm:
|
||||
title: 更换模型
|
||||
title: VRM 设置
|
||||
scale-and-position:
|
||||
model-info-title: 模型尺寸信息
|
||||
model-info-x: 宽度(X轴)
|
||||
model-info-y: 高度(Y轴)
|
||||
model-info-z: 深度(Z轴)
|
||||
tips: |
|
||||
设定模型放置的空间坐标。
|
||||
坐标轴已被可视化。
|
||||
title: 缩放与位置
|
||||
scale: 缩放
|
||||
x: X
|
||||
'y': 'Y'
|
||||
x: X轴偏移
|
||||
y: Y 轴偏移
|
||||
z: Z轴偏移
|
||||
switch-to-vrm:
|
||||
title: 想切换至Live2D虚拟形象?
|
||||
change-to-vrm: 切换至Live2D虚拟形象设定页面
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { Input } from '@proj-airi/ui'
|
||||
import { FieldRange, Input } from '@proj-airi/ui'
|
||||
import { useFileDialog } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useVRM } from '../../../../stores'
|
||||
import { Section } from '../../../Layouts'
|
||||
import { Callout, Section } from '../../../Layouts'
|
||||
import { Button } from '../../../Misc'
|
||||
import { ColorPalette } from '../../../Widgets'
|
||||
|
||||
@@ -31,6 +31,8 @@ const {
|
||||
loadSource,
|
||||
loadingModel,
|
||||
modelUrl,
|
||||
modelSize,
|
||||
modelOffset,
|
||||
} = storeToRefs(vrm)
|
||||
const localModelUrl = ref(modelUrl.value)
|
||||
|
||||
@@ -97,4 +99,96 @@ modelFileDialog.onChange((files) => {
|
||||
{{ 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"
|
||||
:class="[
|
||||
'rounded-xl',
|
||||
'bg-white/80 dark:bg-black/75',
|
||||
'backdrop-blur-lg',
|
||||
]"
|
||||
>
|
||||
<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">
|
||||
<span>{{ t('settings.vrm.scale-and-position.model-info-x') }}</span>
|
||||
<span>{{ modelSize.x.toFixed(4) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ t('settings.vrm.scale-and-position.model-info-y') }}</span>
|
||||
<span>{{ modelSize.y.toFixed(4) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ t('settings.vrm.scale-and-position.model-info-z') }}</span>
|
||||
<span>{{ modelSize.z.toFixed(4) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Callout>
|
||||
<Callout
|
||||
theme="lime"
|
||||
label="Tips!"
|
||||
>
|
||||
<div class="text-sm text-neutral-600 space-y-1">
|
||||
{{ t('settings.vrm.scale-and-position.tips') }}
|
||||
</div>
|
||||
</Callout>
|
||||
<FieldRange
|
||||
:model-value="Number(modelOffset.x.toFixed(4))"
|
||||
as="div"
|
||||
:min="-modelSize.x"
|
||||
:max="modelSize.x"
|
||||
:step="modelSize.x / 100"
|
||||
:label="t('settings.vrm.scale-and-position.x')"
|
||||
@update:model-value="val => modelOffset.x = val"
|
||||
>
|
||||
<template #label>
|
||||
<div flex items-center>
|
||||
<div>
|
||||
{{ t('settings.vrm.scale-and-position.x') }}
|
||||
</div>
|
||||
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelOffset.x = 0">
|
||||
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</FieldRange>
|
||||
<FieldRange
|
||||
:model-value="Number(modelOffset.y.toFixed(4))"
|
||||
as="div"
|
||||
:min="-modelSize.y"
|
||||
:max="modelSize.y"
|
||||
:step="modelSize.y / 100"
|
||||
:label="t('settings.vrm.scale-and-position.y')"
|
||||
@update:model-value="val => modelOffset.y = val"
|
||||
>
|
||||
<template #label>
|
||||
<div flex items-center>
|
||||
<div>{{ t('settings.vrm.scale-and-position.y') }}</div>
|
||||
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelOffset.y = 0">
|
||||
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</FieldRange>
|
||||
<FieldRange
|
||||
:model-value="Number(modelOffset.z.toFixed(4))"
|
||||
as="div"
|
||||
:min="-modelSize.z"
|
||||
:max="modelSize.z"
|
||||
:step="modelSize.z / 100"
|
||||
:label="t('settings.vrm.scale-and-position.z')"
|
||||
@update:model-value="val => modelOffset.z = val"
|
||||
>
|
||||
<template #label>
|
||||
<div flex items-center>
|
||||
<div>{{ t('settings.vrm.scale-and-position.z') }}</div>
|
||||
<button px-2 text-xs outline-none title="Reset value to default" @click="() => modelOffset.z = 0">
|
||||
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</FieldRange>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
@@ -16,12 +16,9 @@ const VRMContainerRef = ref<HTMLDivElement>()
|
||||
const { width, height } = useElementBounding(VRMContainerRef)
|
||||
const { selectedModel } = storeToRefs(useVRM())
|
||||
|
||||
const cameraPositionX = ref(-0.17)
|
||||
const cameraPositionY = ref(0)
|
||||
const cameraPositionX = ref(0.17)
|
||||
const cameraPositionY = ref(0.5)
|
||||
const cameraPositionZ = ref(-1)
|
||||
const vrmModelPositionX = ref(-0.18)
|
||||
const vrmModelPositionY = ref(-1.32)
|
||||
const vrmModelPositionZ = ref(-0.24)
|
||||
|
||||
const modelRef = ref<{
|
||||
setExpression: (expression: string) => void
|
||||
@@ -38,6 +35,7 @@ defineExpose({
|
||||
<div ref="VRMContainerRef" w="100%" h="100%">
|
||||
<TresCanvas :alpha="true" :antialias="true" :width="width" :height="height">
|
||||
<OrbitControls />
|
||||
<TresAxesHelper :size="1" />
|
||||
<TresPerspectiveCamera :position="[cameraPositionX, cameraPositionY, cameraPositionZ]" />
|
||||
<TresDirectionalLight :color="0xFFFFFF" :intensity="1.2" :position="[1, 1, 1]" />
|
||||
<TresAmbientLight :color="0xFFFFFF" :intensity="1.5" />
|
||||
@@ -46,7 +44,6 @@ defineExpose({
|
||||
:key="selectedModel"
|
||||
:model="selectedModel"
|
||||
idle-animation="/assets/vrm/animations/idle_loop.vrma"
|
||||
:position="[vrmModelPositionX, vrmModelPositionY, vrmModelPositionZ]"
|
||||
:paused="false"
|
||||
@load-model-progress="(val) => emit('loadModelProgress', val)"
|
||||
@error="(val) => emit('error', val)"
|
||||
|
||||
@@ -57,7 +57,6 @@ defineExpose({
|
||||
ref="modelRef"
|
||||
:model="selectedModel"
|
||||
idle-animation="/assets/vrm/animations/idle_loop.vrma"
|
||||
:position="[vrmModelPositionX, vrmModelPositionY, vrmModelPositionZ]"
|
||||
:paused="props.paused"
|
||||
@load-model-progress="(val) => emit('loadModelProgress', val)"
|
||||
@error="(val) => emit('error', val)"
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import type { VRMCore } from '@pixiv/three-vrm-core'
|
||||
import type { AnimationClip } from 'three'
|
||||
|
||||
import { VRMUtils } from '@pixiv/three-vrm'
|
||||
import { useVRM } from '../../../stores'
|
||||
import { useLoop, useTresContext } from '@tresjs/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { AnimationMixer } from 'three'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
@@ -14,7 +17,6 @@ const props = defineProps<{
|
||||
model: string
|
||||
idleAnimation: string
|
||||
loadAnimations?: string[]
|
||||
position: [number, number, number]
|
||||
paused: boolean
|
||||
}>()
|
||||
|
||||
@@ -33,11 +35,23 @@ const blink = useBlink()
|
||||
const idleEyeSaccades = useIdleEyeSaccades()
|
||||
const vrmEmote = ref<ReturnType<typeof useVRMEmote>>()
|
||||
|
||||
watch(() => props.position, ([x, y, z]) => {
|
||||
const vrmStore = useVRM()
|
||||
const {
|
||||
modelOffset,
|
||||
modelOrigin,
|
||||
modelSize,
|
||||
modelPosition,
|
||||
} = storeToRefs(vrmStore)
|
||||
|
||||
watch(modelOffset, () => {
|
||||
if (vrm.value) {
|
||||
vrm.value.scene.position.set(x, y, z)
|
||||
vrm.value.scene.position.set(
|
||||
modelPosition.value.x,
|
||||
modelPosition.value.y,
|
||||
modelPosition.value.z,
|
||||
)
|
||||
}
|
||||
})
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(async () => {
|
||||
if (!scene.value) {
|
||||
@@ -45,16 +59,36 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const _vrm = await loadVrm(props.model, {
|
||||
const _vrmInfo = await loadVrm(props.model, {
|
||||
scene: scene.value,
|
||||
lookAt: true,
|
||||
position: props.position,
|
||||
onProgress: progress => emit('loadModelProgress', Number.parseFloat((100.0 * (progress.loaded / progress.total)).toFixed(2))),
|
||||
positionOffset: [modelOffset.value.x, modelOffset.value.y, modelOffset.value.z],
|
||||
onProgress: progress => emit('loadModelProgress', Number((100 * progress.loaded / progress.total).toFixed(2))),
|
||||
})
|
||||
if (!_vrm) {
|
||||
if (!_vrmInfo) {
|
||||
console.warn('No VRM model loaded')
|
||||
return
|
||||
}
|
||||
const { _vrm, modelCenter: vrmModelCenter, modelSize: vrmModelSize } = _vrmInfo
|
||||
|
||||
// Set initial postions 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 initial positons for animation
|
||||
function removeRootPositionTrack(clip: AnimationClip) {
|
||||
clip.tracks = clip.tracks.filter((track) => {
|
||||
return !track.name.endsWith('.position')
|
||||
})
|
||||
}
|
||||
|
||||
const animation = await loadVRMAnimation(props.idleAnimation)
|
||||
const clip = await clipFromVRMAnimation(_vrm, animation)
|
||||
@@ -62,6 +96,7 @@ onMounted(async () => {
|
||||
console.warn('No VRM animation loaded')
|
||||
return
|
||||
}
|
||||
removeRootPositionTrack(clip)
|
||||
|
||||
// play animation
|
||||
vrmAnimationMixer.value = new AnimationMixer(_vrm.scene)
|
||||
|
||||
@@ -3,6 +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 { useVRMLoader } from './loader'
|
||||
|
||||
@@ -11,11 +12,11 @@ interface GLTFUserdata extends Record<string, any> {
|
||||
}
|
||||
|
||||
export async function loadVrm(model: string, options?: {
|
||||
position?: [number, number, number]
|
||||
positionOffset?: [number, number, number]
|
||||
scene?: Scene
|
||||
lookAt?: boolean
|
||||
onProgress?: (progress: ProgressEvent<EventTarget>) => void | Promise<void>
|
||||
}): Promise<VRMCore | undefined> {
|
||||
}): Promise<{ _vrm: VRMCore, modelCenter: Vector3, modelSize: Vector3 } | undefined> {
|
||||
const loader = useVRMLoader()
|
||||
const gltf = await loader.loadAsync(model, progress => options?.onProgress?.(progress))
|
||||
|
||||
@@ -46,9 +47,30 @@ export async function loadVrm(model: string, options?: {
|
||||
if (options?.scene)
|
||||
options.scene.add(_vrm.scene)
|
||||
|
||||
// Set position
|
||||
if (options?.position)
|
||||
_vrm.scene.position.set(...options.position)
|
||||
// Move the VRM model centre to the (0, 0, 0)
|
||||
const box = new Box3().setFromObject(_vrm.scene)
|
||||
const modelSize = new Vector3()
|
||||
const modelCenter = new Vector3()
|
||||
box.getSize(modelSize)
|
||||
box.getCenter(modelCenter)
|
||||
modelCenter.negate()
|
||||
modelCenter.y -= modelSize.y / 8 // Adjust pivot to align chest with the origin
|
||||
|
||||
return _vrm
|
||||
// Set position
|
||||
if (options?.positionOffset) {
|
||||
_vrm.scene.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)
|
||||
}
|
||||
|
||||
return {
|
||||
_vrm,
|
||||
modelCenter,
|
||||
modelSize,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,18 @@ export const useVRM = defineStore('vrm', () => {
|
||||
const modelUrl = ref<string>('/assets/vrm/models/AvatarSample-B/AvatarSample_B.vrm')
|
||||
const loadSource = ref<'file' | 'url'>('url')
|
||||
const loadingModel = ref(false)
|
||||
const position = useLocalStorage('settings/vrm/position', { x: 0, y: 0 })
|
||||
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 })
|
||||
const modelPosition = computed(() => ({
|
||||
x: modelOrigin.value.x + modelOffset.value.x,
|
||||
y: modelOrigin.value.y + modelOffset.value.y,
|
||||
z: modelOrigin.value.z + modelOffset.value.z,
|
||||
}))
|
||||
const positionInPercentageString = computed(() => ({
|
||||
x: `${position.value.x}%`,
|
||||
y: `${position.value.y}%`,
|
||||
x: `${modelPosition.value.x}%`,
|
||||
y: `${modelPosition.value.y}%`,
|
||||
z: `${modelPosition.value.z}%`,
|
||||
}))
|
||||
|
||||
const modelObjectUrl = ref<string>()
|
||||
@@ -43,7 +51,10 @@ export const useVRM = defineStore('vrm', () => {
|
||||
modelUrl,
|
||||
loadSource,
|
||||
loadingModel,
|
||||
position,
|
||||
modelSize,
|
||||
modelOrigin,
|
||||
modelOffset,
|
||||
modelPosition,
|
||||
positionInPercentageString,
|
||||
selectedModel, // Expose the new computed property
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user