Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Avatar Editor refactor #60

Merged
merged 2 commits into from
Nov 8, 2023
Merged
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
41 changes: 29 additions & 12 deletions example/web-avatar-client/src/AvatarEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,45 @@ import {
AvatarVisualizer,
CharacterPartsSelector,
CollectionDataType,
CharacterComposition,
Character,
ModelLoader,
BodyPartTypes,
CharacterComposition,
} from "@mml-io/3d-web-standalone-avatar-editor";
import React from "react";
import { Object3D } from "three";

import idleAnimationURL from "../../assets/avatar/anims/AS_Andor_Stand_Idle.glb";
import hdrURL from "../../assets/hdr/industrial_sunset_2k.hdr";

export function AvatarEditor(props: { collectionData: CollectionDataType }) {
type BodyPartTypes = "fullBody" | "head" | "upperBody" | "lowerBody" | "feet";

const partToCameraOffset = new Map<
BodyPartTypes,
{
offset: { x: number; y: number; z: number };
targetDistance: number;
}
>([
["head", { offset: { x: 0, y: 1.616079270843859, z: 0 }, targetDistance: 0.8 }],
["lowerBody", { offset: { x: 0, y: 0.6694667063220178, z: 0 }, targetDistance: 1.3 }],
["feet", { offset: { x: 0, y: 0.2194667063220177, z: 0 }, targetDistance: 0.9 }],
["fullBody", { offset: { x: 0, y: 1.079590141424593, z: 0 }, targetDistance: 2.5 }],
["upperBody", { offset: { x: 0, y: 1.199837285184325, z: 0 }, targetDistance: 1.2 }],
]);

export function AvatarEditor<C extends CollectionDataType>(props: { collectionData: C }) {
const [characterMesh, setCharacterMesh] = React.useState<Object3D | null>(null);
const [character] = React.useState(new Character(new ModelLoader()));
const [selectedPart, setSelectedPart] = React.useState<BodyPartTypes>("fullBody");

const onComposedCharacter = React.useCallback(
async (characterParts: CharacterComposition) => {
const { fullBody, head, upperBody, lowerBody, feet } = characterParts;
async (characterParts: CharacterComposition<C>) => {
const { fullBody, parts } = characterParts;

// The character parts picker provides the full body separately from the parts that are then layered onto it
const obj3d = await character.mergeBodyParts(
fullBody.asset,
head.asset,
upperBody.asset,
lowerBody.asset,
feet.asset,
fullBody.url,
Object.values(parts).map((part) => part.url),
);
setCharacterMesh(obj3d);
setSelectedPart("fullBody");
Expand All @@ -41,19 +54,23 @@ export function AvatarEditor(props: { collectionData: CollectionDataType }) {
}
};

const partEntry = partToCameraOffset.get(selectedPart)!;

return (
<>
<CharacterPartsSelector
onSelectingPart={onSelectingPart}
onComposedCharacter={onComposedCharacter}
fullBodyKey="fullBody"
collectionData={props.collectionData}
onComposedCharacter={onComposedCharacter}
/>
{characterMesh && (
<AvatarVisualizer
characterMesh={characterMesh}
hdrURL={hdrURL}
idleAnimationURL={idleAnimationURL}
selectedPart={selectedPart}
cameraTargetDistance={partEntry.targetDistance}
cameraTargetOffset={partEntry.offset}
/>
)}
</>
Expand Down
1 change: 0 additions & 1 deletion example/web-avatar-client/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import collectionData from "./collection.json";

class App {
root: Root;

constructor() {
this.init();
}
Expand Down
85 changes: 44 additions & 41 deletions packages/3d-web-avatar-editor-ui/src/CharacterPartsSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,63 +1,66 @@
import React, { useCallback, useEffect, useState } from "react";

import { AssetDescription, BodyPartTypes, CharacterComposition, CollectionDataType } from "./types";
import { AssetDescription, CharacterComposition, CollectionDataType } from "./types";

type CharacterPartsSelectorProps = {
collectionData: CollectionDataType;
onSelectingPart: (part: BodyPartTypes) => void;
onComposedCharacter: (characterParts: CharacterComposition) => void;
type CharacterPartsSelectorProps<C extends CollectionDataType> = {
collectionData: C;
fullBodyKey: keyof C;
onSelectingPart: (part: keyof C) => void;
onComposedCharacter: (characterComposition: CharacterComposition<C>) => void;
};

export const CharacterPartsSelector: React.FC<CharacterPartsSelectorProps> = ({
export function CharacterPartsSelector<C extends CollectionDataType>({
collectionData,
fullBodyKey,
onSelectingPart,
onComposedCharacter,
}) => {
const [selectedPart, setSelectedPart] = useState<BodyPartTypes | null>(null);
const [currentSelection, setCurrentSelection] = useState({
fullBody: collectionData.fullBody[0],
head: collectionData.head[0],
upperBody: collectionData.upperBody[0],
lowerBody: collectionData.lowerBody[0],
feet: collectionData.feet[0],
});
}: CharacterPartsSelectorProps<C>) {
const [selectedPart, setSelectedPart] = useState<string | null>(null);
const [currentSelection, setCurrentSelection] = useState<Record<keyof C, AssetDescription>>(() =>
Object.entries(collectionData).reduce<Record<keyof C, AssetDescription>>(
(
acc: Record<keyof C, AssetDescription>,
[key, asset]: [keyof C, Array<AssetDescription>],
) => {
acc[key] = asset[0];
return acc;
},
{} as Record<keyof C, AssetDescription>,
),
);

const createMMLDescription = useCallback(() => {
const description = `<m-character src="${currentSelection.fullBody.asset}">
<m-model src="${currentSelection.head.asset}"></m-model>
<m-model src="${currentSelection.upperBody.asset}"></m-model>
<m-model src="${currentSelection.lowerBody.asset}"></m-model>
<m-model src="${currentSelection.feet.asset}"></m-model>
const fullBody = currentSelection[fullBodyKey];
const remainingParts = Object.entries(currentSelection).filter(([key]) => key !== fullBodyKey);
const description = `<m-character src="${fullBody}">
${remainingParts.map(([key, asset]) => `<m-model src="${asset.asset}"></m-model>`).join("\n")}
</m-character>
`;
console.log(description);
}, [currentSelection]);
}, [currentSelection, fullBodyKey]);

useEffect(() => {
const fullBody = currentSelection[fullBodyKey];
const remainingParts = Object.entries(currentSelection).filter(([key]) => key !== fullBodyKey);
onComposedCharacter({
fullBody: currentSelection.fullBody,
head: currentSelection.head,
upperBody: currentSelection.upperBody,
lowerBody: currentSelection.lowerBody,
feet: currentSelection.feet,
fullBody: { url: fullBody.asset },
parts: remainingParts.reduce(
(accParts: Record<keyof C, { url: string }>, [key, asset]: [keyof C, AssetDescription]) => {
accParts[key] = { url: asset.asset };
return accParts;
},
{} as Record<keyof C, { url: string }>,
),
});
createMMLDescription();
}, [
onComposedCharacter,
currentSelection.fullBody,
currentSelection.head,
currentSelection.upperBody,
currentSelection.lowerBody,
currentSelection.feet,
createMMLDescription,
]);
}, [onComposedCharacter, fullBodyKey, currentSelection, createMMLDescription]);

const handleThumbnailClick = (part: BodyPartTypes) => {
const handleThumbnailClick = (part: string) => {
onSelectingPart(part);
setSelectedPart(part);
};

const handleModalThumbnailClick = (part: BodyPartTypes, item: AssetDescription) => {
const handleModalThumbnailClick = (part: string, item: AssetDescription) => {
const selectedData = item;
setCurrentSelection((prev) => ({ ...prev, [part]: selectedData }));
createMMLDescription();
Expand All @@ -67,12 +70,12 @@ export const CharacterPartsSelector: React.FC<CharacterPartsSelectorProps> = ({
const renderThumbnails = () => {
return (
<div className="left-thumbnails">
{["fullBody", "head", "upperBody", "lowerBody", "feet"].map((part) => (
{Object.keys(collectionData).map((part) => (
<img
key={part}
src={currentSelection[part as BodyPartTypes].thumb}
src={currentSelection[part].thumb}
alt={part}
onClick={() => handleThumbnailClick(part as BodyPartTypes)}
onClick={() => handleThumbnailClick(part as string)}
/>
))}
</div>
Expand Down Expand Up @@ -103,6 +106,6 @@ export const CharacterPartsSelector: React.FC<CharacterPartsSelectorProps> = ({
{renderModal()}
</div>
);
};
}

export default CharacterPartsSelector;
7 changes: 1 addition & 6 deletions packages/3d-web-avatar-editor-ui/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,2 @@
export { CharacterPartsSelector } from "./CharacterPartsSelector";
export type {
AssetDescription,
CollectionDataType,
BodyPartTypes,
CharacterComposition,
} from "./types";
export type { AssetDescription, CollectionDataType, CharacterComposition } from "./types";
14 changes: 10 additions & 4 deletions packages/3d-web-avatar-editor-ui/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
export type BodyPartTypes = "fullBody" | "head" | "upperBody" | "lowerBody" | "feet";

export type AssetDescription = {
name: string;
asset: string;
thumb: string;
};

export type CollectionDataType = Record<BodyPartTypes, Array<AssetDescription>>;
export type CollectionDataType = Record<string, Array<AssetDescription>>;

export type CharacterComposition = Record<BodyPartTypes, AssetDescription>;
export type CharacterComposition<C extends CollectionDataType> = {
fullBody: { url: string };
parts: Record<
keyof C,
{
url: string;
}
>;
};
78 changes: 22 additions & 56 deletions packages/3d-web-avatar/src/character/Character.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,20 @@ export class Character {

constructor(private modelLoader: ModelLoader) {}

public async mergeBodyParts(
fullBodyURL: string,
headURL: string,
upperBodyURL: string,
lowerBodyURL: string,
feetURL: string,
): Promise<Object3D> {
public async mergeBodyParts(fullBodyURL: string, bodyParts: Array<string>): Promise<Object3D> {
const fullBodyAsset = await this.modelLoader.load(fullBodyURL);
const fullBodyGLTF = this.skeletonHelpers.cloneGLTF(fullBodyAsset as GLTF, "fullBody");

const headAsset = await this.modelLoader.load(headURL);
const upperBodyAsset = await this.modelLoader.load(upperBodyURL);
const lowerBodyAsset = await this.modelLoader.load(lowerBodyURL);
const feetAsset = await this.modelLoader.load(feetURL);

const skinnedMeshesToRemove: SkinnedMesh[] = [];
const assetPromises: Array<Promise<{ asset: GLTF; partUrl: string }>> = bodyParts.map(
(partUrl) => {
return new Promise((resolve) => {
this.modelLoader.load(partUrl).then((asset) => {
resolve({ asset: asset!, partUrl });
});
});
},
);
const assets = await Promise.all(assetPromises);

const fullBodyModelGroup = fullBodyGLTF.gltf.scene;

Expand All @@ -45,49 +43,17 @@ export class Character {
this.sharedSkeleton = fullBodyGLTF.sharedSkeleton;
this.sharedMatrixWorld = fullBodyGLTF.matrixWorld;

skinnedMeshesToRemove.forEach((child) => {
child.removeFromParent();
});

const headGLTF = this.skeletonHelpers.cloneGLTF(headAsset as GLTF, "headGLTF");
const headModelGroup = headGLTF.gltf.scene;
headModelGroup.traverse((child) => {
if (child.type === "SkinnedMesh") {
(child as SkinnedMesh).castShadow = true;
(child as SkinnedMesh).bind(this.sharedSkeleton!, this.sharedMatrixWorld!);
this.skinnedMeshesParent?.children.splice(0, 0, child as SkinnedMesh);
}
});

const upperBodyGLTF = this.skeletonHelpers.cloneGLTF(upperBodyAsset as GLTF, "upperBodyGLTF");
const upperBodyModelGroup = upperBodyGLTF.gltf.scene;
upperBodyModelGroup.traverse((child) => {
if (child.type === "SkinnedMesh") {
(child as SkinnedMesh).castShadow = true;
(child as SkinnedMesh).bind(this.sharedSkeleton!, this.sharedMatrixWorld!);
this.skinnedMeshesParent?.children.splice(1, 0, child as SkinnedMesh);
}
});

const lowerBodyGLTF = this.skeletonHelpers.cloneGLTF(lowerBodyAsset as GLTF, "lowerBodyGLTF");
const lowerBodyModelGroup = lowerBodyGLTF.gltf.scene;
lowerBodyModelGroup.traverse((child) => {
if (child.type === "SkinnedMesh") {
(child as SkinnedMesh).castShadow = true;
(child as SkinnedMesh).bind(this.sharedSkeleton!, this.sharedMatrixWorld!);
this.skinnedMeshesParent?.children.splice(2, 0, child as SkinnedMesh);
}
});

const feetGLTF = this.skeletonHelpers.cloneGLTF(feetAsset as GLTF, "feetGLTF");
const feetModelGroup = feetGLTF.gltf.scene;
feetModelGroup.traverse((child) => {
if (child.type === "SkinnedMesh") {
(child as SkinnedMesh).castShadow = true;
(child as SkinnedMesh).bind(this.sharedSkeleton!, this.sharedMatrixWorld!);
this.skinnedMeshesParent?.children.splice(3, 0, child as SkinnedMesh);
}
});
for (const loadingAsset of assets) {
const gltf = this.skeletonHelpers.cloneGLTF(loadingAsset.asset, loadingAsset.partUrl);
const modelGroup = gltf.gltf.scene;
modelGroup.traverse((child) => {
if (child.type === "SkinnedMesh") {
(child as SkinnedMesh).castShadow = true;
(child as SkinnedMesh).bind(this.sharedSkeleton!, this.sharedMatrixWorld!);
this.skinnedMeshesParent?.children.splice(3, 0, child as SkinnedMesh);
}
});
}

return fullBodyGLTF!.gltf.scene as Object3D;
}
Expand Down
10 changes: 4 additions & 6 deletions packages/3d-web-client-core/src/camera/CameraManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ export class CameraManager {

private lerpFactor: number = 0;
private lerpDuration: number = 2.1;
private fixedOnTarget: boolean = false;

constructor(
targetElement: HTMLElement,
Expand Down Expand Up @@ -80,7 +79,6 @@ export class CameraManager {

private onMouseMove(event: MouseEvent): void {
if (!this.dragging || getTweakpaneActive()) return;
this.fixedOnTarget = false;
if (this.targetTheta === null || this.targetPhi === null) return;
this.targetTheta += event.movementX * 0.01;
this.targetPhi -= event.movementY * 0.01;
Expand All @@ -89,7 +87,6 @@ export class CameraManager {
}

private onMouseWheel(event: WheelEvent): void {
this.fixedOnTarget = false;
const scrollAmount = event.deltaY * 0.001;
this.targetDistance += scrollAmount;
this.targetDistance = Math.max(
Expand All @@ -115,9 +112,10 @@ export class CameraManager {
}
}

public setLerpedTarget(target: Vector3): void {
public setLerpedTarget(target: Vector3, targetDistance: number): void {
this.isLerping = true;
this.fixedOnTarget = true;
this.targetDistance = targetDistance;
this.desiredDistance = targetDistance;
this.setTarget(target);
}

Expand Down Expand Up @@ -167,7 +165,7 @@ export class CameraManager {
this.lerpFactor += 0.01 / this.lerpDuration;
this.lerpFactor = Math.min(1, this.lerpFactor);
this.target.lerpVectors(this.lerpTarget, this.finalTarget, this.easeOutExpo(this.lerpFactor));
} else if (!this.fixedOnTarget) {
} else {
this.adjustCameraPosition();
}

Expand Down
Loading