diff --git a/src/App/components/Nova.tsx b/src/App/components/Nova.tsx index e36c4dc6..bb70e0da 100644 --- a/src/App/components/Nova.tsx +++ b/src/App/components/Nova.tsx @@ -7,16 +7,25 @@ import { SettingsPanel } from './SettingsPanel'; const idleUrl = 'https://readyplayerme-assets.s3.amazonaws.com/animations/nova-male-idle.glb'; const victoryUrl = 'https://readyplayerme-assets.s3.amazonaws.com/animations/nova-victory-03.glb'; -const modelUrl = - 'https://api.readyplayer.dev/v3/avatars/66e2cecfbd5d3e60f8cdbde5.glb?meshCompression=true&textureQuality=medium&meshSimplify=0&morphTargetsGroup=Editor+combined'; const animations: Record = { idle: idleUrl, victory: victoryUrl }; +const modelOneUrl = + 'https://api.readyplayer.dev/v3/avatars/66e02a5804466d05776a8f80.glb?meshCompression=true&textureQuality=medium&meshLOD=0&morphTargetsGroup=Basic expressions'; +const modelTwoUrl = + 'https://api.readyplayer.dev/v3/avatars/66e00aca4ec56c8f76e5da75.glb?meshCompression=true&textureQuality=medium&meshLOD=0&morphTargetsGroup=Basic expressions'; + +const models: Record = { + one: modelOneUrl, + two: modelTwoUrl +}; + export const AvatarNova: React.FC = () => { const [activeAnimation, setActiveAnimation] = React.useState('idle'); + const [modelSrc, setModelSrc] = React.useState(models.one); return ( <> @@ -27,9 +36,15 @@ export const AvatarNova: React.FC = () => { + + ; activeAnimation?: string; + /** + * Control properties of animations. + */ + animationConfig?: AnimationConfiguration; /** * Control properties of materials. */ @@ -203,6 +208,7 @@ const Avatar: FC = ({ backLightPosition, lightTarget, fov = 50, + animationConfig, materialConfig }) => { const setSpawnState = useSetAtom(spawnState); @@ -226,6 +232,7 @@ const Avatar: FC = ({ scale={scale} onLoaded={onLoaded} bloom={effects?.bloom} + animationConfig={animationConfig} materialConfig={materialConfig} /> ); @@ -299,6 +306,7 @@ const Avatar: FC = ({ effects?.bloom, idleRotation, headMovement, + animationConfig, materialConfig ]); diff --git a/src/components/Models/MultipleAnimationModel/MultipleAnimationModel.component.tsx b/src/components/Models/MultipleAnimationModel/MultipleAnimationModel.component.tsx index 0e9ff411..50195bb2 100644 --- a/src/components/Models/MultipleAnimationModel/MultipleAnimationModel.component.tsx +++ b/src/components/Models/MultipleAnimationModel/MultipleAnimationModel.component.tsx @@ -1,12 +1,12 @@ import React, { FC, useEffect, useRef, useState } from 'react'; import { useFrame, useGraph } from '@react-three/fiber'; -import { AnimationAction, AnimationClip, AnimationMixer, Group } from 'three'; -import { GLTFLoader } from 'three-stdlib'; +import { AnimationAction, AnimationClip, AnimationMixer } from 'three'; import { Model } from 'src/components/Models/Model'; -import { BaseModelProps } from 'src/types'; -import { useEmotion, useGltfCachedLoader, useIdleExpression } from 'src/services'; +import { AnimationConfiguration, BaseModelProps } from 'src/types'; +import { useEmotion, useFallback, useGltfCachedLoader, useIdleExpression } from 'src/services'; import { Emotion } from 'src/components/Avatar/Avatar.component'; +import { loadAnimationClip } from 'src/services/Animation.service'; export interface MultipleAnimationModelProps extends BaseModelProps { modelSrc: string | Blob; @@ -14,6 +14,7 @@ export interface MultipleAnimationModelProps extends BaseModelProps { activeAnimation: string; scale?: number; emotion?: Emotion; + animationConfig?: AnimationConfiguration; } export const MultipleAnimationModel: FC = ({ @@ -21,77 +22,87 @@ export const MultipleAnimationModel: FC = ({ animations, activeAnimation, scale = 1, + setModelFallback, onLoaded, emotion, bloom, + animationConfig, materialConfig }) => { - const groupRef = useRef(null); const mixerRef = useRef(null); + const activeActionRef = useRef(null); + const animationTimeRef = useRef(0); + const [loadedAnimations, setLoadedAnimations] = useState>({}); - const [activeAction, setActiveAction] = useState(null); const { scene } = useGltfCachedLoader(modelSrc); const { nodes } = useGraph(scene); useEffect(() => { - if (scene && groupRef.current) { - groupRef.current.add(scene); + const loadAllAnimations = async () => { + const clips: Record = {}; + + await Promise.all( + Object.keys(animations).map(async (name) => { + const newClip = await loadAnimationClip(animations[name]); + clips[name] = newClip; + }) + ); + + setLoadedAnimations(clips); + }; - mixerRef.current = new AnimationMixer(scene); + loadAllAnimations(); + }, [animations]); + + useEffect(() => { + if (scene) { + const mixer = new AnimationMixer(scene); + mixerRef.current = mixer; + + if (activeActionRef.current) { + const newAction = mixer.clipAction(activeActionRef.current.getClip()); + + newAction.play(); + mixer.update(animationTimeRef.current); + + activeActionRef.current = newAction; + } } return () => { mixerRef.current?.stopAllAction(); + mixerRef.current?.uncacheRoot(scene); }; }, [scene]); - useEffect(() => { - const loader = new GLTFLoader(); - Object.keys(animations).forEach((name) => { - loader.load(animations[name], (gltf) => { - const newClip = gltf.animations[0]; - setLoadedAnimations((prev) => ({ - ...prev, - [name]: newClip - })); - }); - }); - }, [animations]); - useEffect(() => { const mixer = mixerRef.current; + const prevAction = activeActionRef.current; const newClip = loadedAnimations[activeAnimation]; if (!newClip || !mixer) return; + if (prevAction && prevAction.getClip().name === newClip.name) return; const newAction = mixer.clipAction(newClip); - const prevAction = activeAction; + activeActionRef.current = newAction; if (prevAction) { newAction.reset(); - newAction.crossFadeFrom(prevAction, 0.5, true); + newAction.crossFadeFrom(prevAction, animationConfig?.crossfadeDuration ?? 0.5, true); } newAction.play(); - setActiveAction(newAction); - }, [activeAnimation, loadedAnimations, activeAction, nodes.Armature]); + }, [activeAnimation, loadedAnimations, animationConfig]); useFrame((state, delta) => { mixerRef.current?.update(delta); + animationTimeRef.current = activeActionRef.current?.time || 0; }); useEmotion(nodes, emotion); useIdleExpression('blink', nodes); + useFallback(nodes, setModelFallback); - return ( - - ); + return ; }; diff --git a/src/components/Models/index.ts b/src/components/Models/index.ts index 4130d942..7133f22d 100644 --- a/src/components/Models/index.ts +++ b/src/components/Models/index.ts @@ -3,5 +3,6 @@ export { FloatingModel } from './FloatingModel'; export { HalfBodyModel } from './HalfBodyModel'; export { StaticModel } from './StaticModel'; export { PoseModel } from './PoseModel'; +export { MultipleAnimationModel } from './MultipleAnimationModel'; export { Model } from './Model'; export { EnvironmentModel } from './EnvironmentModel'; diff --git a/src/types/index.ts b/src/types/index.ts index 28af9631..15632148 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -162,6 +162,13 @@ export type EffectConfiguration = { vignette?: boolean; }; +export type AnimationConfiguration = { + /** + * Duration of the crossfade between animations. + */ + crossfadeDuration?: number; +}; + export interface SpawnState { /** * Add a custom loaded effect like particles when avatar is loaded, animate them with a custom animation.