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:
ryanfitzpatrickio
2026-07-31 06:32:43 -05:00
commit 8a96ede9f2
181 changed files with 25807 additions and 0 deletions
+234
View File
@@ -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);
});
});
+418
View File
@@ -0,0 +1,418 @@
import { eulerToQuaternion, type AnimationClip } from "../anim/procedural.js";
import { bounds, vertexCount, type MeshData } from "../mesh/MeshBuilder.js";
import type { Skeleton } from "../rig/skeleton.js";
/**
* glTF 2.0 / GLB writer.
*
* Hand-rolled rather than using three's `GLTFExporter`, which expects a browser
* (Blob, FileReader) and a live scene graph. The pipeline is a Node process
* holding plain arrays, so emitting the container directly is both simpler and
* dependency-free — GLB is a JSON chunk and a binary chunk with 4-byte
* alignment, and that is the whole format.
*/
const GLB_MAGIC = 0x46546c67; // "glTF"
const GLB_VERSION = 2;
const CHUNK_JSON = 0x4e4f534a;
const CHUNK_BIN = 0x004e4942;
const COMPONENT_FLOAT = 5126;
const COMPONENT_UNSIGNED_SHORT = 5123;
const COMPONENT_UNSIGNED_INT = 5125;
const TARGET_ARRAY_BUFFER = 34962;
const TARGET_ELEMENT_ARRAY_BUFFER = 34963;
interface Accessor {
bufferView: number;
componentType: number;
count: number;
type: string;
min?: number[];
max?: number[];
normalized?: boolean;
}
interface BufferView {
buffer: 0;
byteOffset: number;
byteLength: number;
target?: number;
byteStride?: number;
}
/** Accumulates binary data with the 4-byte alignment glTF requires. */
class BinaryWriter {
private readonly chunks: Uint8Array[] = [];
private length = 0;
readonly bufferViews: BufferView[] = [];
readonly accessors: Accessor[] = [];
private align(): void {
const padding = (4 - (this.length % 4)) % 4;
if (padding === 0) return;
this.chunks.push(new Uint8Array(padding));
this.length += padding;
}
private addView(bytes: Uint8Array, target?: number): number {
this.align();
const byteOffset = this.length;
this.chunks.push(bytes);
this.length += bytes.byteLength;
this.bufferViews.push({
buffer: 0,
byteOffset,
byteLength: bytes.byteLength,
...(target !== undefined ? { target } : {}),
});
return this.bufferViews.length - 1;
}
addFloats(values: ArrayLike<number>, type: string, target?: number, withBounds = false): number {
const array = Float32Array.from(values as number[]);
const view = this.addView(new Uint8Array(array.buffer, array.byteOffset, array.byteLength), target);
const components = COMPONENTS_PER_TYPE[type]!;
const accessor: Accessor = {
bufferView: view,
componentType: COMPONENT_FLOAT,
count: array.length / components,
type,
};
if (withBounds) {
const min = new Array<number>(components).fill(Infinity);
const max = new Array<number>(components).fill(-Infinity);
for (let i = 0; i < array.length; i += components) {
for (let c = 0; c < components; c++) {
const value = array[i + c]!;
if (value < min[c]!) min[c] = value;
if (value > max[c]!) max[c] = value;
}
}
accessor.min = min;
accessor.max = max;
}
this.accessors.push(accessor);
return this.accessors.length - 1;
}
addIndices(values: number[]): number {
// 16-bit where it fits: a PSX-budget mesh almost always does, and it halves
// the index buffer.
const needs32 = values.some((value) => value > 65535);
const array = needs32 ? Uint32Array.from(values) : Uint16Array.from(values);
const view = this.addView(
new Uint8Array(array.buffer, array.byteOffset, array.byteLength),
TARGET_ELEMENT_ARRAY_BUFFER,
);
this.accessors.push({
bufferView: view,
componentType: needs32 ? COMPONENT_UNSIGNED_INT : COMPONENT_UNSIGNED_SHORT,
count: values.length,
type: "SCALAR",
});
return this.accessors.length - 1;
}
addJoints(values: number[]): number {
const array = Uint16Array.from(values);
const view = this.addView(
new Uint8Array(array.buffer, array.byteOffset, array.byteLength),
TARGET_ARRAY_BUFFER,
);
this.accessors.push({
bufferView: view,
componentType: COMPONENT_UNSIGNED_SHORT,
count: values.length / 4,
type: "VEC4",
});
return this.accessors.length - 1;
}
/** Raw bytes (e.g. embedded PNG) — no accessor, just a bufferView. */
addRaw(bytes: Uint8Array): number {
return this.addView(bytes);
}
finish(): Uint8Array {
this.align();
const out = new Uint8Array(this.length);
let offset = 0;
for (const chunk of this.chunks) {
out.set(chunk, offset);
offset += chunk.byteLength;
}
return out;
}
}
const COMPONENTS_PER_TYPE: Record<string, number> = {
SCALAR: 1,
VEC2: 2,
VEC3: 3,
VEC4: 4,
MAT4: 16,
};
export interface ExportOptions {
name?: string;
clips?: AnimationClip[];
/** Written into the asset's generator string for provenance. */
generator?: string;
/**
* Optional image bytes for baseColorTexture (PNG or JPEG).
* Requires mesh.uvs to be populated (TEXCOORD_0).
*/
baseColorImage?: Uint8Array;
/** MIME type for baseColorImage. Default: sniff from magic bytes, else image/png. */
baseColorMimeType?: string;
}
export function exportGlb(
mesh: MeshData,
skeleton: Skeleton,
inverseBindMatrices: Float32Array,
options: ExportOptions = {},
): Uint8Array {
const writer = new BinaryWriter();
const name = options.name ?? "Character";
const clips = options.clips ?? [];
const hasUvs = mesh.uvs.length >= (mesh.positions.length / 3) * 2 && mesh.uvs.length > 0;
const imageBytes = options.baseColorImage;
const hasTexture = Boolean(imageBytes && hasUvs);
const imageMime =
options.baseColorMimeType ??
(imageBytes ? sniffImageMime(imageBytes) : "image/png");
// --- mesh attributes -----------------------------------------------------
const positionAccessor = writer.addFloats(mesh.positions, "VEC3", TARGET_ARRAY_BUFFER, true);
const normalAccessor = writer.addFloats(mesh.normals, "VEC3", TARGET_ARRAY_BUFFER);
const colorAccessor = writer.addFloats(mesh.colors, "VEC4", TARGET_ARRAY_BUFFER);
const uvAccessor = hasUvs
? writer.addFloats(mesh.uvs, "VEC2", TARGET_ARRAY_BUFFER)
: undefined;
const jointsAccessor = writer.addJoints(mesh.joints);
const weightsAccessor = writer.addFloats(mesh.weights, "VEC4", TARGET_ARRAY_BUFFER);
const indexAccessor = writer.addIndices(mesh.indices);
const ibmAccessor = writer.addFloats(inverseBindMatrices, "MAT4");
let imageBufferView: number | undefined;
if (hasTexture && imageBytes) {
imageBufferView = writer.addRaw(imageBytes);
}
// --- node graph ----------------------------------------------------------
// Node 0 is the skinned mesh; joints follow, so joint N is node N+1.
const JOINT_NODE_OFFSET = 1;
const nodes: Array<Record<string, unknown>> = [
{ name, mesh: 0, skin: 0 },
];
for (const joint of skeleton.joints) {
const children = skeleton.joints
.map((other, index) => ({ other, index }))
.filter(({ other }) => other.parent === joint.name)
.map(({ index }) => index + JOINT_NODE_OFFSET);
nodes.push({
name: joint.name,
translation: joint.local,
...(children.length > 0 ? { children } : {}),
});
}
const rootJointIndex = skeleton.joints.findIndex((joint) => joint.parent === null);
// --- animations ----------------------------------------------------------
const animations = clips.map((clip) => buildAnimation(clip, skeleton, writer, JOINT_NODE_OFFSET));
const attributes: Record<string, number> = {
POSITION: positionAccessor,
NORMAL: normalAccessor,
COLOR_0: colorAccessor,
JOINTS_0: jointsAccessor,
WEIGHTS_0: weightsAccessor,
};
if (uvAccessor !== undefined) attributes["TEXCOORD_0"] = uvAccessor;
const material: Record<string, unknown> = {
name: hasTexture ? "PSXTextured" : "PSXVertexColor",
pbrMetallicRoughness: {
baseColorFactor: [1, 1, 1, 1],
metallicFactor: 0,
roughnessFactor: 1,
...(hasTexture ? { baseColorTexture: { index: 0 } } : {}),
},
doubleSided: false,
};
const gltf: Record<string, unknown> = {
asset: {
version: "2.0",
generator: options.generator ?? "psx-adventure-engine harness",
},
scene: 0,
scenes: [{ nodes: [0, rootJointIndex + JOINT_NODE_OFFSET] }],
nodes,
meshes: [
{
name: `${name}Mesh`,
primitives: [
{
attributes,
indices: indexAccessor,
material: 0,
},
],
},
],
skins: [
{
name: `${name}Skin`,
inverseBindMatrices: ibmAccessor,
skeleton: rootJointIndex + JOINT_NODE_OFFSET,
joints: skeleton.joints.map((_, index) => index + JOINT_NODE_OFFSET),
},
],
materials: [material],
...(hasTexture
? {
textures: [{ source: 0, sampler: 0 }],
images: [{ bufferView: imageBufferView, mimeType: imageMime }],
// Nearest sampling — PSX chunky texels.
samplers: [
{
magFilter: 9728, // NEAREST
minFilter: 9728, // NEAREST
wrapS: 33071, // CLAMP_TO_EDGE
wrapT: 33071,
},
],
}
: {}),
...(animations.length > 0 ? { animations } : {}),
accessors: writer.accessors,
bufferViews: writer.bufferViews,
buffers: [{ byteLength: 0 }],
};
const binary = writer.finish();
(gltf["buffers"] as Array<{ byteLength: number }>)[0]!.byteLength = binary.byteLength;
return packGlb(gltf, binary);
}
function buildAnimation(
clip: AnimationClip,
skeleton: Skeleton,
writer: BinaryWriter,
jointNodeOffset: number,
): Record<string, unknown> {
const samplers: Array<Record<string, unknown>> = [];
const channels: Array<Record<string, unknown>> = [];
for (const channel of clip.channels) {
const jointIndex = skeleton.index.get(channel.bone);
// A clip may reference bones a given rig does not have; skip rather than
// fail, so clips stay portable across characters (GDD §10.2).
if (jointIndex === undefined) continue;
const times = channel.keyframes.map((key) => key.time);
// Ensure consecutive quats take the *short* arc (dot ≥ 0). LINEAR
// interpolation of XYZW otherwise spins the long way around.
const rotations: number[] = [];
let prev: [number, number, number, number] | null = null;
for (const key of channel.keyframes) {
let q = eulerToQuaternion(key.rotation[0], key.rotation[1], key.rotation[2]) as [
number,
number,
number,
number,
];
if (prev && prev[0] * q[0] + prev[1] * q[1] + prev[2] * q[2] + prev[3] * q[3] < 0) {
q = [-q[0], -q[1], -q[2], -q[3]];
}
rotations.push(q[0], q[1], q[2], q[3]);
prev = q;
}
// Rest rotations are identity, so a clip's quaternion is the final value.
const input = writer.addFloats(times, "SCALAR", undefined, true);
const output = writer.addFloats(rotations, "VEC4");
samplers.push({ input, output, interpolation: "LINEAR" });
channels.push({
sampler: samplers.length - 1,
target: { node: jointIndex + jointNodeOffset, path: "rotation" },
});
}
return { name: clip.name, samplers, channels };
}
function packGlb(gltf: unknown, binary: Uint8Array): Uint8Array {
const jsonText = JSON.stringify(gltf);
const jsonBytes = new TextEncoder().encode(jsonText);
// Both chunks pad to 4 bytes: JSON with spaces, BIN with zeros, per spec.
const jsonPadding = (4 - (jsonBytes.byteLength % 4)) % 4;
const binPadding = (4 - (binary.byteLength % 4)) % 4;
const jsonChunkLength = jsonBytes.byteLength + jsonPadding;
const binChunkLength = binary.byteLength + binPadding;
const totalLength = 12 + 8 + jsonChunkLength + 8 + binChunkLength;
const out = new Uint8Array(totalLength);
const view = new DataView(out.buffer);
let offset = 0;
view.setUint32(offset, GLB_MAGIC, true);
view.setUint32(offset + 4, GLB_VERSION, true);
view.setUint32(offset + 8, totalLength, true);
offset += 12;
view.setUint32(offset, jsonChunkLength, true);
view.setUint32(offset + 4, CHUNK_JSON, true);
offset += 8;
out.set(jsonBytes, offset);
// 0x20 is a space; JSON chunks pad with spaces so the text stays valid.
out.fill(0x20, offset + jsonBytes.byteLength, offset + jsonChunkLength);
offset += jsonChunkLength;
view.setUint32(offset, binChunkLength, true);
view.setUint32(offset + 4, CHUNK_BIN, true);
offset += 8;
out.set(binary, offset);
return out;
}
/** Convenience for the pipeline report. */
export function meshStatistics(mesh: MeshData): { vertices: number; triangles: number } {
const { min, max } = bounds(mesh);
void min;
void max;
return { vertices: vertexCount(mesh), triangles: mesh.indices.length / 3 };
}
function sniffImageMime(bytes: Uint8Array): string {
if (bytes.length >= 4 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
return "image/png";
}
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
return "image/jpeg";
}
if (bytes.length >= 12 && bytes[0] === 0x52 && bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42) {
return "image/webp";
}
return "image/png";
}