Initial public release of PSX Adventure Engine
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.
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user