feat(stage-ui): VRM model postioning functionalities (#302)

This commit is contained in:
Lilia_Chen
2025-07-19 22:39:32 +08:00
committed by GitHub
parent d434c9486f
commit 1a2a790ee5
8 changed files with 207 additions and 33 deletions
+12 -4
View File
@@ -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)
+28 -6
View File
@@ -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,
}
}
+15 -4
View File
@@ -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
}