Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export default function Home() {
const [eyePosition, setEyePosition] = useState({ x: 10, y: 10 });
const [modelReady, setModelReady] = useState(false);
const [clickerCount, setClickerCount] = useState(1);
const [testAnimation, setTestAnimation] = useState(false);

const { handleClick } = useClick({
setHearts,
Expand Down Expand Up @@ -41,10 +42,11 @@ export default function Home() {
return (
<div className="flex flex-col items-center justify-center min-h-screen relative text-center">

<ThreeJSComponent clickerCount={clickerCount} eyePosition={eyePosition} setReady={setReady} />
<ThreeJSComponent clickerCount={clickerCount} eyePosition={eyePosition} setReady={setReady} testAnimation={testAnimation} setTestAnimation={setTestAnimation} />
<div className="fixed inset-0 flex text-center left-1/4 items-center justify-center z-10 top-28 w-1/2 h-[80vh]">
<div className="relative inset-0 z-10 w-1/2 h-[80vh]" ref={heartContainerRef}>
<HeartComponent hearts={hearts} />
{!testAnimation && <button onClick={() => setTestAnimation(true)} className="mt-4 p-2 bg-blue-500 text-white rounded">Animate</button>}
</div>
</div>
</div>
Expand Down
29 changes: 26 additions & 3 deletions components/ThreeJs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,14 @@ const ThreeJSComponent = memo(function ThreeJSComponent({
clickerCount,
eyePosition,
setReady,
testAnimation,
setTestAnimation,
}: {
clickerCount: number;
eyePosition: { x: number; y: number };
setReady: () => void;
testAnimation: boolean;
setTestAnimation: (value: boolean) => void;
}) {
const containerRef = useRef(null);
const rendererRef = useRef(null);
Expand Down Expand Up @@ -44,6 +48,7 @@ const ThreeJSComponent = memo(function ThreeJSComponent({
const pose1ActionData = useRef(null);
const happyActionData = useRef(null);
const excitedActionData = useRef(null);
const testActionData = useRef(null);
const tapAnimations = [happyActionData, excitedActionData];
const idleFBXUrl = "/assets/models/Idle.fbx";
const loserFBXUrl = "/assets/models/Loser.fbx";
Expand All @@ -52,6 +57,7 @@ const ThreeJSComponent = memo(function ThreeJSComponent({
const pose1FBXUrl = "/assets/models/Pose_1.fbx";
const happyFBXURL = "/assets/models/Happy.fbx";
const excitedFBXURL = "/assets/models/Excited.fbx";
const testFBXURL = "/assets/models/Test.fbx";

const smileAndBowTimeoutRef = useRef(null); // Ref to store the smile timeout
const expressionTimeoutRef = useRef(null);
Expand Down Expand Up @@ -249,6 +255,23 @@ const ThreeJSComponent = memo(function ThreeJSComponent({
}, startWavingTimeout);
}, [animationsLoaded]);

useEffect(() => {
if (!testAnimation) return;
// don't let other animations or expressions play
setAnimationPlaying(true);
testActionData.current.reset();
testActionData.current.play();
idleActionData.current.crossFadeTo(testActionData.current, 0.4);
setTimeout(() => {
idleActionData.current.reset();
idleActionData.current.play();
testActionData.current.crossFadeTo(idleActionData.current, 0.4);
setAnimationPlaying(false);
}, 3000);
setTestAnimation(false);

}, [testAnimation, setTestAnimation]);

const handleAnimation = (counter) => {
if (counter % 40 === 0 && counter !== 0) {
// only play new animation if a current one is not playing
Expand Down Expand Up @@ -387,9 +410,6 @@ const ThreeJSComponent = memo(function ThreeJSComponent({
loadMixamoAnimation(wavingFBXUrl, currentVrm.current).then((clip) => {
wavingActionData.current = currentMixer.current.clipAction(clip);
}),
loadMixamoAnimation(idleFBXUrl, currentVrm.current).then((clip) => {
idleActionData.current = currentMixer.current.clipAction(clip);
}),
loadMixamoAnimation(loserFBXUrl, currentVrm.current).then((clip) => {
loserActionData.current = currentMixer.current.clipAction(clip);
}),
Expand All @@ -405,6 +425,9 @@ const ThreeJSComponent = memo(function ThreeJSComponent({
loadMixamoAnimation(excitedFBXURL, currentVrm.current).then((clip) => {
excitedActionData.current = currentMixer.current.clipAction(clip);
}),
loadMixamoAnimation(testFBXURL, currentVrm.current).then((clip) => {
testActionData.current = currentMixer.current.clipAction(clip);
}),
];

return Promise.all(promises);
Expand Down
Binary file removed public/assets/models/Spin.fbx
Binary file not shown.
Binary file added public/assets/models/Test.fbx
Binary file not shown.
60 changes: 60 additions & 0 deletions utils/armatureVRMRigMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* A map from common armature rig names to VRM Humanoid bone names
*/
export const armatureVRMRigMap = {
Hips: "hips",
Spine: "spine",
Spine1: "spine",
Spine2: "chest",
Spine3: "upperChest",
Neck: "neck",
Head: "head",
LeftShoulder: "leftShoulder",
LeftArm: "leftUpperArm",
LeftForeArm: "leftLowerArm",
LeftHand: "leftHand",
LeftHandThumb1: "leftThumbProximal",
LeftHandThumb2: "leftThumbIntermediate",
LeftHandThumb3: "leftThumbDistal",
LeftHandIndex1: "leftIndexProximal",
LeftHandIndex2: "leftIndexIntermediate",
LeftHandIndex3: "leftIndexDistal",
LeftHandMiddle1: "leftMiddleProximal",
LeftHandMiddle2: "leftMiddleIntermediate",
LeftHandMiddle3: "leftMiddleDistal",
LeftHandRing1: "leftRingProximal",
LeftHandRing2: "leftRingIntermediate",
LeftHandRing3: "leftRingDistal",
LeftHandPinky1: "leftLittleProximal",
LeftHandPinky2: "leftLittleIntermediate",
LeftHandPinky3: "leftLittleDistal",
RightShoulder: "rightShoulder",
RightArm: "rightUpperArm",
RightForeArm: "rightLowerArm",
RightHand: "rightHand",
RightHandThumb1: "rightThumbProximal",
RightHandThumb2: "rightThumbIntermediate",
RightHandThumb3: "rightThumbDistal",
RightHandIndex1: "rightIndexProximal",
RightHandIndex2: "rightIndexIntermediate",
RightHandIndex3: "rightIndexDistal",
RightHandMiddle1: "rightMiddleProximal",
RightHandMiddle2: "rightMiddleIntermediate",
RightHandMiddle3: "rightMiddleDistal",
RightHandRing1: "rightRingProximal",
RightHandRing2: "rightRingIntermediate",
RightHandRing3: "rightRingDistal",
RightHandPinky1: "rightLittleProximal",
RightHandPinky2: "rightLittleIntermediate",
RightHandPinky3: "rightLittleDistal",
LeftUpLeg: "leftUpperLeg",
LeftLeg: "leftLowerLeg",
LeftFoot: "leftFoot",
LeftToeBase: "leftToes",
RightUpLeg: "rightUpperLeg",
RightLeg: "rightLowerLeg",
RightFoot: "rightFoot",
RightToeBase: "rightToes",
HeadTop_End: null, // Optional, not mapped in VRM by default
};

66 changes: 50 additions & 16 deletions utils/loadMixamoAnimation.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import * as THREE from "three";
import { FBXLoader } from "three/examples/jsm/loaders/FBXLoader";
import { mixamoVRMRigMap } from "./mixamoVRMRigMap";
import { armatureVRMRigMap } from "./armatureVRMRigMap"; // Import your custom map

/**
* Load Mixamo animation, convert for three-vrm use, and return it.
* Load Mixamo or Armature animation, convert for three-vrm use, and return it.
*
* @param {string} url A url of mixamo animation data
* @param {string} url A url of mixamo or armature animation data
* @param {VRM} vrm A target VRM
* @returns {Promise<THREE.AnimationClip>} The converted AnimationClip
*/
export function loadMixamoAnimation(url, vrm) {
const loader = new FBXLoader(); // A loader which loads FBX
return loader.loadAsync(url).then((asset) => {
const clip = THREE.AnimationClip.findByName(asset.animations, "mixamo.com"); // extract the AnimationClip
const name = asset.animations[0].name;
const clip = THREE.AnimationClip.findByName(asset.animations, name); // extract the AnimationClip

const tracks = []; // KeyframeTracks compatible with VRM will be added here

Expand All @@ -21,36 +23,65 @@ export function loadMixamoAnimation(url, vrm) {
const _quatA = new THREE.Quaternion();
const _vec3 = new THREE.Vector3();

// Adjust with reference to hips height.
const motionHipsHeight = asset.getObjectByName("mixamorigHips").position.y;
const vrmHipsY = vrm.humanoid?.getNormalizedBoneNode("hips").getWorldPosition(_vec3).y;
// Determine which rig map to use
const rigMap = name.includes("mixamo.com") ? mixamoVRMRigMap : armatureVRMRigMap;

// Handle different naming conventions for hips bone
const hipsBoneName = name.includes("mixamo.com") ? "mixamorigHips" : "Hips"; // Adjust for non-mixamo rigs
const motionHips = asset.getObjectByName(hipsBoneName);

if (!motionHips) {
console.error("No hips bone found in the rig!");
return;
}

const motionHipsHeight = motionHips.position.y;
const vrmHips = vrm.humanoid?.getNormalizedBoneNode("hips");
const vrmHipsY = vrmHips?.getWorldPosition(_vec3).y;
const vrmRootY = vrm.scene.getWorldPosition(_vec3).y;
const vrmHipsHeight = Math.abs(vrmHipsY - vrmRootY);
const hipsPositionScale = vrmHipsHeight / motionHipsHeight;

let firstQuaternionTrackLength: number | null = null;

clip.tracks.forEach((track) => {
// Convert each tracks for VRM use, and push to `tracks`
// Convert each track for VRM use and push to `tracks`
const trackSplitted = track.name.split(".");
const mixamoRigName = trackSplitted[0];
const vrmBoneName = mixamoVRMRigMap[mixamoRigName];
const rigName = trackSplitted[0];
const vrmBoneName = rigMap[rigName];

if (!vrmBoneName) {
// If there is no corresponding VRM bone, skip this track
return;
}

const vrmNodeName = vrm.humanoid?.getNormalizedBoneNode(vrmBoneName)?.name;
const mixamoRigNode = asset.getObjectByName(mixamoRigName);
const rigNode = asset.getObjectByName(rigName);

if (vrmNodeName != null) {
if (vrmNodeName != null && rigNode) {
const propertyName = trackSplitted[1];

// Store rotations of rest-pose.
mixamoRigNode.getWorldQuaternion(restRotationInverse).invert();
mixamoRigNode.parent.getWorldQuaternion(parentRestWorldRotation);
rigNode.getWorldQuaternion(restRotationInverse).invert();
rigNode.parent.getWorldQuaternion(parentRestWorldRotation);

if (track instanceof THREE.QuaternionKeyframeTrack) {
// Retarget rotation of mixamoRig to NormalizedBone.
// Store the length of the first quaternion track's values
if (firstQuaternionTrackLength === null) {
firstQuaternionTrackLength = track.values.length;
}

// Check if the current track's values length is less than or equal to the first track's values length
if (track.values.length > firstQuaternionTrackLength) {
// Skip this track if its length is greater than the first track's length
return;
}
// Retarget rotation of rig to NormalizedBone.
for (let i = 0; i < track.values.length; i += 4) {
const flatQuaternion = track.values.slice(i, i + 4);

_quatA.fromArray(flatQuaternion);

// 親のレスト時ワールド回転 * トラックの回転 * レスト時ワールド回転の逆
// Parent's rest world rotation * track rotation * rest world rotation inverse
_quatA.premultiply(parentRestWorldRotation).multiply(restRotationInverse);

_quatA.toArray(flatQuaternion);
Expand All @@ -68,6 +99,9 @@ export function loadMixamoAnimation(url, vrm) {
),
);
} else if (track instanceof THREE.VectorKeyframeTrack) {
if (vrmBoneName != "hips" || propertyName.toString() !== "position") {
return;
}
const value = track.values.map((v, i) => (vrm.meta?.metaVersion === "0" && i % 3 !== 1 ? -v : v) * hipsPositionScale);
tracks.push(new THREE.VectorKeyframeTrack(`${vrmNodeName}.${propertyName}`, track.times, value));
}
Expand Down