guts/web/js/world/molecule.js
jing a99f733452 [lane A] MODELBEAST dresses the molecules: 5 matcaps, and the canal picks space-filling
The right axis wasn't "generate or don't" — it was WHICH HALF. Geometry stays
procedural because a molecule's shape is already known exactly. **Material is
exactly a generation problem**, and it's the whole difference between a textbook
diagram and a game object. So MODELBEAST made the materials.

5 greyscale matcaps, flux_local, 69 seconds, $0.00, 68 KB total:
  mol_glass (O,N) — the big win: oxygen is a red glass marble with internal glow
  mol_matte (C)   — graphite soot; carbon reads as the scaffold it is
  mol_chrome (Co) — B12's cobalt is now a chrome bearing in a cage
  mol_molten (P)  — ATP's phosphate tail is incandescent AND pulses (uTime, free)
  mol_pearl (H)   — satin white; hydrogens stop shouting

The trick is Lane D's own law applied to spheres: author the LUMINANCE, tint
in-shader. derive_maps already greyscales every matcap, so one glass ball serves
oxygen AND nitrogen at their own CPK hues — 5 balls dress the whole periodic
table we care about. They pack into one strip atlas indexed per-vertex by aMat,
so **a molecule is still exactly 1 draw call** however many materials it's made
of (7 molecules = 7 draws, unchanged). ?matcap=0 falls back to the built-in
fake-lit path: the assets-optional law holds.

SPACE-FILLING WINS FOR PICKUPS, and the canal decided it, not me. New
`repr: 'space-filling'` draws every atom at its real van der Waals radius with no
sticks. A/B'd at real pickup size against the real L2 wall, same camera:
ball-and-stick goes spindly and dissolves; the solid blob holds its silhouette —
and it's CHEAPER (21k vs 26k tris, no bond cylinders). Recommendation to B:
space-filling in flight, ball-stick for hero/UI/collect close-ups where the
chemistry is worth reading. Evidence: round2_molecule_materials.png (3-way).

Two defects found by rendering it, both mine, both fixed:
- Tint x luminance darkens TWICE: CPK carbon is 0.4 and a matte ball averages
  0.5, so soot x soot vanished — the first pass sank glucose, caffeine and the
  cobalt into the background. Floored the matcap at 0.22, the same floor the
  fallback's key light always had.
- The molten matcap was my own bad prompt: I asked for an "incandescent white hot
  CORE" and got exactly that — a black ball with a hot spot, so phosphorus lost
  its orange. Re-rolled for the whole sphere to glow (attempt 2 of the <=2
  PIPELINE allows). Lesson for the kit, next to the organ/tissue one: a matcap is
  a LUMINANCE LOOKUP, so a dark-dominant ball makes a dark-dominant object.

Also corrected the doc's own framing — v1 said "I'm authorised to burn GPU and
I'm not going to", which was the wrong axis, not just the wrong tone.

qa GREEN; 16/16 texture provenance verified (synced the FIXED batch json up
first this time, so the box couldn't clobber it on the way back).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 21:31:24 +10:00

570 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// world/molecule.js (Lane A) — ball-and-stick molecules, built from real chemistry.
//
// PROTOTYPE / cross-lane proposal (round 2). Not wired into boot. Pickups are Lane B's
// systems, their economy is C's, their art is D's — this is the *renderer* and a design
// argument, offered to all three. Harness: web/dev/laneA_molecules.html
//
// WHY PROCEDURAL AND NOT A GENERATED MESH. Everything else in GUTS that is an object gets
// concepted and put through MODELBEAST (PIPELINE.md). Molecules should not, and it isn't a
// cost argument: a molecule's shape is *known exactly*. Glucose is a hexagonal ring because
// it is a hexagonal ring. Feeding "glucose molecule" to FLUX+TRELLIS would produce a plausible
// blob that a chemist would clock as wrong in a second, at 3-8 minutes a go, and it could never
// be re-derived. Ball-and-stick from an atom list is exact, is ~40 lines of geometry, rebuilds
// instantly, and — the actual point — **reads as science because it IS the notation science
// uses**. The game gets its sciency-ness for free by not faking it.
//
// The look: CPK colours (the standard element palette — O red, N blue, P orange, S yellow,
// metals pink). Every chemistry textbook, every protein viewer, every science documentary uses
// it. Nobody needs to know what it means to feel it. And it does a gameplay job for free:
// against six biomes of monochrome tinted tissue, a CPK molecule is the only thing on screen
// with saturated foreign colour — it reads as *artificial, valuable, targetable* at a glance,
// with no HUD marker. That is the ART_BIBLE's synthetic-scanner fiction paying for itself:
// the ship's scanner identifies a compound and colours it in.
//
// The geometry is IDEALISED, not crystallographic: correct connectivity (which atom bonds to
// which), correct ring sizes, correct bond orders, believable angles — mostly authored flat
// because a flat ring reads instantly and spins beautifully. This is a game, not PyMOL, and
// this comment is here so nobody mistakes it for a structure database later.
//
// No real-time lights anywhere in GUTS (ART_BIBLE), so the shading is faked in-shader from a
// baked key direction. Colorspace: `#include <colorspace_fragment>` is LAW (TECH §Shader law).
import * as THREE from 'three';
const TAU = Math.PI * 2;
// ── elements ────────────────────────────────────────────────────────────────────────────
// `col` is CPK, nudged for a black void background: real CPK carbon is black, which is
// invisible here, so carbon is lifted to a grey that still reads as "not an element with a
// colour". `r` is a display radius in bond-length units — ball-and-stick, so balls are much
// smaller than the van der Waals radii; these are tuned to read, not to measure.
// `vdw` is the van der Waals radius (Å) — the atom's real "size", used by space-filling mode.
// `mat` names the material family this element is made of (see MATCAP_FAMILIES): CPK gives the
// hue, the matcap gives the substance. Carbon is soot, oxygen and nitrogen are gems, phosphorus
// is molten, metals are chrome. That pairing is what turns a diagram into an object.
export const ELEMENTS = {
H: { col: 0xf2f2f2, r: 0.26, vdw: 1.20, mat: 'pearl' },
C: { col: 0x63666e, r: 0.40, vdw: 1.70, mat: 'matte' },
N: { col: 0x3b60ff, r: 0.40, vdw: 1.55, mat: 'glass' },
O: { col: 0xff3222, r: 0.39, vdw: 1.52, mat: 'glass' },
P: { col: 0xff9020, r: 0.50, vdw: 1.80, mat: 'molten' },
S: { col: 0xf5f52a, r: 0.49, vdw: 1.80, mat: 'glass' },
Cl: { col: 0x35e035, r: 0.44, vdw: 1.75, mat: 'glass' },
Na: { col: 0xab5cf2, r: 0.52, vdw: 2.27, mat: 'chrome' },
Fe: { col: 0xe06633, r: 0.55, vdw: 2.00, mat: 'chrome' },
Co: { col: 0xf090a0, r: 0.56, vdw: 2.00, mat: 'chrome' },
};
const el = (e) => ELEMENTS[e] || { col: 0xff00ff, r: 0.4, vdw: 1.6, mat: 'matte' }; // magenta = typo'd element
/** Atlas cell order. Index into this is what `aMat` carries per-vertex. */
export const MATCAP_FAMILIES = ['matte', 'pearl', 'glass', 'chrome', 'molten'];
const famIndex = (e) => Math.max(0, MATCAP_FAMILIES.indexOf(el(e).mat));
/**
* Pack the family matcaps into ONE horizontal strip texture, so a molecule stays a single draw
* however many different materials its atoms are made of. The alternative — one material per
* family — would split every molecule into up to five draws and lose the batching that makes
* these cheap enough to scatter around a level.
*
* Returns null if anything is missing, and that is not an error: the shader falls back to its
* built-in fake-lit path (TECH.md's assets-optional law — the game must boot with an empty
* assets/gen/). Await it before building molecules; pass the result as `matcap`.
*
* @param {string[]} urls one URL per MATCAP_FAMILIES entry, same order.
*/
export async function buildMatcapAtlas(urls) {
try {
if (!Array.isArray(urls) || urls.length !== MATCAP_FAMILIES.length) return null;
const imgs = await Promise.all(urls.map((u) => new Promise((res, rej) => {
const im = new Image();
im.crossOrigin = 'anonymous';
im.onload = () => res(im); im.onerror = () => rej(new Error(`matcap load failed: ${u}`));
im.src = u;
})));
const S = Math.max(...imgs.map((i) => i.height)) || 512;
const cv = document.createElement('canvas');
cv.width = S * imgs.length; cv.height = S;
const ctx = cv.getContext('2d');
imgs.forEach((im, i) => ctx.drawImage(im, i * S, 0, S, S));
const tex = new THREE.CanvasTexture(cv);
tex.colorSpace = THREE.SRGBColorSpace; // these are authored images, like D's walls
tex.wrapS = tex.wrapT = THREE.ClampToEdgeWrapping;
tex.generateMipmaps = false; // mips would bleed neighbouring cells together
tex.minFilter = tex.magFilter = THREE.LinearFilter;
tex.needsUpdate = true;
return tex;
} catch (err) {
console.info('[molecule] no matcap atlas, using the built-in shading —', err.message);
return null;
}
}
/** The URLs `buildMatcapAtlas` wants, read through the documented asset contract. */
export function matcapUrls(assets, base = '/assets/') {
if (!assets || typeof assets.get !== 'function') return null;
const urls = MATCAP_FAMILIES.map((f) => assets.get('matcaps', `mol_${f}`)?.url);
if (urls.some((u) => !u)) return null; // a partial set is a miss, not a half-look
return urls.map((u) => new URL(u, new URL(base, location.href)).href);
}
// ── authoring helpers ───────────────────────────────────────────────────────────────────
/** A regular n-gon in the XY plane. Rings are rings; this is most of chemistry's shapes. */
function polygon(n, r, cx = 0, cy = 0, phase = -Math.PI / 2) {
return Array.from({ length: n }, (_, i) => {
const a = phase + (i * TAU) / n;
return [cx + r * Math.cos(a), cy + r * Math.sin(a), 0];
});
}
/**
* The other n-2 vertices of a regular n-gon that shares the edge A-B with an existing ring —
* i.e. a FUSED ring (caffeine's purine, ATP's adenine). Fused bicyclics are the visual
* signature of "this is a serious biomolecule", so it's worth the trig.
*
* **Returned in ring order starting from the vertex adjacent to B and ending adjacent to A**,
* so the caller bonds `Bout[0] … out[last]A` and the shared AB edge closes the ring. Get
* that backwards and you bond A to the far vertex: still a valid 5-cycle, so nothing errors —
* it just draws a chord straight across the ring. It looked like a squashed pentagon and it
* was found by rendering it, which is the whole reason the bench exists.
*
* @param {number[]} away a point the new ring must bulge away from (the host ring's centre)
*/
function fuseRing(A, B, n, away = [0, 0, 0]) {
const mx = (A[0] + B[0]) / 2, my = (A[1] + B[1]) / 2;
let ex = B[0] - A[0], ey = B[1] - A[1];
const s = Math.hypot(ex, ey); ex /= s; ey /= s;
let px = -ey, py = ex; // edge normal, sign undecided
if (Math.hypot(mx + px - away[0], my + py - away[1]) < Math.hypot(mx - px - away[0], my - py - away[1])) {
px = -px; py = -py; // ...pick the one pointing outward
}
const apothem = (s / 2) / Math.tan(Math.PI / n);
const R = (s / 2) / Math.sin(Math.PI / n);
const cx = mx + px * apothem, cy = my + py * apothem;
const a0 = Math.atan2(A[1] - cy, A[0] - cx);
const aB = Math.atan2(B[1] - cy, B[0] - cx);
const step = TAU / n;
const wrap = (x) => ((x + Math.PI) % TAU + TAU) % TAU - Math.PI; // to (-pi, pi]
const dir = Math.abs(wrap(a0 + step - aB)) < Math.abs(wrap(a0 - step - aB)) ? 1 : -1;
const out = [];
for (let k = 2; k < n; k++) {
const a = a0 + dir * step * k;
out.push([cx + R * Math.cos(a), cy + R * Math.sin(a), 0]);
}
return out;
}
/** Push an atom bonded outward from `from`, away from `origin`, at distance d. */
function outward(from, d, origin = [0, 0, 0]) {
const dx = from[0] - origin[0], dy = from[1] - origin[1];
const L = Math.hypot(dx, dy) || 1;
return [from[0] + (dx / L) * d, from[1] + (dy / L) * d, from[2]];
}
const add = (p, dx, dy, dz = 0) => [p[0] + dx, p[1] + dy, p[2] + dz];
// ── the library ─────────────────────────────────────────────────────────────────────────
// Every molecule here is really in the human gut. That constraint is doing design work: it
// means the pickup table IS the biochemistry of digestion, so the fiction writes itself and
// nothing has to be invented. `role` is a PROPOSAL to Lanes B/C — see docs/MOLECULES.md.
function buildLibrary() {
const M = {};
const def = (id, o) => { M[id] = { id, ...o }; };
// ── water ── the common little pickup. Bent at ~104.5°, which is the one fact everyone
// remembers from school, so it must not be drawn straight.
def('water', {
name: 'Water', formula: 'H₂O', role: 'trickle',
blurb: 'The chaff pickup. Everywhere, worth almost nothing, tops off a sliver of coat.',
atoms: [{ e: 'O', p: [0, 0, 0] }, { e: 'H', p: [0.76, 0.59, 0] }, { e: 'H', p: [-0.76, 0.59, 0] }],
bonds: [[0, 1, 1], [0, 2, 1]],
});
// ── bicarbonate ── the antacid ammo that L2/L3 already reference. Trigonal planar, and it
// is *literally* what neutralises stomach acid in a real body: HCO₃⁻ + HCl → salt + water
// + CO₂. Firing this into the acid sea is real chemistry and it is also just a good weapon.
def('bicarbonate', {
name: 'Bicarbonate', formula: 'HCO₃⁻', role: 'antacid ammo',
blurb: 'Antacid ordnance. Neutralises acid on contact — the real reaction, and it fizzes CO₂.',
atoms: [
{ e: 'C', p: [0, 0, 0] },
{ e: 'O', p: [1.30, 0, 0] },
{ e: 'O', p: [-0.65, 1.126, 0] },
{ e: 'O', p: [-0.65, -1.126, 0] },
{ e: 'H', p: [-1.35, 1.82, 0] },
],
bonds: [[0, 1, 2], [0, 2, 1], [0, 3, 1], [2, 4, 1]],
});
// ── glucose ── the score/nutrient pickup. Pyranose: a six-ring of 5 carbons and ONE oxygen
// (drawn red, top-right, exactly where a chemist expects it), hydroxyls hanging off. This is
// sugar. It is what the gut is FOR.
def('glucose', {
name: 'Glucose', formula: 'C₆H₁₂O₆', role: 'nutrient / score',
blurb: 'Food. The reason the canal exists. Common, stacks, feeds the score multiplier.',
...(() => {
const ring = polygon(6, 1.42);
const atoms = ring.map((p, i) => ({ e: i === 0 ? 'O' : 'C', p })); // ring oxygen at index 0
const bonds = ring.map((_, i) => [i, (i + 1) % 6, 1]);
for (let i = 1; i <= 5; i++) { // hydroxyls on every carbon
if (i === 5) { // ...except C5, which carries CH₂OH
const c = outward(ring[i], 1.45); atoms.push({ e: 'C', p: c }); bonds.push([i, atoms.length - 1, 1]);
const o = add(outward(c, 1.35), 0, 0.35); atoms.push({ e: 'O', p: o }); bonds.push([atoms.length - 2, atoms.length - 1, 1]);
atoms.push({ e: 'H', p: add(o, 0.5, 0.75) }); bonds.push([atoms.length - 2, atoms.length - 1, 1]);
continue;
}
const o = outward(ring[i], 1.38);
atoms.push({ e: 'O', p: o }); bonds.push([i, atoms.length - 1, 1]);
atoms.push({ e: 'H', p: outward(o, 0.95) }); bonds.push([atoms.length - 2, atoms.length - 1, 1]);
}
return { atoms, bonds };
})(),
});
// ── caffeine ── the overdrive powerup, and a joke that lands without explanation: it is a
// purine (fused 6+5 with four nitrogens), and it is genuinely absorbed through the gut wall.
// Everyone knows what caffeine does to a body; nobody needs a tutorial for this pickup.
def('caffeine', {
name: 'Caffeine', formula: 'C₈H₁₀N₄O₂', role: 'overdrive',
blurb: 'Overdrive. Throttle ceiling up, handling twitchier, and it wears off badly.',
...(() => {
const six = polygon(6, 1.42);
const five = fuseRing(six[2], six[3], 5); // fuse the imidazole onto one edge
const atoms = [
{ e: 'N', p: six[0] }, { e: 'C', p: six[1] }, { e: 'C', p: six[2] },
{ e: 'C', p: six[3] }, { e: 'N', p: six[4] }, { e: 'C', p: six[5] },
{ e: 'N', p: five[0] }, { e: 'C', p: five[1] }, { e: 'N', p: five[2] },
];
const bonds = [
[0, 1, 1], [1, 2, 2], [2, 3, 1], [3, 4, 1], [4, 5, 1], [5, 0, 1], // pyrimidine
[3, 6, 1], [6, 7, 2], [7, 8, 1], [8, 2, 1], // imidazole (B->…->A)
];
const carbonyl = (ci, dir) => { // the two C=O that make it a dione
const o = outward(atoms[ci].p, 1.24, [0, 0, 0]);
atoms.push({ e: 'O', p: [o[0] * dir, o[1] * dir === 0 ? o[1] : o[1], o[2]] });
bonds.push([ci, atoms.length - 1, 2]);
};
carbonyl(1, 1); carbonyl(5, 1);
for (const ni of [0, 4, 8]) { // three methyls — caffeine's tell
const c = outward(atoms[ni].p, 1.47);
atoms.push({ e: 'C', p: c }); bonds.push([ni, atoms.length - 1, 1]);
}
return { atoms, bonds };
})(),
});
// ── ATP ── the boost. Adenine + ribose + a three-phosphate tail, and that orange tail IS the
// energy: a body spends ATP by snapping the last phosphate off. A boost pickup that visibly
// carries three charges is a gameplay diagram of itself.
def('atp', {
name: 'ATP', formula: 'C₁₀H₁₆N₅O₁₃P₃', role: 'boost',
blurb: 'Boost. Literally the cell\'s energy currency — three phosphates, three charges.',
...(() => {
const atoms = [], bonds = [];
const push = (e, p) => (atoms.push({ e, p }), atoms.length - 1);
// adenine, off to the left
const six = polygon(6, 1.40, -5.2, 0.8);
const five = fuseRing(six[2], six[3], 5, [-5.2, 0.8, 0]);
const A = [
push('N', six[0]), push('C', six[1]), push('C', six[2]),
push('C', six[3]), push('N', six[4]), push('C', six[5]),
];
const F = [push('N', five[0]), push('C', five[1]), push('N', five[2])];
bonds.push([A[0], A[1], 2], [A[1], A[2], 1], [A[2], A[3], 2], [A[3], A[4], 1], [A[4], A[5], 2], [A[5], A[0], 1]);
bonds.push([A[3], F[0], 1], [F[0], F[1], 2], [F[1], F[2], 1], [F[2], A[2], 1]); // B->…->A
const nh2 = push('N', outward(six[5], 1.36, [-5.2, 0.8, 0])); // the amine
bonds.push([A[5], nh2, 1]);
// ribose, a five-ring with its own oxygen, hung off the adenine
const rib = polygon(5, 1.20, -1.9, -0.2, 0.6);
const R = rib.map((p, i) => push(i === 0 ? 'O' : 'C', p));
for (let i = 0; i < 5; i++) bonds.push([R[i], R[(i + 1) % 5], 1]);
bonds.push([F[2], R[1], 1]); // base -> sugar
const oh1 = push('O', outward(rib[3], 1.36, [-1.9, -0.2, 0]));
const oh2 = push('O', outward(rib[4], 1.36, [-1.9, -0.2, 0]));
bonds.push([R[3], oh1, 1], [R[4], oh2, 1]);
// the triphosphate tail: P-O-P-O-P marching right, each P with its own oxygens
let prev = R[2];
let x = -0.4;
for (let i = 0; i < 3; i++) {
const brO = push('O', [x, 0.9, 0]); bonds.push([prev, brO, 1]); // bridging oxygen
const p = push('P', [x + 1.25, 1.45, 0]); bonds.push([brO, p, 1]);
bonds.push([p, push('O', [x + 1.25, 2.85, 0]), 2]); // P=O
bonds.push([p, push('O', [x + 1.05, 0.15, 0]), 1]); // P-O⁻
prev = p; x += 1.85;
}
return { atoms, bonds };
})(),
});
// ── capsaicin ── the burn hazard. Aromatic ring at one end, long greasy tail at the other:
// the silhouette says "organic and wrong" from across a room, and the tail makes it tumble
// differently from every compact pickup, which is free readability.
def('capsaicin', {
name: 'Capsaicin', formula: 'C₁₈H₂₇NO₃', role: 'burn hazard',
blurb: 'Chilli. Contact burns the coat. Long-tailed and greasy — reads wrong on sight.',
...(() => {
const atoms = [], bonds = [];
const push = (e, p) => (atoms.push({ e, p }), atoms.length - 1);
const ring = polygon(6, 1.40, -4.6, 0);
const R = ring.map((p) => push('C', p));
for (let i = 0; i < 6; i++) bonds.push([R[i], R[(i + 1) % 6], i % 2 ? 2 : 1]); // aromatic
const oh = push('O', outward(ring[3], 1.36, [-4.6, 0, 0])); // phenol
bonds.push([R[3], oh, 1]);
bonds.push([oh, push('H', outward(ring[3], 2.3, [-4.6, 0, 0])), 1]);
const om = push('O', outward(ring[4], 1.36, [-4.6, 0, 0])); // methoxy
bonds.push([R[4], om, 1]);
bonds.push([om, push('C', outward(ring[4], 2.7, [-4.6, 0, 0])), 1]);
// amide linker + the alkyl tail, zig-zagging like a real chain
const c1 = push('C', [-2.7, -0.9, 0]); bonds.push([R[1], c1, 1]);
const n = push('N', [-1.5, -0.3, 0]); bonds.push([c1, n, 1]);
const co = push('C', [-0.3, -0.9, 0]); bonds.push([n, co, 1]);
bonds.push([co, push('O', [-0.3, -2.3, 0]), 2]);
let prev = co, x = 0.9, up = true;
for (let i = 0; i < 7; i++) {
const c = push('C', [x, up ? -0.25 : -1.15, 0]);
bonds.push([prev, c, i === 5 ? 2 : 1]); // one double bond kink, as in the real thing
prev = c; x += 1.05; up = !up;
}
return { atoms, bonds };
})(),
});
// ── cobalamin (B12) core ── the treasure. B12 is the most structurally complex vitamin there
// is and the only one with a METAL at its heart — a cobalt held in a corrin cage. C already
// authored B12 as a rare pickup before any of this existed. Simplified to the corrin core:
// the whole read is "a jewel in a setting", which is exactly what a rare pickup should be.
def('cobalamin', {
name: 'Cobalamin (B₁₂) core', formula: 'C₆₃H₈₈CoN₁₄O₁₄P', role: 'rare treasure',
blurb: 'The jewel. A cobalt atom held in a corrin cage — the only vitamin with a metal.',
...(() => {
const atoms = [{ e: 'Co', p: [0, 0, 0] }];
const bonds = [];
const push = (e, p) => (atoms.push({ e, p }), atoms.length - 1);
for (let q = 0; q < 4; q++) { // four pyrrole rings around the metal
const a = (q * TAU) / 4 + Math.PI / 4;
const nx = Math.cos(a) * 1.95, ny = Math.sin(a) * 1.95;
const n = push('N', [nx, ny, 0]);
bonds.push([0, n, 1]); // Co-N coordination bond
// the ring bulges outward from the metal
const ring = polygon(5, 1.20, Math.cos(a) * 3.05, Math.sin(a) * 3.05, a + Math.PI);
const C = ring.slice(1).map((p) => push('C', p));
bonds.push([n, C[0], 1], [C[0], C[1], 2], [C[1], C[2], 1], [C[2], C[3], 2], [C[3], n, 1]);
const sub = push('C', outward(ring[2], 1.45)); // a stub of the real side chains
bonds.push([C[1], sub, 1]);
const o = push('O', outward(ring[3], 1.40));
bonds.push([C[2], o, 1]);
}
return { atoms, bonds };
})(),
});
return M;
}
export const MOLECULES = buildLibrary();
export const listMolecules = () => Object.keys(MOLECULES);
// ── material ────────────────────────────────────────────────────────────────────────────
/**
* There are no lights in GUTS, so this fakes one: a baked key direction gives the balls their
* roundness, a tight specular gives the glossy model-kit read, and a fresnel rim in the
* scanner's cyan says "this object has been identified" — the same rim language the wall uses,
* so molecules belong to the world instead of being stickers on it.
*/
export function createMoleculeMaterial({ scan = 0x7fdfff, scanGain = 0.55, matcap = null } = {}) {
return new THREE.ShaderMaterial({
defines: matcap ? { USE_MATCAP: '' } : {},
uniforms: {
uScan: { value: new THREE.Color(scan) },
uScanGain: { value: scanGain },
uKey: { value: new THREE.Vector3(0.35, 0.72, 0.6).normalize() },
uMatcap: { value: matcap },
uMatCount: { value: MATCAP_FAMILIES.length },
uMolten: { value: MATCAP_FAMILIES.indexOf('molten') },
uTime: { value: 0 },
},
vertexShader: /* glsl */`
attribute vec3 color;
attribute float aMat;
varying vec3 vColor; varying vec3 vN; varying vec3 vView; varying float vMat;
void main() {
vColor = color;
vMat = aMat;
vec4 mv = modelViewMatrix * vec4(position, 1.0);
vN = normalize(normalMatrix * normal);
vView = -mv.xyz;
gl_Position = projectionMatrix * mv;
}`,
fragmentShader: /* glsl */`
uniform vec3 uScan, uKey; uniform float uScanGain, uMatCount, uMolten, uTime;
#ifdef USE_MATCAP
uniform sampler2D uMatcap;
#endif
varying vec3 vColor; varying vec3 vN; varying vec3 vView; varying float vMat;
void main() {
vec3 N = normalize(vN), V = normalize(vView);
float fres = pow(1.0 - max(0.0, dot(N, V)), 3.0);
vec3 col;
#ifdef USE_MATCAP
// Standard matcap lookup: the VIEW-space normal is the coordinate. vN already is one
// (normalMatrix * normal), which is why a tumbling molecule stays correctly lit.
vec2 muv = N.xy * 0.5 + 0.5;
muv = clamp(muv, 0.006, 0.994); // stay off the cell edge...
float u = (vMat + muv.x) / uMatCount; // ...then pick this atom's family cell
float lum = texture2D(uMatcap, vec2(u, muv.y)).r;
// Floor + gain. Tint x luminance darkens TWICE — CPK carbon is already 0.4, and a
// matte ball averages 0.5, so soot x soot = invisible (measured: the first matcap
// pass sank glucose, caffeine and the cobalt into the background). The fallback path
// has floored its key light at 0.30 since day one for the same reason; this is that
// floor, restored. Keeps the material's shape, lifts its black point off the void.
lum = 0.22 + 0.92 * lum;
// Lane D's law, applied to spheres instead of walls: the matcap is authored greyscale
// and carries the MATERIAL; the CPK colour carries the ELEMENT. One glass ball serves
// oxygen and nitrogen at their own hues, and 68 KB dresses the whole set.
col = vColor * lum * 1.7;
// Tinting alone would give chrome a red highlight on an oxygen, which instantly reads
// as plastic — a real specular is the colour of the LIGHT, not the object. So push the
// hot end back to white.
col = mix(col, vec3(1.0), smoothstep(0.72, 1.0, lum) * 0.72);
// Molten atoms are the only ones that emit: ATP's phosphate tail throbs, which is the
// charge it is carrying made visible. Costs one compare and no per-molecule work.
float molten = step(abs(vMat - uMolten), 0.5);
col += vColor * molten * (0.25 + 0.20 * sin(uTime * 3.1)) * lum;
#else
// Fallback when no matcap shipped (assets-optional law). Key light in VIEW space so a
// tumbling molecule never rotates into an unlit pose; 0.30 floor so nothing silhouettes.
float diff = 0.30 + 0.70 * max(0.0, dot(N, uKey));
float spec = pow(max(0.0, dot(reflect(-uKey, N), V)), 26.0);
col = vColor * diff + vec3(1.0) * spec * 0.55;
#endif
col += uScan * fres * uScanGain;
gl_FragColor = vec4(col, 1.0);
#include <colorspace_fragment>
}`,
});
}
// ── geometry ────────────────────────────────────────────────────────────────────────────
// Atoms and bonds are baked into ONE geometry with per-vertex colour, so a molecule is a
// single draw call however many atoms it has. That matters: these are pickups, and there may
// be dozens live. A naive mesh-per-atom glucose would be 21 draws on its own.
let SPHERE = null, CYL = null;
const templates = (detail) => {
if (!SPHERE) {
SPHERE = new THREE.IcosahedronGeometry(1, detail).toNonIndexed();
CYL = new THREE.CylinderGeometry(1, 1, 1, 9, 1, true).toNonIndexed();
}
return { SPHERE, CYL };
};
function appendGeo(dst, src, matrix, colorHex, mat = 0) {
const nm = new THREE.Matrix3().getNormalMatrix(matrix);
const pos = src.attributes.position, nor = src.attributes.normal;
const c = new THREE.Color(colorHex);
const v = new THREE.Vector3(), n = new THREE.Vector3();
for (let i = 0; i < pos.count; i++) {
v.fromBufferAttribute(pos, i).applyMatrix4(matrix);
n.fromBufferAttribute(nor, i).applyMatrix3(nm).normalize();
dst.position.push(v.x, v.y, v.z);
dst.normal.push(n.x, n.y, n.z);
dst.color.push(c.r, c.g, c.b);
dst.mat.push(mat);
}
}
const Y = new THREE.Vector3(0, 1, 0);
/**
* @param {string} id key in MOLECULES
* @param {object} opts
* @param {number} opts.fit scale so the whole molecule fits this radius (game units).
* Every molecule ends up the same size on screen — right for a
* contact sheet, and right if a pickup must occupy a fixed box.
* @param {number} opts.unit ALTERNATIVE to `fit`: absolute units per bond length, so
* molecules keep their TRUE relative sizes — water is a speck and
* B₁₂ is a chandelier. For pickups this is the better one: size
* tells the player what a thing is worth before they read a
* single colour, and it costs nothing because it's just true.
* @param {number} opts.detail icosphere detail: 2 for hero/close, 1 for a live pickup
* @param {string} opts.repr 'ball-stick' (default) reads the chemistry and is right up
* close. 'space-filling' draws each atom at its real van der
* Waals radius with no sticks — the atoms merge into one solid
* lump. That's how a molecule actually occupies space, AND it is
* the readable one: at pickup size and flight speed a
* ball-and-stick goes spindly and dissolves, while a solid blob
* holds its silhouette. Suspected LOD: space-filling far,
* ball-stick close. Not yet measured in flight.
* @param {THREE.Material} opts.material share ONE across every molecule (see harness)
* @returns {THREE.Mesh} one mesh, one draw. `.userData` carries name/formula/role/blurb.
*/
export function buildMolecule(id, { fit = 1, unit = 0, detail = 2, material = null, bondRadius = 0.13, repr = 'ball-stick' } = {}) {
const spec = MOLECULES[id];
if (!spec) throw new Error(`[molecule] unknown molecule "${id}". Have: ${listMolecules().join(', ')}`);
const { SPHERE: sph, CYL: cyl } = templates(detail);
// Recentre on the atom centroid and solve the scale that makes it `fit`, so every molecule
// arrives the same size on screen no matter how many atoms it has — a pickup is a pickup.
const filling = repr === 'space-filling';
const radiusOf = (e) => (filling ? el(e).vdw * 0.62 : el(e).r); // 0.62: vdW spheres overlap
// hard at full size and the
// shape turns to porridge
const ps = spec.atoms.map((a) => new THREE.Vector3(...a.p));
const centre = ps.reduce((acc, p) => acc.add(p), new THREE.Vector3()).multiplyScalar(1 / ps.length);
ps.forEach((p) => p.sub(centre));
const extent = Math.max(...ps.map((p, i) => p.length() + radiusOf(spec.atoms[i].e))) || 1;
const k = unit > 0 ? unit : fit / extent;
const dst = { position: [], normal: [], color: [], mat: [] };
const m = new THREE.Matrix4();
for (let i = 0; i < ps.length; i++) {
const a = spec.atoms[i], r = radiusOf(a.e) * k;
m.compose(ps[i].clone().multiplyScalar(k), new THREE.Quaternion(), new THREE.Vector3(r, r, r));
appendGeo(dst, sph, m, el(a.e).col, famIndex(a.e));
}
for (const [i, j, order = 1] of (filling ? [] : spec.bonds)) {
const A = ps[i].clone().multiplyScalar(k), B = ps[j].clone().multiplyScalar(k);
const dir = B.clone().sub(A), len = dir.length();
if (len < 1e-6) continue;
const q = new THREE.Quaternion().setFromUnitVectors(Y, dir.clone().normalize());
// A double bond is drawn as two parallel sticks, a triple as three — the notation, again.
// Offset perpendicular to both the bond and the view-ish axis so the split always reads.
const perp = new THREE.Vector3().crossVectors(dir, new THREE.Vector3(0, 0, 1));
if (perp.lengthSq() < 1e-6) perp.set(1, 0, 0);
// Bond radius lives in the same scaled units as the atom radii — ball-and-stick only reads
// as ball-and-stick if the sticks are visibly thinner than the balls.
perp.normalize().multiplyScalar(bondRadius * 1.9 * k);
const rr = bondRadius * k * (order > 1 ? 0.62 : 1);
const offs = order === 1 ? [0] : order === 2 ? [-0.5, 0.5] : [-1, 0, 1];
for (const o of offs) {
const mid = A.clone().add(B).multiplyScalar(0.5).addScaledVector(perp, o);
// Each half of the bond takes its own atom's colour — the classic two-tone stick, and it
// means you can read what's bonded to what without any of the balls being visible.
for (const half of [-1, 1]) {
const c = mid.clone().addScaledVector(dir.clone().normalize(), (len / 4) * half);
const e = spec.atoms[half < 0 ? i : j].e;
m.compose(c, q, new THREE.Vector3(rr, len / 2, rr));
appendGeo(dst, cyl, m, el(e).col, famIndex(e));
}
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(dst.position, 3));
geo.setAttribute('normal', new THREE.Float32BufferAttribute(dst.normal, 3));
geo.setAttribute('color', new THREE.Float32BufferAttribute(dst.color, 3));
geo.setAttribute('aMat', new THREE.Float32BufferAttribute(dst.mat, 1));
geo.computeBoundingSphere();
const mesh = new THREE.Mesh(geo, material || createMoleculeMaterial());
mesh.name = `molecule ${id}`;
mesh.userData = { id, name: spec.name, formula: spec.formula, role: spec.role, blurb: spec.blurb, atoms: spec.atoms.length, tris: dst.position.length / 9, repr };
return mesh;
}