feat: animation system for skin preview renderer
This commit is contained in:
1 parent
cbfb48fc1a
commit
f50375220d
2 files changed
+167
-5
No files matched your search
@@ -1 +1 @@
|
||||
{"asset":{"version":"2.0","generator":"Blockbench 4.12.4 glTF exporter"},"scenes":[{"nodes":[18],"name":"blockbench_export"}],"scene":0,"nodes":[{"translation":[0,-1.5,0],"name":"Head","mesh":0},{"translation":[0,-1.5,0],"name":"Hat Layer","mesh":1},{"rotation":[0.05228614400929896,-0.043559608510925024,0.002282862348496242,0.997679060715675],"translation":[0,1.5,0],"name":"Head","children":[0,1]},{"translation":[0,-1.5,0],"name":"Body","mesh":2},{"translation":[0,-1.5,0],"name":"Body Layer","mesh":3},{"translation":[0,1.5,0],"name":"Body","children":[3,4]},{"translation":[-0.3125,-1.375,0],"name":"Right Arm","mesh":4},{"translation":[-0.3125,-1.375,0],"name":"Right Arm Layer","mesh":5},{"rotation":[0.08715574274765817,0,0,0.9961946980917455],"translation":[0.3125,1.375,0],"name":"Right Arm","children":[6,7]},{"translation":[0.3125,-1.375,0],"name":"Left Arm","mesh":6},{"translation":[0.3125,-1.375,0],"name":"Left Arm Layer","mesh":7},{"rotation":[-0.10452846326765346,0,0,0.9945218953682733],"translation":[-0.3125,1.375,0],"name":"Left Arm","children":[9,10]},{"translation":[-0.11875,-0.75,0],"name":"Right Leg","mesh":8},{"translation":[-0.11875,-0.75,0],"name":"Right Leg Layer","mesh":9},{"rotation":[-0.09583115474787583,-0.0016727390282702394,0.01737205902003089,0.9952445947048578],"translation":[0.11875,0.75,0],"name":"Right Leg","children":[12,13]},{"translation":[0.11875,-0.75,0],"name":"Left Leg","mesh":10},{"translation":[0.11875,-0.75,0],"name":"Left Leg Layer","mesh":11},{"rotation":[0.08714246850588939,-0.0015210774457754552,-0.017385994761764084,0.9960429728140489],"translation":[-0.11875,0.75,0],"name":"Left Leg","children":[15,16]},{"children":[2,5,8,11,14,17]}],"bufferViews":[{"buffer":0,"byteOffset":0,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":288,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":576,"byteLength":192,"target":34962,"byteStride":8},{"buffer":0,"byteOffset":768,"byteLength":72,"target":34963},{"buffer":0,"byteOffset":840,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":1128,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":1416,"byteLength":192,"target":34962,"byteStride":8},{"buffer":0,"byteOffset":1608,"byteLength":72,"target":34963},{"buffer":0,"byteOffset":1680,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":1968,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":2256,"byteLength":192,"target":34962,"byteStride":8},{"buffer":0,"byteOffset":2448,"byteLength":72,"target":34963},{"buffer":0,"byteOffset":2520,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":2808,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":3096,"byteLength":192,"target":34962,"byteStride":8},{"buffer":0,"byteOffset":3288,"byteLength":72,"target":34963},{"buffer":0,"byteOffset":3360,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":3648,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":3936,"byteLength":192,"target":34962,"byteStride":8},{"buffer":0,"byteOffset":4128,"byteLength":72,"target":34963},{"buffer":0,"byteOffset":4200,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":4488,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":4776,"byteLength":192,"target":34962,"byteStride":8},{"buffer":0,"byteOffset":4968,"byteLength":72,"target":34963},{"buffer":0,"byteOffset":5040,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":5328,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":5616,"byteLength":192,"target":34962,"byteStride":8},{"buffer":0,"byteOffset":5808,"byteLength":72,"target":34963},{"buffer":0,"byteOffset":5880,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":6168,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":6456,"byteLength":192,"target":34962,"byteStride":8},{"buffer":0,"byteOffset":6648,"byteLength":72,"target":34963},{"buffer":0,"byteOffset":6720,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":7008,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":7296,"byteLength":192,"target":34962,"byteStride":8},{"buffer":0,"byteOffset":7488,"byteLength":72,"target":34963},{"buffer":0,"byteOffset":7560,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":7848,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":8136,"byteLength":192,"target":34962,"byteStride":8},{"buffer":0,"byteOffset":8328,"byteLength":72,"target":34963},{"buffer":0,"byteOffset":8400,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":8688,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":8976,"byteLength":192,"target":34962,"byteStride":8},{"buffer":0,"byteOffset":9168,"byteLength":72,"target":34963},{"buffer"Line truncated
|
||||
{"asset":{"version":"2.0","generator":"Blockbench 4.12.1 glTF exporter"},"scenes":[{"nodes":[18],"name":"blockbench_export"}],"scene":0,"nodes":[{"translation":[0,-1.5,0],"name":"Head","mesh":0},{"translation":[0,-1.5,0],"name":"Hat Layer","mesh":1},{"rotation":[-0.01745240643728351,0,0,0.9998476951563913],"translation":[0,1.5,0],"name":"Head","children":[0,1]},{"translation":[0,-1.5,0],"name":"Body","mesh":2},{"translation":[0,-1.5,0],"name":"Body Layer","mesh":3},{"translation":[0,1.5,0],"name":"Body","children":[3,4]},{"translation":[-0.3125,-1.375,0],"name":"Right Arm","mesh":4},{"translation":[-0.3125,-1.375,0],"name":"Right Arm Layer","mesh":5},{"rotation":[-0.3007057995042731,0,0,0.9537169507482269],"translation":[0.3125,1.375,0],"name":"Right Arm","children":[6,7]},{"translation":[0.3125,-1.375,0],"name":"Left Arm","mesh":6},{"translation":[0.3125,-1.375,0],"name":"Left Arm Layer","mesh":7},{"rotation":[0.3007057995042731,0,0,0.9537169507482269],"translation":[-0.3125,1.375,0],"name":"Left Arm","children":[9,10]},{"translation":[-0.11875,-0.75,0],"name":"Right Leg","mesh":8},{"translation":[-0.11875,-0.75,0],"name":"Right Leg Layer","mesh":9},{"rotation":[0.35831336837079036,0.006254383109560491,0.01629322504512165,0.9334382376763479],"translation":[0.11875,0.75,0],"name":"Right Leg","children":[12,13]},{"translation":[0.11875,-0.75,0],"name":"Left Leg","mesh":10},{"translation":[0.11875,-0.75,0],"name":"Left Leg Layer","mesh":11},{"rotation":[-0.35831336837079036,0.006254383109560491,-0.01629322504512165,0.9334382376763479],"translation":[-0.11875,0.75,0],"name":"Left Leg","children":[15,16]},{"children":[2,5,8,11,14,17]}],"bufferViews":[{"buffer":0,"byteOffset":0,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":288,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":576,"byteLength":192,"target":34962,"byteStride":8},{"buffer":0,"byteOffset":768,"byteLength":72,"target":34963},{"buffer":0,"byteOffset":840,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":1128,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":1416,"byteLength":192,"target":34962,"byteStride":8},{"buffer":0,"byteOffset":1608,"byteLength":72,"target":34963},{"buffer":0,"byteOffset":1680,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":1968,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":2256,"byteLength":192,"target":34962,"byteStride":8},{"buffer":0,"byteOffset":2448,"byteLength":72,"target":34963},{"buffer":0,"byteOffset":2520,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":2808,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":3096,"byteLength":192,"target":34962,"byteStride":8},{"buffer":0,"byteOffset":3288,"byteLength":72,"target":34963},{"buffer":0,"byteOffset":3360,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":3648,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":3936,"byteLength":192,"target":34962,"byteStride":8},{"buffer":0,"byteOffset":4128,"byteLength":72,"target":34963},{"buffer":0,"byteOffset":4200,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":4488,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":4776,"byteLength":192,"target":34962,"byteStride":8},{"buffer":0,"byteOffset":4968,"byteLength":72,"target":34963},{"buffer":0,"byteOffset":5040,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":5328,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":5616,"byteLength":192,"target":34962,"byteStride":8},{"buffer":0,"byteOffset":5808,"byteLength":72,"target":34963},{"buffer":0,"byteOffset":5880,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":6168,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":6456,"byteLength":192,"target":34962,"byteStride":8},{"buffer":0,"byteOffset":6648,"byteLength":72,"target":34963},{"buffer":0,"byteOffset":6720,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":7008,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":7296,"byteLength":192,"target":34962,"byteStride":8},{"buffer":0,"byteOffset":7488,"byteLength":72,"target":34963},{"buffer":0,"byteOffset":7560,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":7848,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":8136,"byteLength":192,"target":34962,"byteStride":8},{"buffer":0,"byteOffset":8328,"byteLength":72,"target":34963},{"buffer":0,"byteOffset":8400,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":8688,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":8976,"byteLength":192,"target":34962,"byteStride":8},{"buffer":0,"byteOffset":9168,"byteLength":72,"target":34963},{"buffer":0,"byteOffset":9240,"byteLength":288,"targeLine truncated
|
||||
@@ -87,8 +87,8 @@
|
||||
<script setup lang="ts">
|
||||
import * as THREE from 'three'
|
||||
import { useGLTF } from '@tresjs/cientos'
|
||||
import { useTexture, TresCanvas } from '@tresjs/core'
|
||||
import { shallowRef, ref, computed, watch, markRaw, onBeforeMount } from 'vue'
|
||||
import { useTexture, TresCanvas, useRenderLoop } from '@tresjs/core'
|
||||
import { shallowRef, ref, computed, watch, markRaw, onBeforeMount, onUnmounted, toRefs } from 'vue'
|
||||
import {
|
||||
applyTexture,
|
||||
applyCapeTexture,
|
||||
@@ -98,6 +98,13 @@ import {
|
||||
loadTexture as loadSkinTexture,
|
||||
} from '@modrinth/utils'
|
||||
|
||||
interface AnimationConfig {
|
||||
baseAnimation: string
|
||||
randomAnimations: string[]
|
||||
randomAnimationInterval?: number
|
||||
transitionDuration?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
textureSrc: string
|
||||
@@ -111,6 +118,7 @@ const props = withDefaults(
|
||||
scale?: number
|
||||
fov?: number
|
||||
initialRotation?: number
|
||||
animationConfig?: AnimationConfig
|
||||
}>(),
|
||||
{
|
||||
variant: 'CLASSIC',
|
||||
@@ -121,6 +129,12 @@ const props = withDefaults(
|
||||
capeSrc: undefined,
|
||||
initialRotation: 15.75,
|
||||
nametag: undefined,
|
||||
animationConfig: {
|
||||
baseAnimation: 'idle',
|
||||
randomAnimations: [],
|
||||
randomAnimationInterval: 8000,
|
||||
transitionDuration: 0.5,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -141,10 +155,127 @@ const isModelLoaded = ref(false)
|
||||
const isTextureLoaded = ref(false)
|
||||
const isReady = computed(() => isModelLoaded.value && isTextureLoaded.value)
|
||||
|
||||
const mixer = ref<THREE.AnimationMixer | null>(null)
|
||||
const actions = ref<Record<string, THREE.AnimationAction>>({})
|
||||
const clock = new THREE.Clock()
|
||||
const currentAnimation = ref<string>('')
|
||||
const randomAnimationTimer = ref<number | null>(null)
|
||||
|
||||
const { baseAnimation, randomAnimations } = toRefs(props.animationConfig)
|
||||
|
||||
function initializeAnimations(loadedScene: THREE.Object3D, clips: THREE.AnimationClip[]) {
|
||||
if (!clips || clips.length === 0) {
|
||||
console.warn('No animation clips found in the model')
|
||||
return
|
||||
}
|
||||
|
||||
mixer.value = new THREE.AnimationMixer(loadedScene)
|
||||
actions.value = {}
|
||||
|
||||
clips.forEach((clip) => {
|
||||
const action = mixer.value!.clipAction(clip)
|
||||
actions.value[clip.name] = action
|
||||
console.log(`Loaded animation: ${clip.name}`)
|
||||
})
|
||||
|
||||
if (baseAnimation.value && actions.value[baseAnimation.value]) {
|
||||
playAnimation(baseAnimation.value)
|
||||
setupRandomAnimationLoop()
|
||||
} else {
|
||||
console.warn(`Base animation "${baseAnimation.value}" not found`)
|
||||
|
||||
const firstAnimationName = Object.keys(actions.value)[0]
|
||||
if (firstAnimationName) {
|
||||
console.log(`Playing first available animation: ${firstAnimationName}`)
|
||||
playAnimation(firstAnimationName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function playAnimation(name: string) {
|
||||
if (!mixer.value || !actions.value[name]) {
|
||||
console.warn(`Animation "${name}" not found!`)
|
||||
return false
|
||||
}
|
||||
|
||||
const action = actions.value[name]
|
||||
const transitionDuration = props.animationConfig.transitionDuration || 0.3
|
||||
|
||||
Object.entries(actions.value).forEach(([actionName, actionInstance]) => {
|
||||
if (actionName !== name && actionInstance.isRunning()) {
|
||||
actionInstance.fadeOut(transitionDuration)
|
||||
}
|
||||
})
|
||||
|
||||
// Reset and play the new animation
|
||||
action.reset()
|
||||
action.setLoop(THREE.LoopRepeat, Infinity)
|
||||
action.fadeIn(transitionDuration)
|
||||
action.play()
|
||||
|
||||
currentAnimation.value = name
|
||||
console.log(`Playing animation: ${name}`)
|
||||
return true
|
||||
}
|
||||
|
||||
function setupRandomAnimationLoop() {
|
||||
const interval = props.animationConfig.randomAnimationInterval || 10000
|
||||
|
||||
randomAnimationTimer.value = window.setInterval(() => {
|
||||
if (randomAnimations.value.length > 0) {
|
||||
if (currentAnimation.value === baseAnimation.value) {
|
||||
const randomIndex = Math.floor(Math.random() * randomAnimations.value.length)
|
||||
const randomAnimationName = randomAnimations.value[randomIndex]
|
||||
|
||||
if (actions.value[randomAnimationName]) {
|
||||
playRandomAnimation(randomAnimationName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, interval)
|
||||
}
|
||||
|
||||
function playRandomAnimation(name: string) {
|
||||
if (!playAnimation(name)) return
|
||||
|
||||
const animationDuration = actions.value[name].getClip().duration * 1000
|
||||
|
||||
setTimeout(() => {
|
||||
if (currentAnimation.value === name && baseAnimation.value) {
|
||||
playAnimation(baseAnimation.value)
|
||||
}
|
||||
}, animationDuration + 500)
|
||||
}
|
||||
|
||||
function stopAnimations() {
|
||||
if (mixer.value) {
|
||||
mixer.value.stopAllAction()
|
||||
}
|
||||
currentAnimation.value = ''
|
||||
}
|
||||
|
||||
function getAvailableAnimations(): string[] {
|
||||
return Object.keys(actions.value)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
playAnimation,
|
||||
stopAnimations,
|
||||
getAvailableAnimations,
|
||||
getCurrentAnimation: () => currentAnimation.value,
|
||||
})
|
||||
|
||||
const { onLoop } = useRenderLoop()
|
||||
onLoop(() => {
|
||||
if (mixer.value) {
|
||||
mixer.value.update(clock.getDelta())
|
||||
}
|
||||
})
|
||||
|
||||
async function loadModel(src: string) {
|
||||
try {
|
||||
isModelLoaded.value = false
|
||||
const { scene: loadedScene } = await useGLTF(src)
|
||||
const { scene: loadedScene, animations } = await useGLTF(src)
|
||||
scene.value = markRaw(loadedScene)
|
||||
|
||||
if (texture.value) {
|
||||
@@ -155,6 +286,10 @@ async function loadModel(src: string) {
|
||||
bodyNode.value = findBodyNode(loadedScene)
|
||||
capeAttached.value = false
|
||||
|
||||
if (animations && animations.length > 0) {
|
||||
initializeAnimations(loadedScene, animations)
|
||||
}
|
||||
|
||||
updateModelInfo()
|
||||
isModelLoaded.value = true
|
||||
} catch (error) {
|
||||
@@ -332,6 +467,22 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.animationConfig,
|
||||
(newConfig) => {
|
||||
if (randomAnimationTimer.value) {
|
||||
clearInterval(randomAnimationTimer.value)
|
||||
randomAnimationTimer.value = null
|
||||
}
|
||||
|
||||
if (mixer.value && newConfig.baseAnimation && actions.value[newConfig.baseAnimation]) {
|
||||
playAnimation(newConfig.baseAnimation)
|
||||
setupRandomAnimationLoop()
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onBeforeMount(async () => {
|
||||
try {
|
||||
isTextureLoaded.value = false
|
||||
@@ -351,12 +502,23 @@ onBeforeMount(async () => {
|
||||
console.error('Failed to initialize skin preview:', error)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (randomAnimationTimer.value) {
|
||||
clearInterval(randomAnimationTimer.value)
|
||||
}
|
||||
|
||||
if (mixer.value) {
|
||||
mixer.value.stopAllAction()
|
||||
mixer.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.nametag-bg {
|
||||
background: linear-gradient(308.68deg, rgba(0, 0, 0, 0) -52.46%, rgba(100, 100, 100, 0.1) 94.75%),
|
||||
rgba(0, 0, 0, 0.2);
|
||||
rgba(0, 0, 0, 0.2);
|
||||
box-shadow:
|
||||
inset -0.5px -0.5px 0px rgba(0, 0, 0, 0.25),
|
||||
inset 0.5px 0.5px 0px rgba(255, 255, 255, 0.05);
|
||||
|
||||
Reference in new issue
Block a user