Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,7 @@ wasm/**/out/

# Claude Code agent worktrees — transient checkouts, not repo source.
.claude/

# Generated X-wing geometry (base64 typed arrays baked from data/model.glb by
# scripts/gen-xwing.mjs; regenerated on re-export, never hand-formatted).
packages/player/src/models/xwing.geometry.ts
Binary file added data/model.glb
Binary file not shown.
4 changes: 4 additions & 0 deletions packages/player/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,12 @@
"svelte": "^5"
},
"devDependencies": {
"@gltf-transform/core": "^4.4.1",
"@gltf-transform/extensions": "^4.4.1",
"@gltf-transform/functions": "^4.4.1",
"@sveltejs/vite-plugin-svelte": "^7.1.4",
"@vitest/browser-playwright": "^4.1.10",
"meshoptimizer": "^1.2.0",
"playwright": "^1.61.1",
"vite": "^8.1.3",
"vitest": "^4.1.10",
Expand Down
165 changes: 165 additions & 0 deletions packages/player/scripts/gen-xwing.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// Author-time X-wing geometry extractor (run when data/model.glb changes).
//
// node packages/player/scripts/gen-xwing.mjs
//
// Precompiles a glTF-binary (.glb) into ONE compact, bundle-ready geometry:
// every mesh baked into world space, merged, and keyed by a material table
// (linear baseColor + emissive). Emits packages/player/src/models/
// xwing.geometry.ts (base64 typed arrays — no runtime fetch, no three.js in the
// bundle). The runtime draws it with the tiny WebGL renderer in xwing-render.ts,
// so we get a real 3D model (engine glow, dolly, true rotation) for a few KB.
//
// Parsing is delegated to glTF-Transform (a DEV dependency, never bundled), so
// any re-export is handled robustly: KHR_mesh_quantization, EXT_meshopt_compression,
// Draco, KHR_materials_emissive_strength, interleaving, sparse accessors — the SDK
// decompresses + dequantizes and getWorldMatrix() bakes the node hierarchy.
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { dequantize } from "@gltf-transform/functions";
import { MeshoptDecoder } from "meshoptimizer";
import { writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const here = dirname(fileURLToPath(import.meta.url));
const GLB = resolve(here, "../../../data/model.glb");
const OUT = resolve(here, "../src/models/xwing.geometry.ts");

// --- load + normalize (decompress meshopt/draco, then float-ify quantized) --
await MeshoptDecoder.ready;
const io = new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
const doc = await io.read(GLB);
await doc.transform(dequantize()); // KHR_mesh_quantization → plain float attributes
const root = doc.getRoot();

// Transform a world-matrix (column-major mat4) over a point / a direction.
const apply = (m, x, y, z) => [
m[0] * x + m[4] * y + m[8] * z + m[12],
m[1] * x + m[5] * y + m[9] * z + m[13],
m[2] * x + m[6] * y + m[10] * z + m[14],
];
function applyDir(m, x, y, z) {
const r = [
m[0] * x + m[4] * y + m[8] * z,
m[1] * x + m[5] * y + m[9] * z,
m[2] * x + m[6] * y + m[10] * z,
];
const l = Math.hypot(r[0], r[1], r[2]) || 1;
return [r[0] / l, r[1] / l, r[2] / l];
}

// --- material table (linear baseColor + emissive), deduped by Material -------
const materials = [];
const matIndex = new Map();
function materialId(mat) {
if (!mat) {
if (!matIndex.has(null)) {
matIndex.set(null, materials.length);
materials.push({ color: [0.8, 0.8, 0.8], emissive: [0, 0, 0] });
}
return matIndex.get(null);
}
if (matIndex.has(mat)) return matIndex.get(mat);
const c = mat.getBaseColorFactor(); // [r,g,b,a] linear
const e = mat.getEmissiveFactor(); // [r,g,b] linear
const es = mat.getExtension("KHR_materials_emissive_strength")?.getEmissiveStrength() ?? 1;
const round = (x) => Math.round(x * 1000) / 1000;
const id = materials.length;
matIndex.set(mat, id);
materials.push({
color: [round(c[0]), round(c[1]), round(c[2])],
emissive: [round(e[0] * es), round(e[1] * es), round(e[2] * es)],
});
return id;
}

// --- bake every mesh instance into one merged, world-space geometry ---------
const positions = [];
const normals = [];
const matIds = [];
const indices = [];
for (const node of root.listNodes()) {
const mesh = node.getMesh();
if (!mesh) continue;
const world = node.getWorldMatrix(); // accounts for the full parent chain
for (const prim of mesh.listPrimitives()) {
if (prim.getMode() !== 4) continue; // TRIANGLES only
const pos = prim.getAttribute("POSITION")?.getArray();
if (!pos) continue;
const nrm = prim.getAttribute("NORMAL")?.getArray();
const idxAcc = prim.getIndices();
const count = pos.length / 3;
const base = positions.length / 3;
const mat = materialId(prim.getMaterial());
for (let v = 0; v < count; v++) {
const p = apply(world, pos[v * 3], pos[v * 3 + 1], pos[v * 3 + 2]);
const d = nrm ? applyDir(world, nrm[v * 3], nrm[v * 3 + 1], nrm[v * 3 + 2]) : [0, 1, 0];
positions.push(p[0], p[1], p[2]);
normals.push(d[0], d[1], d[2]);
matIds.push(mat);
}
if (idxAcc) {
const idx = idxAcc.getArray();
for (let k = 0; k < idx.length; k++) indices.push(base + idx[k]);
} else {
for (let k = 0; k < count; k++) indices.push(base + k);
}
}
}

// --- center at the bbox midpoint, scale to unit radius ----------------------
const lo = [Infinity, Infinity, Infinity];
const hi = [-Infinity, -Infinity, -Infinity];
for (let v = 0; v < positions.length / 3; v++)
for (let a = 0; a < 3; a++) {
lo[a] = Math.min(lo[a], positions[v * 3 + a]);
hi[a] = Math.max(hi[a], positions[v * 3 + a]);
}
const mid = [(lo[0] + hi[0]) / 2, (lo[1] + hi[1]) / 2, (lo[2] + hi[2]) / 2];
let radius = 0;
for (let v = 0; v < positions.length / 3; v++) {
const dx = positions[v * 3] - mid[0],
dy = positions[v * 3 + 1] - mid[1],
dz = positions[v * 3 + 2] - mid[2];
radius = Math.max(radius, Math.hypot(dx, dy, dz));
}
for (let v = 0; v < positions.length / 3; v++)
for (let a = 0; a < 3; a++) positions[v * 3 + a] = (positions[v * 3 + a] - mid[a]) / radius;

// --- emit .ts (base64 typed arrays; decoded on import) ----------------------
const b64 = (typed) =>
Buffer.from(typed.buffer, typed.byteOffset, typed.byteLength).toString("base64");
const posArr = new Float32Array(positions);
const nrmArr = new Float32Array(normals);
const matArr = new Uint8Array(matIds);
const idxArr = positions.length / 3 > 65535 ? new Uint32Array(indices) : new Uint16Array(indices);
const idxKind = idxArr instanceof Uint32Array ? "Uint32Array" : "Uint16Array";

const ts = `// GENERATED by scripts/gen-xwing.mjs from data/model.glb — do not edit by hand.
// A precompiled, bundle-ready X-wing: every glb mesh baked into world space and
// merged, keyed by a material table (linear RGB + emissive). Drawn by the tiny
// WebGL renderer in xwing-render.ts. Regenerate after re-exporting the model.
/* eslint-disable */
const d = (s: string, C: any) => {
const b = atob(s);
const u = new Uint8Array(b.length);
for (let i = 0; i < b.length; i++) u[i] = b.charCodeAt(i);
return new C(u.buffer);
};
export type XwingMaterial = { color: [number, number, number]; emissive: [number, number, number] };
export const xwing = {
vertexCount: ${posArr.length / 3},
indexCount: ${idxArr.length},
positions: d("${b64(posArr)}", Float32Array) as Float32Array,
normals: d("${b64(nrmArr)}", Float32Array) as Float32Array,
matIds: d("${b64(matArr)}", Uint8Array) as Uint8Array,
indices: d("${b64(idxArr)}", ${idxKind}) as ${idxKind},
materials: ${JSON.stringify(materials)} as XwingMaterial[],
};
`;
writeFileSync(OUT, ts);
console.log(
`xwing.geometry.ts: ${posArr.length / 3} verts, ${idxArr.length / 3} tris, ${materials.length} materials, ${idxKind}, ~${Math.round(ts.length / 1024)}KB`,
);
104 changes: 102 additions & 2 deletions packages/player/src/Starfield.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@
// Parallax starfield over a deep-space nebula backdrop: stars stream out of the
// bright centre leaving motion-blur trails, their speed pulsing with the music's
// energy and easing to a near-stop when it stops. Periodically the whole field
// barrel-rolls about the centre for a bit of demoscene flair. (A 3D ship model
// will be brought in later from ../maquette.) Backdrop is the shared nebula asset.
// barrel-rolls about the centre for a bit of demoscene flair. A real 3D X-wing
// (precompiled glb → xwing.geometry.ts, drawn by the tiny WebGL renderer in
// xwing-render.ts — no three.js in the bundle) rides in the foreground at
// bottom-centre, nose to the horizon, weaving side to side with its engines
// lit; it eases near + still on pause. Backdrop is the shared nebula asset.
import { playback } from "./player.svelte";
import { driveFrames } from "./raf";
import bgUrl from "./assets/starfield-bg.jpg";
import { createXwingRenderer } from "./xwing-render";

let { active = true }: { active?: boolean } = $props();

Expand Down Expand Up @@ -73,6 +77,18 @@
const buf = document.createElement("canvas");
const bctx = buf.getContext("2d");

// The X-wing is a real 3D model now (precompiled glb → xwing.geometry.ts,
// drawn by the tiny WebGL renderer below into its own transparent canvas each
// frame, then composited here). Live 3D lets the tremor be true rotation, the
// engines glow from the model's baked emissive, and the ship dolly nearer when
// paused. Rendered at a fixed backing size and scaled to the on-screen size.
const SHIP_TEX = 768; // renderer backing resolution (scaled to shipSize)
const SHIP_ROT_X = 0.25; // behind + above: we look down on the spine/canopy, nose angled up-and-away toward the nebula core (climbing, not diving)
const SHIP_ROT_Y = Math.PI; // rear view: engines (glow) toward us, nose receding up into the nebula
const ship = createXwingRenderer(SHIP_TEX);
let shipLife = 0; // eased 0..1 presence, so the ship fades in with the viz
let near = 0; // eased 0..1: 0 = playing (far, gliding), 1 = paused (nearer, still)

// Accent-tinted stars follow the theme accent (orange/purple).
const accentRgb = hexToRgb(getComputedStyle(el).getPropertyValue("--accent") || "#f78f08");
const PALETTE = [...BASE_TINTS, accentRgb, lighten(accentRgb, 0.55)];
Expand Down Expand Up @@ -324,6 +340,89 @@
g2.fill();
}
g2.globalAlpha = 1;

// --- foreground: the X-wing, gliding toward the nebula ---
// Anchored bottom-centre, pointed at the glow. The 3D model is rendered
// live each frame at a pose that (a) slowly yaws/glides "into" the scene
// and (b) rides a low-frequency turbulence tremor (real pitch/yaw/roll +
// position jitter) that swells with the music. When paused it eases to a
// near, still hero shot (dolly in, tremor out). Engines glow from the
// model's baked emissive, pulsing on the beat.
shipLife += ((active ? 1 : 0) - shipLife) * 0.03;
const playingNow = playback.playing && !playback.paused;
near += ((playingNow ? 0 : 1) - near) * 0.04;
if (ship && shipLife > 0.01) {
const shipSize = Math.min(w, h) * 0.5 * (1 + near * 0.3); // grows when paused (comes nearer)
// "Adrift in space eddies." The ship HOLDS its heading (rear to us, nose
// up to the horizon) and is only gently pushed around by slow currents —
// a buoyant rock, like a boat on a slow swell. Every motion is small,
// slow, and a sum of incommensurate sines, so it drifts organically and
// never reads as a repeating loop, a spin, or a hard bank. Deliberately
// INDEPENDENT of the music — tying the amplitude to energy made the rock
// jerky. It just calms + pulls near on pause. A constant "sea state".
const sea = shipLife * (1 - near * 0.85);
const o = (f: number, p: number) => Math.sin(clock * f + p); // oscillator (period 2π/f s)
// Slow buoyant swell (the "eddies").
const rockRoll = o(0.31, 0.0) * 0.6 + o(0.19, 2.1) * 0.4; // lengthwise rock ~[-1,1]
const heave = o(0.24, 1.3) * 0.6 + o(0.41, 0.4) * 0.4; // rise + fall on the swell
const drift = o(0.16, 2.7) * 0.7 + o(0.29, 0.9) * 0.3; // slow lateral wander
// Fast micro-tremor on every axis — a fine shake/vibration in space, on
// top of the slow swell (higher freq, small amplitude).
const TR = 0.022; // tremor amplitude (rad)
const trR = o(2.7, 0.4) * 0.6 + o(3.9, 1.9) * 0.4;
const trP = o(2.3, 1.2) * 0.6 + o(4.3, 0.2) * 0.4;
const trY = o(3.1, 2.5) * 0.6 + o(2.5, 0.8) * 0.4;
// rotZ is the intrinsic LENGTHWISE roll (about the fuselage) — the
// dominant motion: the ship banks/rolls around its own length (~±20°).
const roll = (rockRoll * 0.36 + trR * TR) * sea;
const pitch = (heave * 0.06 + trP * TR) * sea; // bow eases up/down + tremor
const yaw = (drift * 0.05 + o(0.13, 1.1) * 0.03 + trY * TR) * sea; // heading wander + tremor
const shipX = w / 2 + drift * w * 0.045 * sea; // small lateral drift
const shipY = h * 0.72 + heave * h * 0.03 * sea - near * h * 0.05;

// Engine wash: a soft additive glow at the ship's rear (toward us). A
// slow, steady breathe (NOT the beat) so the thrusters read as lit
// without the music jerking them.
const breathe = 0.5 + 0.5 * o(0.7, 0.0);
const eR = shipSize * (0.17 + breathe * 0.05);
const eg = g2.createRadialGradient(shipX, shipY, 0, shipX, shipY, eR);
const ei = (0.24 + breathe * 0.12) * shipLife;
eg.addColorStop(0, `rgba(255,190,120,${ei})`);
eg.addColorStop(0.5, `rgba(120,180,255,${ei * 0.4})`);
eg.addColorStop(1, "rgba(120,180,255,0)");
g2.globalCompositeOperation = "lighter";
g2.fillStyle = eg;
g2.beginPath();
g2.arc(shipX, shipY, eR, 0, TAU);
g2.fill();
g2.globalCompositeOperation = "source-over";

// Render the model live at this pose, then stamp it centred on (shipX,shipY).
ship.render({
rotX: SHIP_ROT_X + pitch,
rotY: SHIP_ROT_Y + yaw,
rotZ: roll,
// The nebula/horizon is effectively infinitely far, so view the ship
// with near-parallel rays: a strong telephoto (far dist + narrow fov)
// is nearly orthographic, so there's no perspective foreshortening /
// near-wing ballooning. (The pause "come nearer" is the 2D scale above,
// since at this distance a dist change barely resizes.)
dist: 14,
fov: 0.145,
scale: 1,
engine: 0.4 + breathe * 0.4, // steady thruster glow, not music-driven
light: [0.35, 0.7, 0.55],
});
g2.globalAlpha = Math.min(1, shipLife * 1.2);
g2.drawImage(
ship.canvas,
shipX - shipSize / 2,
shipY - shipSize / 2,
shipSize,
shipSize,
);
g2.globalAlpha = 1;
}
}
},
{ fps: 60 },
Expand All @@ -332,6 +431,7 @@
return () => {
stopFrames();
ro.disconnect();
ship?.dispose();
};
});
</script>
Expand Down
21 changes: 21 additions & 0 deletions packages/player/src/models/xwing.geometry.ts

Large diffs are not rendered by default.

Loading
Loading