Browser reference stack for PSX-era third-person adventure: fixed cameras, inventory puzzles, Box3D physics, host-authoritative P2P co-op, and a modular character harness. Ships the Ashgrove Precinct Level 1 investigation demo with a full cast and nine linked rooms.
235 lines
8.6 KiB
TypeScript
235 lines
8.6 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { AnimationClip as ThreeClip, Bone, SkinnedMesh } from "three";
|
|
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
|
import { buildDefaultClips } from "../anim/procedural.js";
|
|
import { analyzeReference } from "../image/analyze.js";
|
|
import { denoise, segmentForeground } from "../image/segment.js";
|
|
import { DEFAULT_FIGURE, renderFigure } from "../image/testFixtures.js";
|
|
import { buildHumanoidMesh } from "../mesh/humanoid.js";
|
|
import { triangleCount, vertexCount } from "../mesh/MeshBuilder.js";
|
|
import { buildSkeleton } from "../rig/skeleton.js";
|
|
import { computeInverseBindMatrices, computeSkinWeights, skinStatistics } from "../rig/skin.js";
|
|
import { exportGlb } from "./glb.js";
|
|
|
|
/**
|
|
* The exporter is hand-rolled, so "it produced bytes" proves nothing. These
|
|
* tests feed the output back through three's own `GLTFLoader` — the exact
|
|
* parser the runtime uses — and assert on the reconstructed scene graph.
|
|
*/
|
|
|
|
function buildCharacter() {
|
|
const raster = renderFigure(DEFAULT_FIGURE);
|
|
const mask = denoise(segmentForeground(raster));
|
|
const analysis = analyzeReference(raster, mask);
|
|
|
|
const skeleton = buildSkeleton(analysis.measurements, { height: 1.7 });
|
|
const mesh = buildHumanoidMesh(skeleton, analysis.measurements, analysis.palette);
|
|
computeSkinWeights(mesh, skeleton);
|
|
|
|
const inverseBindMatrices = computeInverseBindMatrices(skeleton);
|
|
const clips = buildDefaultClips();
|
|
const glb = exportGlb(mesh, skeleton, inverseBindMatrices, { name: "TestCharacter", clips });
|
|
|
|
return { glb, mesh, skeleton, clips, analysis };
|
|
}
|
|
|
|
/** Parses GLB bytes with the real loader. */
|
|
function parseGlb(glb: Uint8Array) {
|
|
const loader = new GLTFLoader();
|
|
const buffer = glb.buffer.slice(glb.byteOffset, glb.byteOffset + glb.byteLength) as ArrayBuffer;
|
|
|
|
return new Promise<import("three/examples/jsm/loaders/GLTFLoader.js").GLTF>((resolve, reject) => {
|
|
loader.parse(buffer, "", resolve, reject);
|
|
});
|
|
}
|
|
|
|
describe("GLB container", () => {
|
|
it("writes a valid header", () => {
|
|
const { glb } = buildCharacter();
|
|
const view = new DataView(glb.buffer, glb.byteOffset, glb.byteLength);
|
|
|
|
expect(view.getUint32(0, true)).toBe(0x46546c67); // "glTF"
|
|
expect(view.getUint32(4, true)).toBe(2);
|
|
expect(view.getUint32(8, true)).toBe(glb.byteLength);
|
|
});
|
|
|
|
it("keeps every chunk 4-byte aligned", () => {
|
|
const { glb } = buildCharacter();
|
|
const view = new DataView(glb.buffer, glb.byteOffset, glb.byteLength);
|
|
|
|
let offset = 12;
|
|
while (offset < glb.byteLength) {
|
|
const chunkLength = view.getUint32(offset, true);
|
|
expect(chunkLength % 4).toBe(0);
|
|
offset += 8 + chunkLength;
|
|
}
|
|
// Chunks must tile the file exactly.
|
|
expect(offset).toBe(glb.byteLength);
|
|
});
|
|
});
|
|
|
|
describe("GLTFLoader round trip", () => {
|
|
it("parses without error", async () => {
|
|
const { glb } = buildCharacter();
|
|
const gltf = await parseGlb(glb);
|
|
expect(gltf.scene).toBeDefined();
|
|
});
|
|
|
|
it("reconstructs a skinned mesh with the expected geometry", async () => {
|
|
const { glb, mesh } = buildCharacter();
|
|
const gltf = await parseGlb(glb);
|
|
|
|
let skinned: SkinnedMesh | null = null;
|
|
gltf.scene.traverse((object) => {
|
|
if ((object as SkinnedMesh).isSkinnedMesh) skinned = object as SkinnedMesh;
|
|
});
|
|
|
|
expect(skinned).not.toBeNull();
|
|
const geometry = skinned!.geometry;
|
|
|
|
expect(geometry.getAttribute("position").count).toBe(vertexCount(mesh));
|
|
expect(geometry.index!.count / 3).toBe(triangleCount(mesh));
|
|
|
|
// Vertex colours carry the palette; without them the character is white.
|
|
expect(geometry.getAttribute("color")).toBeDefined();
|
|
expect(geometry.getAttribute("normal")).toBeDefined();
|
|
expect(geometry.getAttribute("skinIndex")).toBeDefined();
|
|
expect(geometry.getAttribute("skinWeight")).toBeDefined();
|
|
});
|
|
|
|
it("binds all 22 canonical bones", async () => {
|
|
const { glb, skeleton } = buildCharacter();
|
|
const gltf = await parseGlb(glb);
|
|
|
|
let skinned: SkinnedMesh | null = null;
|
|
gltf.scene.traverse((object) => {
|
|
if ((object as SkinnedMesh).isSkinnedMesh) skinned = object as SkinnedMesh;
|
|
});
|
|
|
|
expect(skinned!.skeleton.bones).toHaveLength(skeleton.joints.length);
|
|
|
|
const names = skinned!.skeleton.bones.map((bone: Bone) => bone.name);
|
|
expect(names).toContain("Hips");
|
|
expect(names).toContain("Head");
|
|
expect(names).toContain("LeftHand");
|
|
expect(names).toContain("RightToeBase");
|
|
});
|
|
|
|
it("preserves the bone hierarchy", async () => {
|
|
const { glb } = buildCharacter();
|
|
const gltf = await parseGlb(glb);
|
|
|
|
const bones = new Map<string, Bone>();
|
|
gltf.scene.traverse((object) => {
|
|
if ((object as Bone).isBone) bones.set(object.name, object as Bone);
|
|
});
|
|
|
|
// A flat list of bones would still load but would not animate correctly.
|
|
expect(bones.get("LeftForeArm")?.parent?.name).toBe("LeftArm");
|
|
expect(bones.get("LeftLeg")?.parent?.name).toBe("LeftUpLeg");
|
|
expect(bones.get("Spine")?.parent?.name).toBe("Hips");
|
|
});
|
|
|
|
it("carries every animation clip with named bone tracks", async () => {
|
|
const { glb, clips } = buildCharacter();
|
|
const gltf = await parseGlb(glb);
|
|
|
|
expect(gltf.animations).toHaveLength(clips.length);
|
|
|
|
const walk = gltf.animations.find((clip: ThreeClip) => clip.name === "Walk");
|
|
expect(walk).toBeDefined();
|
|
expect(walk!.duration).toBeGreaterThan(0);
|
|
expect(walk!.tracks.length).toBeGreaterThan(0);
|
|
|
|
// Tracks must target bone names, which is what makes clips portable
|
|
// between characters produced by the harness.
|
|
const targets = walk!.tracks.map((track) => track.name.split(".")[0]);
|
|
expect(targets).toContain("LeftUpLeg");
|
|
expect(targets).toContain("RightUpLeg");
|
|
});
|
|
|
|
it("keeps the character upright and roughly the requested height", async () => {
|
|
const { glb } = buildCharacter();
|
|
const gltf = await parseGlb(glb);
|
|
|
|
let skinned: SkinnedMesh | null = null;
|
|
gltf.scene.traverse((object) => {
|
|
if ((object as SkinnedMesh).isSkinnedMesh) skinned = object as SkinnedMesh;
|
|
});
|
|
|
|
skinned!.geometry.computeBoundingBox();
|
|
const bbox = skinned!.geometry.boundingBox!;
|
|
|
|
// Feet near the origin, crown near the target height.
|
|
expect(bbox.min.y).toBeGreaterThan(-0.05);
|
|
expect(bbox.max.y).toBeGreaterThan(1.5);
|
|
expect(bbox.max.y).toBeLessThan(1.85);
|
|
// Taller than wide — a figure, not a puddle.
|
|
expect(bbox.max.y - bbox.min.y).toBeGreaterThan(bbox.max.x - bbox.min.x);
|
|
});
|
|
});
|
|
|
|
describe("skin weights", () => {
|
|
it("normalises every vertex to exactly one", () => {
|
|
const { mesh } = buildCharacter();
|
|
const stats = skinStatistics(mesh);
|
|
expect(stats.unweighted).toBe(0);
|
|
expect(stats.maxInfluences).toBeLessThanOrEqual(4);
|
|
});
|
|
|
|
it("assigns hand vertices to the hand bone, not the torso", () => {
|
|
const { mesh, skeleton } = buildCharacter();
|
|
const handIndex = skeleton.index.get("LeftHand")!;
|
|
const foreArmIndex = skeleton.index.get("LeftForeArm")!;
|
|
const handWorld = skeleton.joints[handIndex]!.world;
|
|
|
|
// Find the vertex nearest the left hand joint.
|
|
let nearest = -1;
|
|
let nearestDistance = Infinity;
|
|
for (let v = 0; v < vertexCount(mesh); v++) {
|
|
const distance = Math.hypot(
|
|
mesh.positions[v * 3]! - handWorld[0],
|
|
mesh.positions[v * 3 + 1]! - handWorld[1],
|
|
mesh.positions[v * 3 + 2]! - handWorld[2],
|
|
);
|
|
if (distance < nearestDistance) {
|
|
nearestDistance = distance;
|
|
nearest = v;
|
|
}
|
|
}
|
|
|
|
const influences = [0, 1, 2, 3].map((i) => ({
|
|
joint: mesh.joints[nearest * 4 + i]!,
|
|
weight: mesh.weights[nearest * 4 + i]!,
|
|
}));
|
|
const dominant = influences.reduce((best, entry) => (entry.weight > best.weight ? entry : best));
|
|
|
|
// Distance-to-joint skinning would let the spine claim this vertex; the
|
|
// segment-based version must not.
|
|
expect([handIndex, foreArmIndex]).toContain(dominant.joint);
|
|
});
|
|
|
|
it("keeps left-side vertices off right-side bones", () => {
|
|
const { mesh, skeleton } = buildCharacter();
|
|
const rightBones = new Set(
|
|
skeleton.joints
|
|
.map((joint, index) => ({ joint, index }))
|
|
.filter(({ joint }) => joint.name.startsWith("Right"))
|
|
.map(({ index }) => index),
|
|
);
|
|
|
|
let violations = 0;
|
|
for (let v = 0; v < vertexCount(mesh); v++) {
|
|
const x = mesh.positions[v * 3]!;
|
|
if (x < 0.15) continue; // clearly on the left side
|
|
|
|
for (let i = 0; i < 4; i++) {
|
|
if (rightBones.has(mesh.joints[v * 4 + i]!) && mesh.weights[v * 4 + i]! > 0.2) {
|
|
violations++;
|
|
}
|
|
}
|
|
}
|
|
expect(violations).toBe(0);
|
|
});
|
|
});
|