feat: 3D VRM supported as scene

This commit is contained in:
Neko Ayaka
2024-12-05 02:50:15 +08:00
parent 3b95add3b2
commit 5738c219b5
11 changed files with 765 additions and 16 deletions
+1
View File
@@ -23,3 +23,4 @@ node_modules
coverage/
**/public/assets/js/*
**/public/assets/live2d/models/*
**/public/assets/vrm/models/*
+1
View File
@@ -25,6 +25,7 @@ words:
- cubismviewmatrix
- demi
- elevenlabs
- gltf
- hiyori
- iconify
- intlify
@@ -0,0 +1,35 @@
<script lang="ts" setup>
import { watchEffect } from 'vue'
import TransitionVertical from './TransitionVertical.vue'
const props = defineProps<{
default?: boolean
label?: string
}>()
const isVisible = defineModel<boolean>({ default: false })
watchEffect(() => {
if (props.default != null) {
isVisible.value = !!props.default
}
})
</script>
<template>
<div flex="~ col" border="~ gray/25 rounded-lg" divide="y dashed gray/25" of-clip shadow-sm>
<button
sticky top-0 z-10 flex items-center justify-between px2 py1 text-sm backdrop-blur-xl
@click="isVisible = !isVisible"
>
<span>
<slot name="label">
{{ props.label ?? 'Collapsable' }}
</slot>
</span> <span op50>{{ isVisible ? '▲' : '▼' }}</span>
</button>
<TransitionVertical>
<div v-if="isVisible" w-full>
<slot />
</div>
</TransitionVertical>
</div>
</template>
@@ -0,0 +1,204 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
const props = withDefaults(defineProps<{
values: number[]
min?: number
max?: number
step?: number
disabled?: boolean
}>(), {
min: 0,
max: 100,
step: 1,
disabled: false,
})
const emit = defineEmits<{
(e: 'update:values', value: number[]): void
(e: 'mousedown', event: MouseEvent): void
}>()
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max)
}
const sliderRef = ref<HTMLElement | null>(null)
const isDragging = ref(false)
const previousIndex = ref<number>(0)
// Utility functions
const asc = (a: number, b: number) => a - b
function findClosest(values: number[], currentValue: number) {
const { index: closestIndex } = values.reduce((acc: { distance: number, index: number } | null, value: number, index: number) => {
const distance = Math.abs(currentValue - value)
if (acc === null || distance < acc.distance || distance === acc.distance) {
return { distance, index }
}
return acc
}, null) || { index: 0 }
return closestIndex
}
function valueToPercent(value: number, min: number, max: number) {
return ((value - min) * 100) / (max - min)
}
function percentToValue(percent: number, min: number, max: number) {
return (max - min) * percent + min
}
function getDecimalPrecision(num: number) {
if (Math.abs(num) < 1) {
const parts = num.toExponential().split('e-')
const matissaDecimalPart = parts[0].split('.')[1]
return (matissaDecimalPart ? matissaDecimalPart.length : 0) + Number.parseInt(parts[1], 10)
}
const decimalPart = num.toString().split('.')[1]
return decimalPart ? decimalPart.length : 0
}
function roundValueToStep(value: number, step: number) {
const nearest = Math.round(value / step) * step
return Number(nearest.toFixed(getDecimalPrecision(step)))
}
// Computed values
const sortedValues = computed(() => {
return [...props.values]
.sort(asc)
.map(value => clamp(value, props.min, props.max))
})
const sliderStyle = computed(() => {
const sliderOffset = valueToPercent(sortedValues.value[0], props.min, props.max)
const sliderLeap = valueToPercent(sortedValues.value[sortedValues.value.length - 1], props.min, props.max) - sliderOffset
return {
left: `${sliderOffset}%`,
width: `${sliderLeap}%`,
backgroundSize: `${sliderLeap}% 100%`,
}
})
// Event handlers
function getNewValue(event: MouseEvent, move = false) {
if (!sliderRef.value)
return { newValue: sortedValues.value, activeIndex: 0 }
const { width, left } = sliderRef.value.getBoundingClientRect()
const percent = (event.clientX - left) / width
let currentValue = percentToValue(percent, props.min, props.max)
currentValue = roundValueToStep(currentValue, props.step)
currentValue = clamp(currentValue, props.min, props.max)
const activeIndex = move ? previousIndex.value : findClosest(sortedValues.value, currentValue)
const newValues = [...sortedValues.value]
newValues[activeIndex] = currentValue
const sortedNewValues = [...newValues].sort(asc)
const newActiveIndex = sortedNewValues.indexOf(currentValue)
previousIndex.value = newActiveIndex
return {
newValue: sortedNewValues,
activeIndex: newActiveIndex,
}
}
function handleMouseDown(event: MouseEvent) {
if (props.disabled)
return
event.preventDefault()
isDragging.value = true
emit('mousedown', event)
const { newValue } = getNewValue(event)
emit('update:values', newValue)
}
function handleMouseMove(event: MouseEvent) {
if (!isDragging.value || props.disabled)
return
const { newValue } = getNewValue(event, true)
emit('update:values', newValue)
}
function handleMouseUp(_: MouseEvent) {
if (!isDragging.value)
return
isDragging.value = false
}
function handleMouseLeave(event: MouseEvent) {
if (!isDragging.value)
return
handleMouseUp(event)
}
</script>
<template>
<span
ref="sliderRef"
class="range-slider"
:class="{ disabled }"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@mouseleave="handleMouseLeave"
>
<span
class="slider-track"
:style="sliderStyle"
/>
<span
v-for="(value, index) in sortedValues"
:key="index"
role="slider"
class="slider-thumb"
:style="{ left: `${valueToPercent(value, min, max)}%` }"
:data-index="index"
/>
</span>
</template>
<style scoped>
.range-slider {
width: 100%;
box-sizing: border-box;
display: inline-block;
cursor: ew-resize;
touch-action: none;
border: 3px solid #4bb9fd;
position: relative;
}
.range-slider.disabled {
cursor: default;
pointer-events: none;
opacity: 0.5;
}
.slider-track {
display: block;
position: relative;
background-color: #4bb9fd;
background-image: linear-gradient(90deg, var(--primary-light), var(--primary-light));
background-repeat: no-repeat;
height: 14px;
}
.slider-thumb {
position: absolute;
width: 10px;
height: 10px;
background: white;
top: 50%;
transform: translate(-50%, -50%);
}
</style>
@@ -0,0 +1,149 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
const props = withDefaults(defineProps<{
modelValue: number
min?: number
max?: number
step?: number
disabled?: boolean
}>(), {
min: 0,
max: 100,
step: 1,
disabled: false,
})
const emit = defineEmits<{
(e: 'update:modelValue', value: number): void
(e: 'mousedown', event: MouseEvent): void
}>()
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max)
}
const sliderRef = ref<HTMLElement | null>(null)
const isDragging = ref(false)
// Scale factor for internal calculations
const SCALE_FACTOR = 100
function getStepPrecision(step: number): number {
const stepStr = step.toString()
if (stepStr.includes('e-')) {
return Number.parseInt(stepStr.split('e-')[1], 10)
}
const decimals = stepStr.includes('.') ? stepStr.split('.')[1].length : 0
return decimals
}
function scaleUp(value: number): number {
return value * SCALE_FACTOR
}
function scaleDown(value: number): number {
return value / SCALE_FACTOR
}
function roundToStep(value: number, step: number): number {
const scaledValue = scaleUp(value)
const scaledStep = scaleUp(step)
const precision = getStepPrecision(step)
const rounded = Math.round(scaledValue / scaledStep) * scaledStep
const scaledBack = scaleDown(rounded)
return Number(scaledBack.toFixed(precision))
}
function valueToPercent(value: number, min: number, max: number) {
const scaledValue = scaleUp(value)
const scaledMin = scaleUp(min)
const scaledMax = scaleUp(max)
return ((scaledValue - scaledMin) * 100) / (scaledMax - scaledMin)
}
function percentToValue(percent: number, min: number, max: number) {
const scaledMin = scaleUp(min)
const scaledMax = scaleUp(max)
const scaledValue = (scaledMax - scaledMin) * percent + scaledMin
const value = scaleDown(scaledValue)
return roundToStep(value, props.step)
}
const currentValue = computed(() => {
return roundToStep(clamp(props.modelValue, props.min, props.max), props.step)
})
const sliderStyle = computed(() => {
const sliderPercent = valueToPercent(currentValue.value, props.min, props.max)
return {
width: `${sliderPercent}%`,
backgroundSize: `${sliderPercent}% 100%`,
}
})
function getNewValue(event: MouseEvent) {
if (!sliderRef.value)
return currentValue.value
const { width, left } = sliderRef.value.getBoundingClientRect()
const percent = clamp((event.clientX - left) / width, 0, 1)
return percentToValue(percent, props.min, props.max)
}
function handleMouseDown(event: MouseEvent) {
if (props.disabled)
return
event.preventDefault()
isDragging.value = true
emit('mousedown', event)
const newValue = getNewValue(event)
emit('update:modelValue', newValue)
}
function handleMouseMove(event: MouseEvent) {
if (!isDragging.value || props.disabled)
return
const newValue = getNewValue(event)
emit('update:modelValue', newValue)
}
function handleMouseUp(_: MouseEvent) {
if (!isDragging.value)
return
isDragging.value = false
}
function handleMouseLeave(event: MouseEvent) {
if (!isDragging.value)
return
handleMouseUp(event)
}
</script>
<template>
<span
ref="sliderRef"
class="range-slider disabled:pointer-events-none disabled:cursor-default disabled:opacity-50"
:class="{ disabled }"
bg="[#e6e1fc] dark:[#676085]" touch-action-none relative inline-block w-full cursor-ew-resize rounded-sm
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@mouseleave="handleMouseLeave"
>
<span :style="sliderStyle" bg="[#cabeff] dark:[#4e34b9]" relative block rounded-sm h="[14px]" />
<span
role="slider"
class="slider-thumb"
:style="{ left: `${valueToPercent(currentValue, min, max)}%` }"
absolute rounded-sm w="[1px]" h="[14px]" bg="zinc-100 dark:zinc-400" top="50%" transform="translate-x-[50%] translate-y-[-50%]"
/>
</span>
</template>
+7 -5
View File
@@ -20,7 +20,7 @@ import BasicTextarea from './BasicTextarea.vue'
// import AudioWaveform from './AudioWaveform.vue'
import Live2DViewer from './Live2DViewer.vue'
import Settings from './Settings.vue'
import ThreeViewer from './ThreeViewer.vue'
import ThreeDScene from './ThreeDScene.vue'
const nowSpeakingAvatarBorderOpacityMin = 30
const nowSpeakingAvatarBorderOpacityMax = 100
@@ -316,7 +316,7 @@ onUnmounted(() => {
</fieldset>
<Settings />
</div>
<div flex="~ row 1" max-h="[calc(100vh-160px)]" relative h-full w-full items-end gap-2>
<div flex="~ row 1" max-h="[calc(100vh-210px)]" relative h-full w-full items-end gap-2>
<Live2DViewer
v-if="stageView === '2d'"
ref="live2DViewerRef"
@@ -324,14 +324,16 @@ onUnmounted(() => {
model="/assets/live2d/models/hiyori_pro_zh/runtime/hiyori_pro_t11.model3.json"
w="50%" min-w="50% <lg:full" min-h="100 sm:100" h-full flex-1
/>
<ThreeViewer
<ThreeDScene
v-else-if="stageView === '3d'"
model="/assets/vrm/models/AvatarSample-A/AvatarSample_A.vrm"
w="50%" min-w="50% <lg:full" min-h="100 sm:100" h-full flex-1
@error="console.error"
/>
<div
class="relative <lg:(absolute bottom-0 from-zinc-800/80 to-zinc-800/0 bg-gradient-to-t p-2)"
px="<sm:2" py="<sm:2" rounded="<sm:lg"
w="50% <lg:full" flex="~ col 1" gap-2 max-h="[calc(100vh-160px)]"
w="50% <lg:full" flex="~ col 1" gap-2 max-h="[calc(100vh-210px)]"
>
<div v-for="(message, index) in messages" :key="index">
<div v-if="message.role === 'assistant'" flex mr="12">
@@ -413,7 +415,7 @@ onUnmounted(() => {
v-model="messageInput"
placeholder="Message"
p="2" bg="zinc-100 dark:zinc-700"
w="[95%]" rounded-lg outline-none
w="[95%]" rounded-lg outline-none min-h="[100px]"
@submit="onSendMessage"
/>
<button
@@ -1,6 +1,4 @@
<script setup lang="ts">
import { OrbitControls } from '@tresjs/cientos'
import { TresCanvas } from '@tresjs/core'
import { breakpointsTailwind, useBreakpoints, useElementBounding, useWindowSize } from '@vueuse/core'
import { computed, ref } from 'vue'
@@ -53,14 +51,6 @@ onMounted(async () => {
<template>
<div ref="containerRef" h-full w-full>
<TresCanvas :alpha="true" :antialias="true" :width="canvasWidth" :height="canvasHeight">
<TresPerspectiveCamera />
<TresMesh>
<TresTorusGeometry :args="[1, 0.5, 16, 32]" />
<TresMeshBasicMaterial color="orange" />
</TresMesh>
<OrbitControls />
<TresAmbientLight :intensity="1" />
</TresCanvas>
<slot :width="canvasWidth" height="canvasHeight" />
</div>
</template>
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { OrbitControls } from '@tresjs/cientos'
import { TresCanvas } from '@tresjs/core'
import Collapsable from './Collapsable.vue'
import DataGuiRange from './DataGui/Range.vue'
import VRMModel from './VRMModel.vue'
const props = defineProps<{
model: string
}>()
const emit = defineEmits<{
(e: 'loadModelProgress', value: number): void
(e: 'error', value: unknown): void
}>()
const cameraPositionX = ref(0)
const cameraPositionY = ref(0.15)
const cameraPositionZ = ref(-1)
const vrmModelPositionX = ref(0)
const vrmModelPositionY = ref(-1.3)
const vrmModelPositionZ = ref(-0.3)
</script>
<template>
<Screen v-slot="{ canvasHeight, canvasWidth }" relative>
<div z="10" top="2" absolute w-full gap-2 px-2 flex="~ col md:row">
<Collapsable h-fit w-full>
<template #label>
<span font-mono>Camera</span>
</template>
<div grid="~ cols-[20px_1fr_60px]" w-full gap-2 p-2 text-sm font-mono>
<div text="zinc-400 dark:zinc-500">
<span>X</span>
</div>
<label w-full flex items-center gap-2>
<DataGuiRange v-model="cameraPositionX" :min="-10" :max="10" :step="0.01" />
</label>
<div>
<span>{{ cameraPositionX }}</span>
</div>
<div text="zinc-400 dark:zinc-500">
<span>Y</span>
</div>
<label w-full flex items-center gap-2>
<DataGuiRange v-model="cameraPositionY" :min="-10" :max="10" :step="0.01" />
</label>
<div>
<span>{{ cameraPositionY }}</span>
</div>
<div text="zinc-400 dark:zinc-500">
<span>Z</span>
</div>
<label w-full flex items-center gap-2>
<DataGuiRange v-model="cameraPositionZ" :min="-10" :max="10" :step="0.01" />
</label>
<div>
<span>{{ cameraPositionZ }}</span>
</div>
</div>
</Collapsable>
<Collapsable h-fit w-full>
<template #label>
<span font-mono>Model</span>
</template>
<div grid="~ cols-[20px_1fr_60px]" w-full gap-2 p-2 text-sm font-mono>
<div text="zinc-400 dark:zinc-500">
<span>X</span>
</div>
<label w-full flex items-center gap-2>
<DataGuiRange v-model="vrmModelPositionX" :min="-10" :max="10" :step="0.01" />
</label>
<div>
<span>{{ vrmModelPositionX }}</span>
</div>
<div text="zinc-400 dark:zinc-500">
<span>Y</span>
</div>
<label w-full flex items-center gap-2>
<DataGuiRange v-model="vrmModelPositionY" :min="-10" :max="10" :step="0.01" />
</label>
<div>
<span>{{ vrmModelPositionY }}</span>
</div>
<div text="zinc-400 dark:zinc-500">
<span>Z</span>
</div>
<label w-full flex items-center gap-2>
<DataGuiRange v-model="vrmModelPositionZ" :min="-10" :max="10" :step="0.01" />
</label>
<div>
<span>{{ vrmModelPositionZ }}</span>
</div>
</div>
</Collapsable>
</div>
<TresCanvas :alpha="true" :antialias="true" :width="canvasWidth" :height="canvasHeight">
<TresPerspectiveCamera :position="[cameraPositionX, cameraPositionY, cameraPositionZ]" />
<OrbitControls />
<VRMModel
:model="props.model"
:position="[vrmModelPositionX, vrmModelPositionY, vrmModelPositionZ]"
@load-model-progress="(val) => emit('loadModelProgress', val)"
@error="(val) => emit('error', val)"
/>
<TresAmbientLight :intensity="1" />
</TresCanvas>
</Screen>
</template>
@@ -0,0 +1,134 @@
<script setup lang="ts">
// From: https://stackoverflow.com/a/71426342/22392721
interface Props {
duration?: number
easingEnter?: string
easingLeave?: string
opacityClosed?: number
opacityOpened?: number
}
const props = withDefaults(defineProps<Props>(), {
duration: 250,
easingEnter: 'ease-in-out',
easingLeave: 'ease-in-out',
opacityClosed: 0,
opacityOpened: 1,
})
const closed = '0px'
interface initialStyle {
height: string
width: string
position: string
visibility: string
overflow: string
paddingTop: string
paddingBottom: string
borderTopWidth: string
borderBottomWidth: string
marginTop: string
marginBottom: string
}
function getElementStyle(element: HTMLElement) {
return {
height: element.style.height,
width: element.style.width,
position: element.style.position,
visibility: element.style.visibility,
overflow: element.style.overflow,
paddingTop: element.style.paddingTop,
paddingBottom: element.style.paddingBottom,
borderTopWidth: element.style.borderTopWidth,
borderBottomWidth: element.style.borderBottomWidth,
marginTop: element.style.marginTop,
marginBottom: element.style.marginBottom,
}
}
function prepareElement(element: HTMLElement, initialStyle: initialStyle) {
const { width } = getComputedStyle(element)
element.style.width = width
element.style.position = 'absolute'
element.style.visibility = 'hidden'
element.style.height = ''
const { height } = getComputedStyle(element)
element.style.width = initialStyle.width
element.style.position = initialStyle.position
element.style.visibility = initialStyle.visibility
element.style.height = closed
element.style.overflow = 'hidden'
return initialStyle.height && initialStyle.height !== closed
? initialStyle.height
: height
}
function animateTransition(
element: HTMLElement,
initialStyle: initialStyle,
done: () => void,
keyframes: Keyframe[] | PropertyIndexedKeyframes | null,
options?: number | KeyframeAnimationOptions,
) {
const animation = element.animate(keyframes, options)
// Set height to 'auto' to restore it after animation
element.style.height = initialStyle.height
animation.onfinish = () => {
element.style.overflow = initialStyle.overflow
done()
}
}
function getEnterKeyframes(height: string, initialStyle: initialStyle) {
return [
{
height: closed,
opacity: props.opacityClosed,
paddingTop: closed,
paddingBottom: closed,
borderTopWidth: closed,
borderBottomWidth: closed,
marginTop: closed,
marginBottom: closed,
},
{
height,
opacity: props.opacityOpened,
paddingTop: initialStyle.paddingTop,
paddingBottom: initialStyle.paddingBottom,
borderTopWidth: initialStyle.borderTopWidth,
borderBottomWidth: initialStyle.borderBottomWidth,
marginTop: initialStyle.marginTop,
marginBottom: initialStyle.marginBottom,
},
]
}
function enterTransition(element: Element, done: () => void) {
const HTMLElement = element as HTMLElement
const initialStyle = getElementStyle(HTMLElement)
const height = prepareElement(HTMLElement, initialStyle)
const keyframes = getEnterKeyframes(height, initialStyle)
const options = { duration: props.duration, easing: props.easingEnter }
animateTransition(HTMLElement, initialStyle, done, keyframes, options)
}
function leaveTransition(element: Element, done: () => void) {
const HTMLElement = element as HTMLElement
const initialStyle = getElementStyle(HTMLElement)
const { height } = getComputedStyle(HTMLElement)
HTMLElement.style.height = height
HTMLElement.style.overflow = 'hidden'
const keyframes = getEnterKeyframes(height, initialStyle).reverse()
const options = { duration: props.duration, easing: props.easingLeave }
animateTransition(HTMLElement, initialStyle, done, keyframes, options)
}
</script>
<template>
<Transition :css="false" @enter="enterTransition" @leave="leaveTransition">
<slot />
</Transition>
</template>
@@ -0,0 +1,60 @@
<script setup lang="ts">
import type { VRMCore } from '@pixiv/three-vrm'
import { VRMLoaderPlugin } from '@pixiv/three-vrm'
import { useTresContext } from '@tresjs/core'
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
const props = defineProps<{
model: string
position: [number, number, number]
}>()
const emit = defineEmits<{
(e: 'loadModelProgress', value: number): void
(e: 'error', value: unknown): void
}>()
let vrm: VRMCore
const { scene } = useTresContext()
interface GLTFUserdata extends Record<string, any> {
vrm: VRMCore
}
onMounted(() => {
if (!scene.value) {
return
}
const loader = new GLTFLoader()
loader.register(parser => new VRMLoaderPlugin(parser))
loader.load(
props.model,
(gltf) => {
const userData = gltf.userData as GLTFUserdata
vrm = userData.vrm as VRMCore
scene.value.add(vrm.scene)
vrm.scene.position.set(...props.position)
},
progress => emit('loadModelProgress', Number.parseFloat((100.0 * (progress.loaded / progress.total)).toFixed(2))),
error => emit('error', error),
)
})
onUnmounted(() => {
if (vrm) {
const { scene } = useTresContext()
scene.value.remove(vrm.scene)
}
})
watch(() => props.position, ([x, y, z]) => {
if (vrm) {
vrm.scene.position.set(x, y, z)
}
})
</script>
<template>
<slot />
</template>
+58
View File
@@ -241,5 +241,63 @@ export default defineConfig({
}
},
},
{
name: 'vrm-models-sample-a',
async configResolved(config) {
const cacheDir = resolve(join(config.root, '.cache'))
const publicDir = resolve(join(config.root, 'public'))
try {
if (!(await exists(resolve(join(cacheDir, 'assets/vrm/models/AvatarSample-A'))))) {
await mkdir(join(cacheDir, 'assets/vrm/models/AvatarSample-A'), { recursive: true })
console.log('Downloading VRM Model - Avatar Sample A...')
const res = await ofetch('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-A/AvatarSample_A.vrm', { responseType: 'arrayBuffer' })
console.log('Saving VRM Model - Avatar Sample A...')
await writeFile(join(cacheDir, 'assets/vrm/models/AvatarSample-A/AvatarSample_A.vrm'), Buffer.from(res))
console.log('VRM Model - Avatar Sample A downloaded and saved.')
}
if (!(await exists(resolve(join(publicDir, 'assets/vrm/models/AvatarSample-A'))))) {
await mkdir(join(publicDir, 'assets/vrm/models/AvatarSample-A'), { recursive: true }).catch(() => { })
await cp(join(cacheDir, 'assets/vrm/models/AvatarSample-A'), join(publicDir, 'assets/vrm/models/AvatarSample-A'), { recursive: true })
}
}
catch (err) {
console.error(err)
throw err
}
},
},
{
name: 'vrm-models-sample-b',
async configResolved(config) {
const cacheDir = resolve(join(config.root, '.cache'))
const publicDir = resolve(join(config.root, 'public'))
try {
if (!(await exists(resolve(join(cacheDir, 'assets/vrm/models/AvatarSample-B'))))) {
await mkdir(join(cacheDir, 'assets/vrm/models/AvatarSample-B'), { recursive: true })
console.log('Downloading VRM Model - Avatar Sample B...')
const res = await ofetch('https://dist.ayaka.moe/vrm-models/VRoid-Hub/AvatarSample-B/AvatarSample_B.vrm', { responseType: 'arrayBuffer' })
console.log('Saving VRM Model - Avatar Sample B...')
await writeFile(join(cacheDir, 'assets/vrm/models/AvatarSample-B/AvatarSample_B.vrm'), Buffer.from(res))
}
if (!(await exists(resolve(join(publicDir, 'assets/vrm/models/AvatarSample-B'))))) {
await mkdir(join(publicDir, 'assets/vrm/models/AvatarSample-B'), { recursive: true }).catch(() => { })
await cp(join(cacheDir, 'assets/vrm/models/AvatarSample-B'), join(publicDir, 'assets/vrm/models/AvatarSample-B'), { recursive: true })
}
}
catch (err) {
console.error(err)
throw err
}
},
},
],
})