diff --git a/src/App/App.component.tsx b/src/App/App.component.tsx index eb8f5e4e..ce855dba 100644 --- a/src/App/App.component.tsx +++ b/src/App/App.component.tsx @@ -1,72 +1,11 @@ import React from 'react'; import { createBrowserRouter, RouterProvider } from 'react-router-dom'; +import { AvatarDevelop } from './components/Develop'; +import { AvatarTest } from './components/Test'; +import { AvatarNova } from './components/Nova'; -import { Avatar, CAMERA } from 'src/components/Avatar'; - -import { Sparkles, StatsGl } from '@react-three/drei'; -import { EnvironmentModel } from 'src/components/Models'; import styles from './App.module.scss'; -const AvatarDevelop: React.FC = () => ( -
-
-
-

localhost playground

-
- Drop your content in there to test the avatar props without shrinking the canvas -
-
-
-
-
- - - - - -
-
-
-); - -const AvatarTest: React.FC = () => { - const urlParams = new URLSearchParams(window.location.search); - const modelUrl = urlParams.get('modelUrl') - ? decodeURIComponent(urlParams.get('modelUrl') || '') - : 'https://models.readyplayer.me/64d61e9e17883fd73ebe5eb7.glb?morphTargets=ARKit,Eyes Extra&textureAtlas=none&lod=0'; - const zoomLevel = urlParams.get('zoomLevel') - ? parseFloat(urlParams.get('zoomLevel') || '1') - : CAMERA.CONTROLS.FULL_BODY.MAX_DISTANCE; - - return ( -
-
-
- -
-
-
- ); -}; - const router = createBrowserRouter([ { path: '/', @@ -75,9 +14,21 @@ const router = createBrowserRouter([ { path: '/test', element: + }, + { + path: '/nova', + element: } ]); -const App: React.FC = () => ; +const App: React.FC = () => ( +
+
+
+ +
+
+
+); export default App; diff --git a/src/App/App.module.scss b/src/App/App.module.scss index 80326cab..ad3a34a5 100644 --- a/src/App/App.module.scss +++ b/src/App/App.module.scss @@ -23,44 +23,6 @@ body, width: 100%; } -.settings { - z-index: 2; - position: fixed; - top: 72px; - right: 24px; - width: 100%; - max-width: 320px; - background-color: rgba(243, 243, 243, 0.9); - border: 1px solid #b9b9b9; - border-radius: 4px; - overflow: hidden; -} - -.wrapper { - position: relative; - height: 100%; - width: 100%; -} - -.title { - font-size: 18px; - font-weight: 600; - font-family: Arial, sans-serif; - color: black; - display: block; - padding: 8px; - margin: 0; - border-bottom: 1px solid #b9b9b9; -} - -.content { - position: relative; - padding: 8px; - display: flex; - flex-direction: column; - gap: 12px; -} - .stats { left: 80px !important; } diff --git a/src/App/components/Develop.tsx b/src/App/components/Develop.tsx new file mode 100644 index 00000000..d4a4433e --- /dev/null +++ b/src/App/components/Develop.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Sparkles, StatsGl } from '@react-three/drei'; + +import { Avatar } from 'src/components/Avatar'; +import { EnvironmentModel } from 'src/components/Models'; + +import { SettingsPanel } from './SettingsPanel'; + +export const AvatarDevelop: React.FC = () => ( + <> + + + + + + + +); diff --git a/src/App/components/Nova.tsx b/src/App/components/Nova.tsx new file mode 100644 index 00000000..e36c4dc6 --- /dev/null +++ b/src/App/components/Nova.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { StatsGl } from '@react-three/drei'; + +import { Avatar, CAMERA } from 'src/components/Avatar'; +import { emotions } from 'src/services/Stories.service'; +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 +}; + +export const AvatarNova: React.FC = () => { + const [activeAnimation, setActiveAnimation] = React.useState('idle'); + + return ( + <> + + + + + + + + + ); +}; diff --git a/src/App/components/SettingsPanel.module.scss b/src/App/components/SettingsPanel.module.scss new file mode 100644 index 00000000..e97aee11 --- /dev/null +++ b/src/App/components/SettingsPanel.module.scss @@ -0,0 +1,37 @@ +.settings { + z-index: 2; + position: fixed; + top: 72px; + right: 24px; + width: 100%; + max-width: 320px; + background-color: rgba(243, 243, 243, 0.9); + border: 1px solid #b9b9b9; + border-radius: 4px; + overflow: hidden; +} + +.wrapper { + position: relative; + height: 100%; + width: 100%; +} + +.title { + font-size: 18px; + font-weight: 600; + font-family: Arial, sans-serif; + color: black; + display: block; + padding: 8px; + margin: 0; + border-bottom: 1px solid #b9b9b9; +} + +.content { + position: relative; + padding: 8px; + display: flex; + flex-direction: column; + gap: 12px; +} diff --git a/src/App/components/SettingsPanel.tsx b/src/App/components/SettingsPanel.tsx new file mode 100644 index 00000000..1ca5f2d7 --- /dev/null +++ b/src/App/components/SettingsPanel.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +import styles from './SettingsPanel.module.scss'; + +export const SettingsPanel: React.FC = ({ + children = 'Drop your content in there to test the avatar props without shrinking the canvas' +}) => ( +
+
+

localhost playground

+
{children}
+
+
+); diff --git a/src/App/components/Test.tsx b/src/App/components/Test.tsx new file mode 100644 index 00000000..9ab4cbfc --- /dev/null +++ b/src/App/components/Test.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +import { Avatar, CAMERA } from 'src/components/Avatar'; + +export const AvatarTest: React.FC = () => { + const urlParams = new URLSearchParams(window.location.search); + const modelUrl = urlParams.get('modelUrl') + ? decodeURIComponent(urlParams.get('modelUrl') || '') + : 'https://models.readyplayer.me/64d61e9e17883fd73ebe5eb7.glb?morphTargets=ARKit,Eyes Extra&textureAtlas=none&lod=0'; + const zoomLevel = urlParams.get('zoomLevel') + ? parseFloat(urlParams.get('zoomLevel') || '1') + : CAMERA.CONTROLS.FULL_BODY.MAX_DISTANCE; + + return ( + + ); +}; diff --git a/src/components/Avatar/Avatar.component.tsx b/src/components/Avatar/Avatar.component.tsx index dc7720dc..d8c0d82e 100644 --- a/src/components/Avatar/Avatar.component.tsx +++ b/src/components/Avatar/Avatar.component.tsx @@ -17,7 +17,8 @@ import Loader from 'src/components/Loader'; import Bloom from 'src/components/Bloom/Bloom.component'; import { BlendFunction } from 'postprocessing'; import Lights from 'src/components/Lights/Lights.component'; -import { spawnState } from '../../state/spawnAtom'; +import { MultipleAnimationModel } from 'src/components/Models/MultipleAnimationModel/MultipleAnimationModel.component'; +import { spawnState } from 'src/state/spawnAtom'; export const CAMERA = { TARGET: { @@ -145,6 +146,8 @@ export interface AvatarProps extends LightingProps, EnvironmentProps, Omit; + activeAnimation?: string; } /** @@ -155,6 +158,8 @@ export interface AvatarProps extends LightingProps, EnvironmentProps, Omit = ({ modelSrc, animationSrc = undefined, + animations = undefined, + activeAnimation = undefined, poseSrc = undefined, environment = 'soft', halfBody = false, @@ -199,6 +204,20 @@ const Avatar: FC = ({ return null; } + if (!!activeAnimation && !halfBody && animations) { + return ( + + ); + } + if (!!animationSrc && !halfBody && isValidFormat(animationSrc)) { return ( = ({ return ( ); - }, [halfBody, animationSrc, modelSrc, scale, poseSrc, idleRotation, emotion, onLoaded, headMovement, effects?.bloom]); + }, [ + modelSrc, + activeAnimation, + halfBody, + animations, + animationSrc, + poseSrc, + scale, + onLoaded, + emotion, + effects?.bloom, + idleRotation, + headMovement + ]); useEffect(() => triggerCallback(onLoading), [modelSrc, animationSrc, onLoading]); diff --git a/src/components/Models/MultipleAnimationModel/MultipleAnimationModel.component.tsx b/src/components/Models/MultipleAnimationModel/MultipleAnimationModel.component.tsx new file mode 100644 index 00000000..3cbef69d --- /dev/null +++ b/src/components/Models/MultipleAnimationModel/MultipleAnimationModel.component.tsx @@ -0,0 +1,87 @@ +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 { Model } from 'src/components/Models/Model'; +import { BaseModelProps } from 'src/types'; +import { useEmotion, useGltfCachedLoader, useIdleExpression } from 'src/services'; +import { Emotion } from 'src/components/Avatar/Avatar.component'; + +export interface MultipleAnimationModelProps extends BaseModelProps { + modelSrc: string | Blob; + animations: Record; + activeAnimation: string; + scale?: number; + emotion?: Emotion; +} + +export const MultipleAnimationModel: FC = ({ + modelSrc, + animations, + activeAnimation, + scale = 1, + onLoaded, + emotion, + bloom +}) => { + const groupRef = useRef(null); + const mixerRef = useRef(null); + 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); + + mixerRef.current = new AnimationMixer(scene); + } + + return () => { + mixerRef.current?.stopAllAction(); + }; + }, [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 newClip = loadedAnimations[activeAnimation]; + + if (!newClip || !mixer) return; + + const newAction = mixer.clipAction(newClip); + const prevAction = activeAction; + + if (prevAction) { + newAction.reset(); + newAction.crossFadeFrom(prevAction, 0.5, true); + } + + newAction.play(); + setActiveAction(newAction); + }, [activeAnimation, loadedAnimations, activeAction, nodes.Armature]); + + useFrame((state, delta) => { + mixerRef.current?.update(delta); + }); + + useEmotion(nodes, emotion); + useIdleExpression('blink', nodes); + + return ; +}; diff --git a/src/components/Models/MultipleAnimationModel/MultipleAnimationModel.container.tsx b/src/components/Models/MultipleAnimationModel/MultipleAnimationModel.container.tsx new file mode 100644 index 00000000..046477fc --- /dev/null +++ b/src/components/Models/MultipleAnimationModel/MultipleAnimationModel.container.tsx @@ -0,0 +1,16 @@ +import React, { FC, Suspense, useState } from 'react'; +import { MultipleAnimationModel, MultipleAnimationModelProps } from './MultipleAnimationModel.component'; + +/** + * Contains model to handle suspense fallback. + */ +export const MultipleAnimationModelContainer: FC = (props) => { + /* eslint-disable-next-line react/jsx-no-useless-fragment */ + const [fallback, setFallback] = useState(<>); + + return ( + + + + ); +}; diff --git a/src/components/Models/MultipleAnimationModel/index.ts b/src/components/Models/MultipleAnimationModel/index.ts new file mode 100644 index 00000000..83889092 --- /dev/null +++ b/src/components/Models/MultipleAnimationModel/index.ts @@ -0,0 +1 @@ +export { MultipleAnimationModelContainer as MultipleAnimationModel } from './MultipleAnimationModel.container'; diff --git a/src/services/Models.service.tsx b/src/services/Models.service.tsx index 632fcbc1..6c20c8b9 100644 --- a/src/services/Models.service.tsx +++ b/src/services/Models.service.tsx @@ -101,6 +101,7 @@ interface UseHeadMovement { rotationMargin?: Vector2; enabled?: boolean; } + /** * Avatar head movement relative to cursor. * When the model isn't a standard Ready Player Me avatar, the head movement won't take effect. @@ -231,6 +232,26 @@ export const useGltfLoader = (source: Blob | string): GLTF => { lifespan: 100 } ); +export const useGltfCachedLoader = (source: Blob | string): GLTF => { + const cachedGltf = useRef>(new Map()); + + return suspend(async (): Promise => { + if (cachedGltf.current.has(source as string)) { + return cachedGltf.current.get(source as string)!; + } + let result: GLTF; + if (source instanceof Blob) { + const buffer = await source.arrayBuffer(); + result = (await loader.parseAsync(buffer, '')) as GLTF; + } else { + result = await loader.loadAsync(source); + } + + cachedGltf.current.set(source as string, result); + return result; + }, [source]); +}; + export function usePersistantRotation(scene: Group) { const refToPreviousScene = useRef(scene);