Release Healer Man 0.1.26
This commit is contained in:
1 parent
e7cc588bfc
commit
147c2804d4
27 files changed
+1010
-55
No files matched your search
@@ -25,9 +25,12 @@ import {
|
||||
type Material,
|
||||
type Mesh,
|
||||
type Object3D,
|
||||
type Skeleton,
|
||||
type SkinnedMesh,
|
||||
type Texture,
|
||||
} from "three";
|
||||
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||
import { shareClonedSkeletons } from "./sharedSkeletons";
|
||||
import type {
|
||||
CharacterAppearance,
|
||||
ClassId,
|
||||
@@ -123,6 +126,7 @@ function configureMaterial(material: Material): void {
|
||||
|
||||
function cloneAvatarScene(root: Object3D): Object3D {
|
||||
const scene = cloneSkeleton(root);
|
||||
shareClonedSkeletons(scene);
|
||||
scene.traverse((node) => {
|
||||
const mesh = node as Mesh;
|
||||
if (!mesh.isMesh) return;
|
||||
@@ -267,13 +271,17 @@ function LoadedCharacterModel({
|
||||
|
||||
useEffect(() => () => {
|
||||
const ownedMaterials = new Set<Material>();
|
||||
const ownedSkeletons = new Set<Skeleton>();
|
||||
scene.traverse((node) => {
|
||||
const skinned = node as SkinnedMesh;
|
||||
if (skinned.isSkinnedMesh) ownedSkeletons.add(skinned.skeleton);
|
||||
const mesh = node as Mesh;
|
||||
if (!mesh.isMesh) return;
|
||||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
||||
materials.forEach((material) => ownedMaterials.add(material));
|
||||
});
|
||||
ownedMaterials.forEach((material) => material.dispose());
|
||||
ownedSkeletons.forEach((skeleton) => skeleton.dispose());
|
||||
}, [scene]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -359,22 +367,33 @@ function LoadedCharacterModel({
|
||||
?? gltf.animations.find((candidate) => candidate.name.toLowerCase().includes(normalized));
|
||||
};
|
||||
|
||||
const selectClip = (motion: CharacterBaseMotion): AnimationClip | undefined => (
|
||||
selectCharacterBaseClip(gltf.animations, motion, weaponStance)
|
||||
?? namedClip(entry.animations[motion])
|
||||
?? namedClip(entry.animations.idle)
|
||||
?? gltf.animations[0]
|
||||
);
|
||||
// The GLTF clip library and weapon stance change far less often than frames.
|
||||
// Resolve locomotion once so five party members do not repeatedly parse and
|
||||
// sort the same hundreds of animation names while standing or running.
|
||||
const baseClips = useMemo(() => Object.fromEntries(
|
||||
(["idle", "walk", "run"] as const).map((motion) => [
|
||||
motion,
|
||||
selectCharacterBaseClip(gltf.animations, motion, weaponStance)
|
||||
?? namedClip(entry.animations[motion])
|
||||
?? namedClip(entry.animations.idle)
|
||||
?? gltf.animations[0],
|
||||
]),
|
||||
) as Record<CharacterBaseMotion, AnimationClip | undefined>, [gltf.animations, entry.animations, weaponStance]);
|
||||
const verticalClips = useMemo(() => ({
|
||||
rising: [selectCharacterVerticalClip(gltf.animations, "rising", false), selectCharacterVerticalClip(gltf.animations, "rising", true)],
|
||||
falling: [selectCharacterVerticalClip(gltf.animations, "falling", false), selectCharacterVerticalClip(gltf.animations, "falling", true)],
|
||||
landing: [selectCharacterVerticalClip(gltf.animations, "landing", false), selectCharacterVerticalClip(gltf.animations, "landing", true)],
|
||||
}), [gltf.animations]);
|
||||
|
||||
const switchMotion = (motion: CharacterLocomotionMotion, moving: boolean) => {
|
||||
if (overrideAction.current) return;
|
||||
const verticalSelection = motion === "rising" || motion === "falling" || motion === "landing"
|
||||
? selectCharacterVerticalClip(gltf.animations, motion, moving)
|
||||
? verticalClips[motion][moving ? 1 : 0]
|
||||
: null;
|
||||
const fallbackMotion: CharacterBaseMotion = moving ? "run" : "idle";
|
||||
const clip = verticalSelection?.clip ?? selectClip(
|
||||
motion === "idle" || motion === "walk" || motion === "run" ? motion : fallbackMotion,
|
||||
);
|
||||
const clip = verticalSelection?.clip ?? baseClips[
|
||||
motion === "idle" || motion === "walk" || motion === "run" ? motion : fallbackMotion
|
||||
];
|
||||
if (!clip) return;
|
||||
if (currentMotion.current === motion && currentAction.current?.getClip() === clip) return;
|
||||
const loop = verticalSelection?.loop ?? true;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Bone, BufferGeometry, Float32BufferAttribute, Group, Matrix4, MeshBasicMaterial, Skeleton, SkinnedMesh, Vector3 } from "three";
|
||||
import { clone } from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||
import { shareClonedSkeletons } from "./sharedSkeletons";
|
||||
|
||||
function skinnedMesh(bone: Bone, inverse = new Matrix4()): SkinnedMesh {
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new Float32BufferAttribute([1, 0, 0], 3));
|
||||
geometry.setAttribute("skinIndex", new Float32BufferAttribute([0, 0, 0, 0], 4));
|
||||
geometry.setAttribute("skinWeight", new Float32BufferAttribute([1, 0, 0, 0], 4));
|
||||
const mesh = new SkinnedMesh(geometry, new MeshBasicMaterial());
|
||||
mesh.bind(new Skeleton([bone], [inverse]), new Matrix4());
|
||||
return mesh;
|
||||
}
|
||||
|
||||
function character(): Group {
|
||||
const root = new Group();
|
||||
const bone = new Bone();
|
||||
bone.name = "spine";
|
||||
root.add(bone, skinnedMesh(bone), skinnedMesh(bone));
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("shared skeleton palettes in character clones", () => {
|
||||
it("preserves posed vertices and each mesh's bind transform while sharing uploads", () => {
|
||||
const root = character();
|
||||
const first = root.children[1] as SkinnedMesh;
|
||||
const second = root.children[2] as SkinnedMesh;
|
||||
second.bindMatrix.makeTranslation(0, 2, 0);
|
||||
root.children[0].rotation.z = 0.5;
|
||||
root.updateMatrixWorld(true);
|
||||
const poseBefore = [first, second].map((mesh) => mesh.applyBoneTransform(0, new Vector3(1, 0, 0)));
|
||||
const bindBefore = second.bindMatrix.clone();
|
||||
expect(shareClonedSkeletons(root)).toBe(1);
|
||||
expect(first.skeleton).toBe(second.skeleton);
|
||||
expect(second.bindMatrix.equals(bindBefore)).toBe(true);
|
||||
[first, second].forEach((mesh, index) => {
|
||||
expect(mesh.applyBoneTransform(0, new Vector3(1, 0, 0)).distanceTo(poseBefore[index])).toBeLessThan(1e-9);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps separate cloned characters and the loader source independently animated", () => {
|
||||
const source = character();
|
||||
const first = clone(source);
|
||||
const second = clone(source);
|
||||
shareClonedSkeletons(first);
|
||||
shareClonedSkeletons(second);
|
||||
const firstMesh = first.children[1] as SkinnedMesh;
|
||||
const secondMesh = second.children[1] as SkinnedMesh;
|
||||
expect(firstMesh.skeleton).not.toBe(secondMesh.skeleton);
|
||||
expect(firstMesh.skeleton.bones[0]).not.toBe(secondMesh.skeleton.bones[0]);
|
||||
first.children[0].position.y = 3;
|
||||
first.updateMatrixWorld(true);
|
||||
second.updateMatrixWorld(true);
|
||||
expect(firstMesh.applyBoneTransform(0, new Vector3(1, 0, 0)).y).toBe(3);
|
||||
expect(secondMesh.applyBoneTransform(0, new Vector3(1, 0, 0)).y).toBe(0);
|
||||
expect(source.children[0].position.y).toBe(0);
|
||||
});
|
||||
|
||||
it("retains different inverse bind matrices and different bone objects", () => {
|
||||
const root = character();
|
||||
const otherBone = new Bone();
|
||||
otherBone.name = "spine";
|
||||
root.add(skinnedMesh(root.children[0] as Bone, new Matrix4().makeTranslation(1, 0, 0)));
|
||||
root.add(otherBone, skinnedMesh(otherBone));
|
||||
expect(shareClonedSkeletons(root)).toBe(1);
|
||||
const meshes = root.children.filter((node) => (node as SkinnedMesh).isSkinnedMesh) as SkinnedMesh[];
|
||||
expect(new Set(meshes.map((mesh) => mesh.skeleton)).size).toBe(3);
|
||||
});
|
||||
|
||||
it("disposes redundant bone textures once and tolerates already shared skeletons", () => {
|
||||
const root = character();
|
||||
const first = root.children[1] as SkinnedMesh;
|
||||
const second = root.children[2] as SkinnedMesh;
|
||||
second.skeleton.computeBoneTexture();
|
||||
const dispose = vi.spyOn(second.skeleton.boneTexture!, "dispose");
|
||||
expect(shareClonedSkeletons(root)).toBe(1);
|
||||
expect(dispose).toHaveBeenCalledTimes(1);
|
||||
expect(shareClonedSkeletons(root)).toBe(0);
|
||||
expect(first.skeleton).toBe(second.skeleton);
|
||||
expect(dispose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Object3D, Skeleton, SkinnedMesh } from "three";
|
||||
|
||||
function samePalette(left: Skeleton, right: Skeleton): boolean {
|
||||
return left.bones.length === right.bones.length
|
||||
&& left.boneInverses.length === right.boneInverses.length
|
||||
&& left.bones.every((bone, index) => bone === right.bones[index])
|
||||
&& left.boneInverses.every((inverse, index) => inverse.equals(right.boneInverses[index]));
|
||||
}
|
||||
|
||||
/** Share identical palettes within an owned clone, never across characters. */
|
||||
export function shareClonedSkeletons(root: Object3D): number {
|
||||
const palettes = new Map<Skeleton["bones"][number] | undefined, Skeleton[]>();
|
||||
const redundant = new Set<Skeleton>();
|
||||
root.traverse((node) => {
|
||||
const mesh = node as SkinnedMesh;
|
||||
if (!mesh.isSkinnedMesh) return;
|
||||
const skeleton = mesh.skeleton;
|
||||
const firstBone = skeleton.bones[0];
|
||||
const candidates = palettes.get(firstBone) ?? [];
|
||||
const shared = candidates.find((candidate) => samePalette(candidate, skeleton));
|
||||
if (shared) {
|
||||
if (shared !== skeleton) {
|
||||
mesh.skeleton = shared;
|
||||
redundant.add(skeleton);
|
||||
}
|
||||
} else {
|
||||
candidates.push(skeleton);
|
||||
palettes.set(firstBone, candidates);
|
||||
}
|
||||
});
|
||||
for (const skeleton of redundant) skeleton.dispose();
|
||||
return redundant.size;
|
||||
}
|
||||
Reference in new issue
Block a user