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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user