Back to Supersonic RC Revive
SOURCE / PINNED RELEASE

Made of little things.

Supersonic RC Revive

Release
372375d18e3f…
Author-recorded commit
6fbf09f88a0a…
License
LICENSE
Author’s source reference
nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.napplet.soy%2F/n-143146b0d6f

Archive hash verified: 76e4eb30c67a6f45…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/game/render/glb.ts
// Minimal reader for the GLB files produced by macromelt + build_level.py and
// by tools/assets: static meshes, TRS nodes, embedded images, base-colour
// materials, an optional TEXCOORD_1 (lightmap) set and an optional COLOR_0
// (baked light). Images decode through <img> + blob URLs, so no fetch is involved.
import { BufferAttribute, BufferGeometry, Matrix4, Quaternion, Texture, Vector3 } from 'three';

interface GltfDoc {
  accessors: Array<{ bufferView: number; byteOffset?: number; componentType: number; count: number; type: string; normalized?: boolean }>;
  bufferViews: Array<{ byteOffset?: number; byteLength: number; byteStride?: number }>;
  images?: Array<{ bufferView: number; mimeType: string; name?: string }>;
  textures?: Array<{ source: number; sampler?: number }>;
  materials?: Array<{
    name?: string;
    alphaMode?: string;
    pbrMetallicRoughness?: { baseColorTexture?: { index: number }; baseColorFactor?: number[] };
  }>;
  meshes: Array<{ name?: string; extras?: Record<string, unknown>; primitives: Array<{ attributes: Record<string, number>; indices?: number; material?: number }> }>;
  nodes: Array<{ name?: string; mesh?: number; children?: number[]; translation?: number[]; rotation?: number[]; scale?: number[] }>;
  scenes: Array<{ nodes: number[] }>;
}

export interface GlbPrimitive {
  geometry: BufferGeometry;
  material: GlbMaterial;
}

export interface GlbMaterial {
  name: string;
  color: [number, number, number, number];
  texture: Texture | null;
  blend: boolean;
}

export interface GlbMesh {
  name: string;
  extras: Record<string, unknown>;
  primitives: GlbPrimitive[];
}

export interface GlbNode {
  name: string;
  /** Local transform relative to the parent node (Director Z-up). */
  matrix: Matrix4;
  mesh: GlbMesh | null;
  children: GlbNode[];
}

const COMPONENTS: Record<string, number> = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4 };

export async function readGlb(buffer: ArrayBuffer): Promise<GlbNode[]> {
  const view = new DataView(buffer);
  if (view.getUint32(0, true) !== 0x46546c67) throw new Error('not a GLB');
  const jsonLength = view.getUint32(12, true);
  const doc = JSON.parse(new TextDecoder().decode(new Uint8Array(buffer, 20, jsonLength))) as GltfDoc;
  const binOffset = 20 + jsonLength + 8;
  const bin = new Uint8Array(buffer, binOffset, view.getUint32(20 + jsonLength, true));

  const viewBytes = (i: number) => {
    const bv = doc.bufferViews[i];
    return bin.subarray(bv.byteOffset ?? 0, (bv.byteOffset ?? 0) + bv.byteLength);
  };
  const accessor = (i: number): BufferAttribute => {
    const acc = doc.accessors[i];
    const width = COMPONENTS[acc.type];
    const bytes = viewBytes(acc.bufferView);
    const start = bytes.byteOffset + (acc.byteOffset ?? 0);
    const copy = bin.buffer.slice(start, start + acc.count * width * byteSize(acc.componentType));
    const array =
      acc.componentType === 5126 ? new Float32Array(copy)
      : acc.componentType === 5125 ? new Uint32Array(copy)
      : acc.componentType === 5123 ? new Uint16Array(copy)
      : new Uint8Array(copy);
    return new BufferAttribute(array, width, acc.normalized ?? false);
  };

  const textures = await Promise.all(
    (doc.textures ?? []).map(async (t) => {
      const img = doc.images![t.source];
      const blob = new Blob([viewBytes(img.bufferView).slice()], { type: img.mimeType });
      const texture = new Texture(await decodeImage(blob));
      texture.name = img.name ?? '';
      texture.flipY = false;
      texture.needsUpdate = true;
      return texture;
    }),
  );
  const materials: GlbMaterial[] = (doc.materials ?? []).map((m) => {
    const pbr = m.pbrMetallicRoughness ?? {};
    const f = pbr.baseColorFactor ?? [1, 1, 1, 1];
    return {
      name: m.name ?? '',
      color: [f[0], f[1], f[2], f[3]],
      texture: pbr.baseColorTexture ? textures[pbr.baseColorTexture.index] : null,
      blend: m.alphaMode === 'BLEND' || f[3] < 1,
    };
  });
  const fallback: GlbMaterial = { name: 'default', color: [1, 1, 1, 1], texture: null, blend: false };
  const meshes: GlbMesh[] = doc.meshes.map((m) => ({
    name: (m.name ?? '').replace(/^mesh:/, ''),
    extras: m.extras ?? {},
    primitives: m.primitives.map((p) => {
      const geometry = new BufferGeometry();
      geometry.setAttribute('position', accessor(p.attributes.POSITION));
      if (p.attributes.NORMAL !== undefined) geometry.setAttribute('normal', accessor(p.attributes.NORMAL));
      if (p.attributes.TEXCOORD_0 !== undefined) geometry.setAttribute('uv', accessor(p.attributes.TEXCOORD_0));
      if (p.attributes.TEXCOORD_1 !== undefined) geometry.setAttribute('uv1', accessor(p.attributes.TEXCOORD_1));
      // Baked vertex lighting (custom bundle); the shader uses it instead of its lights.
      if (p.attributes.COLOR_0 !== undefined) geometry.setAttribute('bake', accessor(p.attributes.COLOR_0));
      if (p.indices !== undefined) geometry.setIndex(accessor(p.indices));
      geometry.computeBoundingSphere();
      return { geometry, material: p.material !== undefined ? materials[p.material] : fallback };
    }),
  }));

  const build = (i: number): GlbNode => {
    const n = doc.nodes[i];
    const t = n.translation ?? [0, 0, 0];
    const r = n.rotation ?? [0, 0, 0, 1];
    const s = n.scale ?? [1, 1, 1];
    return {
      name: n.name ?? '',
      matrix: new Matrix4().compose(new Vector3(t[0], t[1], t[2]), new Quaternion(r[0], r[1], r[2], r[3]), new Vector3(s[0], s[1], s[2])),
      mesh: n.mesh !== undefined ? meshes[n.mesh] : null,
      children: (n.children ?? []).map(build),
    };
  };
  // macromelt wraps everything in a Y-up "SceneRoot"; the game stays Z-up, so
  // return the root's children with their Director-space transforms.
  const roots = doc.scenes[0].nodes.map(build);
  return roots.flatMap((root) => (root.name === 'SceneRoot' ? root.children : [root]));
}

function byteSize(componentType: number): number {
  return componentType === 5126 || componentType === 5125 ? 4 : componentType === 5123 ? 2 : 1;
}

export function decodeImage(blob: Blob): Promise<HTMLImageElement> {
  const url = URL.createObjectURL(blob);
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.onload = () => {
      URL.revokeObjectURL(url);
      resolve(img);
    };
    img.onerror = () => {
      URL.revokeObjectURL(url);
      reject(new Error('image decode failed'));
    };
    img.src = url;
  });
}