feat(stage-ui): VRM model setting: camera psotion (view scale) (#309)

This commit is contained in:
Lilia_Chen
2025-07-22 12:27:27 +08:00
committed by GitHub
parent 3af094b6be
commit 7c62bb0908
7 changed files with 83 additions and 54 deletions
@@ -566,6 +566,7 @@ vrm:
z: Z Offset
fov: FOV (degree)
rotation-y: Rotation (Y-axis)
camera-distance: Camera distance
switch-to-vrm:
title: Switch to Live2D Avatar?
change-to-vrm: Click here to switch to the Live2D avatar setting
@@ -542,6 +542,7 @@ vrm:
z: Z轴偏移
fov: 视角调整(度)
rotation-y: 模型朝向(Y轴旋转)
camera-distance: 相机距离(画面缩放)
switch-to-vrm:
title: 想切换至Live2D虚拟形象?
change-to-vrm: 切换至Live2D虚拟形象设定页面
@@ -36,6 +36,7 @@ const {
cameraFOV,
selectedModel,
modelRotationY,
cameraDistance,
} = storeToRefs(vrm)
const localModelUrl = ref(modelUrl.value)
@@ -66,6 +67,12 @@ function urlUploadClick() {
loadingModel.value = true
localModelUrl.value = selectedModel.value
}
function resetCameraDistance() {
// Calculate the camera distance that can fit the up-2/3 part of the model in the view
const radians = (cameraFOV.value / 2 * Math.PI) / 180
cameraDistance.value = (modelSize.value.y / 3) / Math.tan(radians)
}
</script>
<template>
@@ -127,51 +134,6 @@ function urlUploadClick() {
{{ 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"
@@ -281,6 +243,23 @@ function urlUploadClick() {
</div>
</template>
</FieldRange>
<FieldRange
v-model="cameraDistance"
as="div"
:min="modelSize.z"
:max="modelSize.z * 20"
:step="modelSize.z / 100"
:label="t('settings.vrm.scale-and-position.camera-distance')"
>
<template #label>
<div flex items-center>
<div>{{ t('settings.vrm.scale-and-position.camera-distance') }}</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="resetCameraDistance">
<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"
@@ -19,11 +19,13 @@ const {
selectedModel,
cameraFOV,
initialCameraPosition,
cameraDistance,
} = storeToRefs(useVRM())
const modelRef = ref<InstanceType<typeof VRMModel>>()
const camera = shallowRef(new THREE.PerspectiveCamera())
const controlsRef = ref<InstanceType<typeof OrbitControls>>()
onMounted(() => {
if (vrmContainerRef.value) {
@@ -64,6 +66,39 @@ watch(selectedModel, () => {
}
})
// Bidirectional watch between slider and OrbitControls
watch(() => controlsRef.value?.getDistance(), (newDistance) => {
if (newDistance !== undefined) {
// To avoid floating point inaccuracies causing a feedback loop with the other watcher,
// we can check if the distance has changed significantly.
if (Math.abs(cameraDistance.value - newDistance) > 1e-6) {
cameraDistance.value = newDistance
initialCameraPosition.value = {
x: camera.value.position.x,
y: camera.value.position.y,
z: camera.value.position.z,
}
}
}
})
watch(cameraDistance, (newDistance) => {
if (camera.value && controlsRef.value) {
const newPosition = new THREE.Vector3()
newPosition.copy(camera.value.position.normalize().multiplyScalar(newDistance))
camera.value.position.set(
newPosition.x,
newPosition.y,
newPosition.z,
)
controlsRef.value.update()
initialCameraPosition.value = {
x: newPosition.x,
y: newPosition.y,
z: newPosition.z,
}
}
})
defineExpose({
setExpression: (expression: string) => {
modelRef.value?.setExpression(expression)
@@ -77,6 +112,7 @@ defineExpose({
<TresAxesHelper :size="1" />
<TresDirectionalLight :color="0xFFFFFF" :intensity="1.2" :position="[1, 1, 1]" />
<TresAmbientLight :color="0xFFFFFF" :intensity="1.5" />
<OrbitControls ref="controlsRef" />
<VRMModel
ref="modelRef"
:key="selectedModel"
@@ -87,7 +123,6 @@ defineExpose({
@error="(val) => emit('error', val)"
/>
<!-- <TresPerspectiveCamera :position="[initialCameraPosition.x, initialCameraPosition.y, initialCameraPosition.z]" :fov="cameraFOV"/> -->
<OrbitControls />
</TresCanvas>
</div>
</template>
@@ -161,14 +161,6 @@ watch(modelRotationY, (newRotationY) => {
}
})
onUnmounted(() => {
disposeBeforeRenderLoop?.()
if (vrm.value) {
vrm.value.scene.removeFromParent()
VRMUtils.deepDispose(vrm.value.scene)
}
})
defineExpose({
setExpression(expression: string) {
vrmEmote.value?.setEmotionWithResetAfter(expression, 1000)
@@ -181,6 +173,14 @@ const { pause, resume } = useLoop()
watch(() => props.paused, (value) => {
value ? pause() : resume()
})
onUnmounted(() => {
disposeBeforeRenderLoop?.()
if (vrm.value) {
vrm.value.scene.removeFromParent()
VRMUtils.deepDispose(vrm.value.scene)
}
})
</script>
<template>
@@ -14,6 +14,7 @@ const { camera, renderer } = useTresContext()
const controls = shallowRef<OrbitControls>()
const {
modelSize,
cameraDistance,
} = storeToRefs(useVRM())
// initialize the OrbitControls
@@ -35,11 +36,21 @@ onMounted(() => {
}
controls.value.enablePan = false
// How near and far the camera can zoom
controls.value.minDistance = modelSize.value.z
controls.value.maxDistance = modelSize.value.z * 20
controls.value.update()
cameraDistance.value = controls.value.getDistance()
}
})
defineExpose({
controls,
getDistance: () => controls.value?.getDistance(),
update: () => controls.value?.update(),
})
</script>
<template>
+2
View File
@@ -31,6 +31,7 @@ export const useVRM = defineStore('vrm', () => {
const modelObjectUrl = ref<string>()
const modelRotationY = useLocalStorage('settings/vrm/modelRotationY', 0)
const cameraDistance = useLocalStorage('settings/vrm/cameraDistance', 0)
// Manage the object URL lifecycle to prevent memory leaks
watch(modelFile, (newFile) => {
@@ -70,5 +71,6 @@ export const useVRM = defineStore('vrm', () => {
cameraFOV,
initialCameraPosition,
modelRotationY,
cameraDistance,
}
})