Skip to content

Commit

Permalink
Animations | PORTAL-716 (#105)
Browse files Browse the repository at this point in the history
* feat: animations

* feat: animations

* fix: clampWhenFinished

* feat: cypress diff image updated

---------

Co-authored-by: dan-rpm <[email protected]>
  • Loading branch information
dan-rpm and dan-rpm authored Dec 12, 2024
1 parent 442abfc commit 489c1d9
Show file tree
Hide file tree
Showing 6 changed files with 101 additions and 60 deletions.
81 changes: 48 additions & 33 deletions src/App/components/Nova.tsx
Original file line number Diff line number Diff line change
@@ -1,61 +1,76 @@
import React, { useMemo } from 'react';
import React, { useMemo, useState, FC } 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 getAnimation = (name: string) => `https://readyplayerme-assets.s3.amazonaws.com/animations/${name}.glb`;
const getAnimationNexus = (name: string) =>
`https://readyplayerme-assets.s3.amazonaws.com/nexus/animations/${name}.glb`;

const models: Record<string, string> = {
one: 'https://avatars.readyplayer.me/673dc84d6874800e1db46095.glb?meshCompression=true&textureQuality=low&meshLOD=1&morphTargetsGroup=Basic expressions',
two: 'https://avatars.readyplayer.me/673f109553f9ed9312fafe70.glb?meshCompression=true&textureQuality=low&meshLOD=1&morphTargetsGroup=Basic expressions',
three:
'https://avatars.readyplayer.me/6745ca78c6e2d65e7b99d8e1.glb?meshCompression=true&textureQuality=low&meshLOD=1&morphTargetsGroup=Basic expressions',
four: 'https://avatars.readyplayer.me/6745df2bd7ae3ba1d340d019.glb?meshCompression=true&textureQuality=low&meshLOD=1&morphTargetsGroup=Basic expressions'
};
const modelSrc = 'https://avatars.readyplayer.dev/673b3cf27b275e2ca5be2800.glb';

export const AvatarNova: React.FC = () => {
const [activeAnimation, setActiveAnimation] = React.useState<string>('idle');
const [modelSrc, setModelSrc] = React.useState<string>(models.one);
export const AvatarNova: FC = () => {
const [activeAnimation, setActiveAnimation] = useState<string>('idle');

const animations = useMemo(
() => ({
idle: idleUrl,
victory: victoryUrl
idle: {
source: getAnimation('nova-male-idle')
},
victory: {
source: getAnimation('nova-victory-03'),
repeat: 1
},
idle5: {
source: getAnimationNexus('compressed_idle_nova_5'),
repeat: 1
},
idle53: {
source: getAnimationNexus('compressed_idle_nova_53'),
repeat: 1
},
idle7: {
source: getAnimationNexus('compressed_idle_nova_7'),
repeat: 1
},
idle80: {
source: getAnimationNexus('compressed_idle_nova_80'),
repeat: 1
},
top: {
source: getAnimation('novamale_changetopwear_v001'),
repeat: 1
},
bottom: {
source: getAnimation('novamale_changebottomwear_01_v001'),
repeat: 1
},
foot: {
source: getAnimation('NovaMale_ChangeFootwear_v002'),
repeat: 1
}
}),
[]
);

return (
<>
<SettingsPanel>
<button type="button" onClick={() => setActiveAnimation('idle')}>
Set idle animation
</button>
<button type="button" onClick={() => setActiveAnimation('victory')}>
Set victory animation
</button>
<button type="button" onClick={() => setModelSrc(models.one)}>
Set Model1
</button>
<button type="button" onClick={() => setModelSrc(models.two)}>
Set Model2
</button>
<button type="button" onClick={() => setModelSrc(models.three)}>
Set Model3
</button>
<button type="button" onClick={() => setModelSrc(models.four)}>
Set Model4
</button>
{Object.keys(animations).map((name) => (
<button key={name} type="button" onClick={() => setActiveAnimation(name)}>
Set {name} animation
</button>
))}
</SettingsPanel>
<Avatar
modelSrc={modelSrc}
emotion={emotions.smile}
animations={animations}
activeAnimation={activeAnimation}
shadows
onAnimationEnd={() => setActiveAnimation('idle')}
style={{ background: 'rgb(9,20,26)' }}
fov={45}
cameraInitialDistance={CAMERA.CONTROLS.FULL_BODY.MAX_DISTANCE}
Expand Down
23 changes: 10 additions & 13 deletions src/components/Avatar/Avatar.component.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { Suspense, FC, useMemo, CSSProperties, ReactNode, useEffect } from 'react';
import { Vector3 } from 'three';
import { AnimationAction, Vector3 } from 'three';
import { ContactShadows } from '@react-three/drei';
import { CameraControls } from 'src/components/Scene/CameraControls.component';
import { Environment } from 'src/components/Scene/Environment.component';
Expand All @@ -9,8 +9,8 @@ import {
SpawnState,
EffectConfiguration,
LightingProps,
AnimationConfiguration,
MaterialConfiguration
MaterialConfiguration,
AnimationsT
} from 'src/types';
import { BaseCanvas } from 'src/components/BaseCanvas';
import { AnimationModel, HalfBodyModel, StaticModel, PoseModel, MultipleAnimationModel } from 'src/components/Models';
Expand Down Expand Up @@ -153,16 +153,13 @@ export interface AvatarProps extends LightingProps, EnvironmentProps, Omit<BaseM
* Use any three.js(fiber, post-processing) compatible components to render in the scene.
*/
children?: ReactNode;
animations?: Record<string, string>;
animations?: AnimationsT;
activeAnimation?: string;
/**
* Control properties of animations.
*/
animationConfig?: AnimationConfiguration;
/**
* Control properties of materials.
*/
materialConfig?: MaterialConfiguration;
onAnimationEnd?: (action: AnimationAction) => void;
}

/**
Expand Down Expand Up @@ -207,7 +204,7 @@ const Avatar: FC<AvatarProps> = ({
backLightPosition,
lightTarget,
fov = 50,
animationConfig,
onAnimationEnd,
materialConfig
}) => {
const setSpawnState = useSetAtom(spawnState);
Expand All @@ -231,7 +228,7 @@ const Avatar: FC<AvatarProps> = ({
scale={scale}
onLoaded={onLoaded}
bloom={effects?.bloom}
animationConfig={animationConfig}
onAnimationEnd={onAnimationEnd}
materialConfig={materialConfig}
/>
);
Expand Down Expand Up @@ -303,10 +300,10 @@ const Avatar: FC<AvatarProps> = ({
onLoaded,
emotion,
effects?.bloom,
materialConfig,
onAnimationEnd,
idleRotation,
headMovement,
animationConfig,
materialConfig
headMovement
]);

useEffect(() => triggerCallback(onLoading), [modelSrc, animationSrc, onLoading]);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import React, { FC, useEffect, useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import { AnimationAction, AnimationMixer } from 'three';
import { AnimationAction, AnimationMixer, LoopOnce, LoopRepeat } from 'three';

import { Model } from 'src/components/Models/Model';
import { AnimationConfiguration, BaseModelProps } from 'src/types';
import { AnimationsT, BaseModelProps } from 'src/types';
import { useEmotion, useFallbackScene, useGltfCachedLoader, useIdleExpression } from 'src/services';
import { Emotion } from 'src/components/Avatar/Avatar.component';
import { useAnimations } from 'src/services/Animation.service';

export interface MultipleAnimationModelProps extends BaseModelProps {
modelSrc: string | Blob;
animations: Record<string, string>;
animations: AnimationsT;
activeAnimation: string;
scale?: number;
emotion?: Emotion;
animationConfig?: AnimationConfiguration;
onAnimationEnd?: (action: AnimationAction) => void;
}

export const MultipleAnimationModel: FC<MultipleAnimationModelProps> = ({
Expand All @@ -26,8 +26,8 @@ export const MultipleAnimationModel: FC<MultipleAnimationModelProps> = ({
onLoaded,
emotion,
bloom,
animationConfig,
materialConfig
materialConfig,
onAnimationEnd
}) => {
const mixerRef = useRef<AnimationMixer | null>(null);
const activeActionRef = useRef<AnimationAction | null>(null);
Expand All @@ -43,7 +43,6 @@ export const MultipleAnimationModel: FC<MultipleAnimationModelProps> = ({

if (activeActionRef.current) {
const newAction = mixer.clipAction(activeActionRef.current.getClip());

newAction.play();
mixer.update(animationTimeRef.current);

Expand All @@ -62,21 +61,41 @@ export const MultipleAnimationModel: FC<MultipleAnimationModelProps> = ({
const mixer = mixerRef.current;
const prevAction = activeActionRef.current;
const newClip = loadedAnimations[activeAnimation];
const animationConfig = animations[activeAnimation];

if (!newClip || !mixer) return;
if (!newClip || !mixer || !animationConfig) return;
if (prevAction && prevAction.getClip().name === newClip.name) return;

const newAction = mixer.clipAction(newClip);
activeActionRef.current = newAction;
const loopCount = animationConfig.repeat ?? Infinity;
const fadeTime = animationConfig.fadeTime ?? 0.5;

newAction.setLoop(loopCount === Infinity ? LoopRepeat : LoopOnce, loopCount);
newAction.clampWhenFinished = true;

const handleAnimationEnd = (event: { action: AnimationAction }) => {
if (event.action === newAction) {
onAnimationEnd?.(newAction);
}
};

mixer.addEventListener('finished', handleAnimationEnd);

if (prevAction) {
prevAction.fadeOut(fadeTime);
newAction.reset().fadeIn(fadeTime);
} else {
newAction.reset();
newAction.crossFadeFrom(prevAction, animationConfig?.crossfadeDuration ?? 0.5, true);
}

newAction.play();
mixer.update(0);
}, [activeAnimation, loadedAnimations, animationConfig]);
activeActionRef.current = newAction;

// eslint-disable-next-line consistent-return
return () => {
mixer.removeEventListener('finished', handleAnimationEnd);
};
}, [activeAnimation, animations, loadedAnimations, onAnimationEnd]);

useFrame((state, delta) => {
mixerRef.current?.update(delta);
Expand Down
5 changes: 3 additions & 2 deletions src/services/Animation.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AnimationClip, Group } from 'three';
import { FBXLoader, GLTFLoader } from 'three-stdlib';
import { suspend } from 'suspend-react';
import { MeshoptDecoder } from './meshopt_decoder';
import { AnimationsT } from '../types';

interface ClipWithType {
group: Group;
Expand Down Expand Up @@ -72,13 +73,13 @@ export const loadAnimationClip = async (source: Blob | string): Promise<Animatio
return animation.isFbx ? normaliseFbxAnimation(animation.group) : animation.group.animations[0];
};

export const useAnimations = (animations: Record<string, string>) =>
export const useAnimations = (animations: AnimationsT) =>
suspend(async (): Promise<Record<string, AnimationClip>> => {
const clips: Record<string, AnimationClip> = {};

await Promise.all(
Object.keys(animations).map(async (name) => {
const newClip = await loadAnimationClip(animations[name]);
const newClip = await loadAnimationClip(animations[name].source);
clips[name] = newClip;
})
);
Expand Down
9 changes: 9 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,3 +194,12 @@ export type MaterialConfiguration = {
*/
emissiveIntensity?: number;
};

export type AnimationsT = Record<
string,
{
source: string;
repeat?: number;
fadeTime?: number;
}
>;
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 489c1d9

Please sign in to comment.