[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>
This commit is contained in:
jing 2026-07-16 21:31:24 +10:00
parent 9f42a2e9ab
commit a99f733452
12 changed files with 357 additions and 46 deletions

View File

@ -5,8 +5,9 @@ systems, their economy is Lane C's, object art is Lane D's — this document is
three, plus a working renderer they can take or leave. three, plus a working renderer they can take or leave.
Fly it: `web/dev/laneA_molecules.html` · code: `web/js/world/molecule.js` · Fly it: `web/dev/laneA_molecules.html` · code: `web/js/world/molecule.js` ·
evidence: `docs/shots/laneA/round2_molecule_pickups.png`, `round2_molecules_in_canal.png`, evidence: `docs/shots/laneA/round2_molecule_pickups.png` (the set + roles),
`round2_molecules_truescale.png` `round2_molecule_materials.png` (flat vs matcap vs space-filling, in the real canal),
`round2_molecules_in_canal.png`, `round2_molecules_truescale.png`
--- ---
@ -46,14 +47,69 @@ That's ART_BIBLE's synthetic-scanner fiction paying rent: the ship identifies a
colours it in. → **Lane E**: this is a free HUD language. Accent a readout in an element's CPK colours it in. → **Lane E**: this is a free HUD language. Accent a readout in an element's CPK
colour and it belongs to that compound with no legend. colour and it belongs to that compound with no legend.
## Why procedural, not MODELBEAST — and where MODELBEAST *is* right ## The division of labour: procedural geometry, generated materials
I'm authorised to burn GPU on this and I'm not going to, for one reason: **a molecule's shape **MODELBEAST makes the materials. Procedural makes the shapes.** Both halves matter and they
is already known exactly.** Glucose is a hexagonal ring because it is a hexagonal ring. FLUX + are not in competition — the first version of this doc framed it as "don't use the GPU", which
TRELLIS would give a plausible blob, in 38 minutes, that a chemist clocks as wrong instantly was the wrong axis and is corrected below.
and that can never be re-derived. Ball-and-stick from an atom list is exact, rebuilds in
milliseconds, and is **one draw call per molecule** (measured: 7 molecules = 7 draws, 26.5k **Geometry is not a generation problem, because a molecule's shape is already known exactly.**
tris total; glucose alone is 3.7k tris / 1 draw). Glucose is a hexagonal ring because it is a hexagonal ring. FLUX + TRELLIS would give a
plausible blob, in 38 minutes, that a chemist clocks as wrong instantly and that can never be
re-derived. Ball-and-stick from an atom list is exact, rebuilds in milliseconds, and is **one
draw call per molecule** (measured: 7 molecules = 7 draws, 26.5k tris total; glucose alone is
3.7k tris / 1 draw).
**Material is exactly a generation problem**, and it's what separates a diagram from an object
— see the next section.
### What MODELBEAST *did* do: the materials (round 2, second pass)
Geometry is exact and free. **Material is where the GPU earns its keep** — and it's the whole
difference between a textbook diagram and a game object. GUTS has no real-time lights by law,
so a **matcap** (a lit-sphere image) is the only way to make something look *made of* anything.
Five greyscale matcaps, flux_local, **69 seconds, $0.00, 68 KB total**:
| matcap | elements | what it does |
|---|---|---|
| `mol_glass` | O, N | **the big win.** Oxygen becomes a deep red glass marble with internal glow. |
| `mol_matte` | C | graphite soot. Carbon is scaffold, and now it reads as scaffold. |
| `mol_chrome` | Co, Fe, Na | polished metal — B₁₂'s cobalt is a chrome bearing in a cage. |
| `mol_molten` | P | ATP's phosphate tail is incandescent, and **pulses** (`uTime`, free). |
| `mol_pearl` | H | soft satin white; hydrogens stop shouting. |
**The trick that makes it cheap is Lane D's own law, applied to spheres**: author the
*luminance*, tint in-shader. `derive_maps` greyscales every matcap already, so one glass ball
serves oxygen *and* nitrogen at their own CPK hues. Five balls dress the whole periodic table
we care about, and they pack into one strip atlas indexed per-vertex by `aMat` — so **a
molecule is still exactly one draw call** no matter how many materials its atoms are made of.
`?matcap=0` falls back to the built-in fake-lit path: the assets-optional law holds.
Two things that only showed up by rendering it, both fixed:
- **Tint × luminance darkens twice.** CPK carbon is already 0.4 and a matte ball averages 0.5,
so soot × 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 has 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 the phosphorus lost its orange
entirely. Re-rolled asking for the *whole sphere* to glow. 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.** Ask for bright, always.
### What the canal decided: space-filling wins for pickups
`buildMolecule(id, {repr})` now does `'ball-stick'` (default) or `'space-filling'` — every atom
at its real van der Waals radius, no sticks, atoms merging into one solid lump. A/B'd at real
pickup size against the real L2 wall, same camera (`round2_molecule_materials.png`):
**Ball-and-stick goes spindly and starts dissolving. Space-filling holds its silhouette — and
it is CHEAPER** (21k vs 26k tris across the set: no bond cylinders). It's also more true, since
that *is* how a molecule occupies space.
**Recommendation to B**: `space-filling` for anything in flight, `ball-stick` for hero, UI,
the medal card and the collect close-up — where the chemistry is legible and worth reading.
The obvious LOD (blob far, sticks near) is untested and I'm not claiming it.
**The split, and it's a principle, not a preference:** **The split, and it's a principle, not a preference:**
@ -132,5 +188,14 @@ fixed box — B's call.)
but nobody should mistake this table for a structure database later. but nobody should mistake this table for a structure database later.
- **The B12 is a simplified corrin core** — the real thing has a nucleotide tail and far more - **The B12 is a simplified corrin core** — the real thing has a nucleotide tail and far more
side chains. It reads as "jewel in a cage", which is the job. side chains. It reads as "jewel in a cage", which is the job.
- **Not performance-tested at pickup density.** 7 on screen is 7 draws / 26k tris. Fifty would - **Not performance-tested at pickup density.** 7 on screen is 7 draws / 26k tris (13 draws /
want instancing or a merged batch, and neither exists yet. 87k with the canal behind them). Fifty would want instancing or a merged batch, and neither
exists yet.
- **The matcap atlas is built at runtime from a canvas.** Fine, but it means a one-frame hitch
on first build and it bypasses `assets.matcap()` (it reads `assets.get('matcaps', …).url`
the documented floor — and loads the images itself, because an atlas needs pixels and
`matcap()` hands back a `THREE.Texture` that may not have decoded yet). If this is adopted,
baking the atlas offline in `derive_maps.py` would be tidier and D may prefer it.
- **`repr: 'space-filling'` hides the bonds by definition**, so a molecule's *chemistry* stops
being readable — that's the trade for silhouette, and it's why ball-stick stays the default
for anything the player is meant to look AT rather than fly at.

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

View File

@ -1,4 +1,69 @@
{ {
"matcap_mol_chrome": {
"gen_seconds": 12.6,
"guidance": 3.5,
"kb": 8,
"kind": "matcap",
"model": "flux2-klein-4b",
"prompt": "spherical material study ball, polished chrome metal, mirror finish, bright hard specular highlights, studio black background",
"seam": null,
"seed": 1412,
"size": 512,
"steps": 4,
"tile": null
},
"matcap_mol_glass": {
"gen_seconds": 12.9,
"guidance": 3.5,
"kb": 6,
"kind": "matcap",
"model": "flux2-klein-4b",
"prompt": "spherical material study ball, polished translucent glass marble, deep internal glow, single sharp specular highlight, studio black background",
"seam": null,
"seed": 1410,
"size": 512,
"steps": 4,
"tile": null
},
"matcap_mol_matte": {
"gen_seconds": 15.1,
"guidance": 3.5,
"kb": 33,
"kind": "matcap",
"model": "flux2-klein-4b",
"prompt": "spherical material study ball, matte graphite, fine powdery surface, soft diffuse shading, no shine, studio black background",
"seam": null,
"seed": 1411,
"size": 512,
"steps": 4,
"tile": null
},
"matcap_mol_molten": {
"gen_seconds": 10.1,
"guidance": 3.5,
"kb": 37,
"kind": "matcap",
"model": "flux2-klein-4b",
"prompt": "spherical material study ball, glowing hot lava, the entire sphere uniformly incandescent and bright, even emissive glow, studio black background",
"seam": null,
"seed": 1413,
"size": 512,
"steps": 4,
"tile": null
},
"matcap_mol_pearl": {
"gen_seconds": 10.8,
"guidance": 3.5,
"kb": 4,
"kind": "matcap",
"model": "flux2-klein-4b",
"prompt": "spherical material study ball, white pearl, soft satin subsurface sheen, gentle highlight, studio black background",
"seam": null,
"seed": 1414,
"size": 512,
"steps": 4,
"tile": null
},
"matcap_tissue_wet": { "matcap_tissue_wet": {
"gen_seconds": 8.9, "gen_seconds": 8.9,
"guidance": 3.5, "guidance": 3.5,

View File

@ -122,6 +122,41 @@ MATCAPS = {
p=("spherical material study ball, wet translucent organic tissue, subsurface glow, " p=("spherical material study ball, wet translucent organic tissue, subsurface glow, "
"studio black background"), "studio black background"),
seed=1401, stem_override=True), seed=1401, stem_override=True),
# ── molecule material families (Lane A, round 2) ─────────────────────────────────────
# GUTS has no real-time lights (ART_BIBLE), so a matcap is the ONLY way to make an object
# look *made of* something. These are the molecule pickups' materials — see docs/MOLECULES.md.
#
# Why families and not one per element: derive_maps greyscales every matcap anyway
# (`load_gray`), and Lane D already proved the pattern on the walls — author the LUMINANCE,
# let the shader apply the tint. So one greyscale "glass" ball serves oxygen AND nitrogen at
# their own CPK hues, and five balls dress the whole periodic table we care about. The CPK
# colour keeps the science legible; the matcap decides whether it reads as a gem, a metal or
# a lump of soot.
"matcap_mol_glass": dict(
p=("spherical material study ball, polished translucent glass marble, deep internal "
"glow, single sharp specular highlight, studio black background"),
seed=1410, stem_override=True),
"matcap_mol_matte": dict(
p=("spherical material study ball, matte graphite, fine powdery surface, soft diffuse "
"shading, no shine, studio black background"),
seed=1411, stem_override=True),
"matcap_mol_chrome": dict(
p=("spherical material study ball, polished chrome metal, mirror finish, bright hard "
"specular highlights, studio black background"),
seed=1412, stem_override=True),
"matcap_mol_molten": dict(
# v1 asked for an "incandescent white hot CORE" and got exactly that: a black ball with
# a hot spot. A matcap is a LUMINANCE lookup that gets multiplied by the CPK tint, so a
# dark-dominant ball makes a dark-dominant atom — ATP's phosphates came out as tiny
# white specks with no orange in them. Ask for the whole sphere to glow, not a core.
p=("spherical material study ball, glowing hot lava, the entire sphere uniformly "
"incandescent and bright, even emissive glow, studio black background"),
seed=1413, stem_override=True),
"matcap_mol_pearl": dict(
p=("spherical material study ball, white pearl, soft satin subsurface sheen, gentle "
"highlight, studio black background"),
seed=1414, stem_override=True),
} }
PROMPTS = {**{k: v for k, v in WALLS.items()}, **MATCAPS} PROMPTS = {**{k: v for k, v in WALLS.items()}, **MATCAPS}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -41,6 +41,21 @@
} }
}, },
"matcaps": { "matcaps": {
"mol_chrome": {
"url": "gen/matcap_mol_chrome.webp"
},
"mol_glass": {
"url": "gen/matcap_mol_glass.webp"
},
"mol_matte": {
"url": "gen/matcap_mol_matte.webp"
},
"mol_molten": {
"url": "gen/matcap_mol_molten.webp"
},
"mol_pearl": {
"url": "gen/matcap_mol_pearl.webp"
},
"tissue_wet": { "tissue_wet": {
"url": "gen/matcap_tissue_wet.webp" "url": "gen/matcap_tissue_wet.webp"
} }

View File

@ -32,10 +32,11 @@
<div id="dbg"></div> <div id="dbg"></div>
<script type="module"> <script type="module">
import * as THREE from 'three'; import * as THREE from 'three';
import { buildMolecule, listMolecules, createMoleculeMaterial, MOLECULES } from '../js/world/molecule.js'; import { buildMolecule, listMolecules, createMoleculeMaterial, MOLECULES, buildMatcapAtlas, matcapUrls } from '../js/world/molecule.js';
const p = new URLSearchParams(location.search); const p = new URLSearchParams(location.search);
const on = (k) => p.has(k) && p.get(k) !== '0'; const on = (k) => p.has(k) && p.get(k) !== '0';
const REPR = p.get('repr') || 'ball-stick'; // ?repr=space-filling
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true }); const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
@ -47,8 +48,22 @@ const scene = new THREE.Scene();
scene.background = new THREE.Color(0x05070a); scene.background = new THREE.Color(0x05070a);
const camera = new THREE.PerspectiveCamera(45, (innerWidth || 1280) / (innerHeight || 720), 0.1, 200); const camera = new THREE.PerspectiveCamera(45, (innerWidth || 1280) / (innerHeight || 720), 0.1, 200);
// ONE material for every molecule on screen — colour is per-vertex, so they still batch. // MODELBEAST's material families, via the documented asset contract. `?matcap=0` forces the
const material = createMoleculeMaterial(); // built-in fake-lit path — that's the assets-optional law under test, not a debug toggle.
let atlas = null;
if (p.get('matcap') !== '0') {
try {
const { createAssets } = await import('../js/core/assets.js');
const assets = await createAssets({ flags: { localassets: true } });
atlas = await buildMatcapAtlas(matcapUrls(assets));
} catch (e) {
console.info('[molbench] no assets, built-in shading —', e.message);
}
}
// ONE material for every molecule on screen — colour AND material family are per-vertex
// (the matcaps live in one strip atlas), so they still batch to a draw apiece.
const material = createMoleculeMaterial({ matcap: atlas });
const root = new THREE.Group(); const root = new THREE.Group();
scene.add(root); scene.add(root);
@ -66,7 +81,7 @@ function grid() {
const rows = Math.ceil(ids.length / cols); const rows = Math.ceil(ids.length / cols);
const pitch = 2.6; const pitch = 2.6;
ids.forEach((id, i) => { ids.forEach((id, i) => {
const m = buildMolecule(id, { fit: 1, detail: 2, material }); const m = buildMolecule(id, { fit: 1, detail: 2, material, repr: REPR });
m.position.set((i % cols - (cols - 1) / 2) * pitch, -((i / cols | 0) - (rows - 1) / 2) * pitch, 0); m.position.set((i % cols - (cols - 1) / 2) * pitch, -((i / cols | 0) - (rows - 1) / 2) * pitch, 0);
root.add(m); spinners.push(m); root.add(m); spinners.push(m);
}); });
@ -79,7 +94,7 @@ function trueScale(unit = 0.42, gap = 0.5) {
clear(); clear();
const order = [...ids].sort((a, b) => MOLECULES[a].atoms.length - MOLECULES[b].atoms.length); const order = [...ids].sort((a, b) => MOLECULES[a].atoms.length - MOLECULES[b].atoms.length);
const built = order.map((id) => { const built = order.map((id) => {
const m = buildMolecule(id, { unit, detail: 2, material }); const m = buildMolecule(id, { unit, detail: 2, material, repr: REPR });
m.geometry.computeBoundingSphere(); m.geometry.computeBoundingSphere();
return { m, r: m.geometry.boundingSphere.radius }; return { m, r: m.geometry.boundingSphere.radius };
}); });
@ -96,7 +111,7 @@ function trueScale(unit = 0.42, gap = 0.5) {
function one(id) { function one(id) {
clear(); clear();
const m = buildMolecule(id, { fit: 1, detail: 3, material }); const m = buildMolecule(id, { fit: 1, detail: 3, material, repr: REPR });
root.add(m); spinners.push(m); root.add(m); spinners.push(m);
camera.position.set(0, 0, 3.1); camera.position.set(0, 0, 3.1);
camera.lookAt(0, 0, 0); camera.lookAt(0, 0, 0);
@ -124,6 +139,7 @@ window.DBG = {
pose(t = 0.6, w = 1280, h = 720) { pose(t = 0.6, w = 1280, h = 720) {
this.size(w, h); this.size(w, h);
for (const m of spinners) { m.rotation.set(t * 0.55, t, t * 0.2); } for (const m of spinners) { m.rotation.set(t * 0.55, t, t * 0.2); }
material.uniforms.uTime.value = t;
renderer.render(scene, camera); renderer.render(scene, camera);
const r = { draws: renderer.info.render.calls, tris: renderer.info.render.triangles }; const r = { draws: renderer.info.render.calls, tris: renderer.info.render.triangles };
renderer.info.reset(); renderer.info.reset();
@ -149,6 +165,7 @@ let t = 0, last = performance.now();
function frame(now) { function frame(now) {
const dt = Math.min((now - last) / 1000, 0.05); last = now; t += dt * 0.6; const dt = Math.min((now - last) / 1000, 0.05); last = now; t += dt * 0.6;
for (const m of spinners) m.rotation.set(t * 0.55, t, t * 0.2); for (const m of spinners) m.rotation.set(t * 0.55, t, t * 0.2);
material.uniforms.uTime.value = t;
renderer.render(scene, camera); renderer.render(scene, camera);
if (!on('shots')) { if (!on('shots')) {
const u = spinners.length === 1 ? spinners[0].userData : null; const u = spinners.length === 1 ? spinners[0].userData : null;

View File

@ -38,19 +38,74 @@ const TAU = Math.PI * 2;
// invisible here, so carbon is lifted to a grey that still reads as "not an element with a // 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 // 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. // 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 = { export const ELEMENTS = {
H: { col: 0xf2f2f2, r: 0.26 }, H: { col: 0xf2f2f2, r: 0.26, vdw: 1.20, mat: 'pearl' },
C: { col: 0x63666e, r: 0.40 }, C: { col: 0x63666e, r: 0.40, vdw: 1.70, mat: 'matte' },
N: { col: 0x3b60ff, r: 0.40 }, N: { col: 0x3b60ff, r: 0.40, vdw: 1.55, mat: 'glass' },
O: { col: 0xff3222, r: 0.39 }, O: { col: 0xff3222, r: 0.39, vdw: 1.52, mat: 'glass' },
P: { col: 0xff9020, r: 0.50 }, P: { col: 0xff9020, r: 0.50, vdw: 1.80, mat: 'molten' },
S: { col: 0xf5f52a, r: 0.49 }, S: { col: 0xf5f52a, r: 0.49, vdw: 1.80, mat: 'glass' },
Cl: { col: 0x35e035, r: 0.44 }, Cl: { col: 0x35e035, r: 0.44, vdw: 1.75, mat: 'glass' },
Na: { col: 0xab5cf2, r: 0.52 }, Na: { col: 0xab5cf2, r: 0.52, vdw: 2.27, mat: 'chrome' },
Fe: { col: 0xe06633, r: 0.55 }, Fe: { col: 0xe06633, r: 0.55, vdw: 2.00, mat: 'chrome' },
Co: { col: 0xf090a0, r: 0.56 }, Co: { col: 0xf090a0, r: 0.56, vdw: 2.00, mat: 'chrome' },
}; };
const el = (e) => ELEMENTS[e] || { col: 0xff00ff, r: 0.4 }; // magenta = you typo'd an element 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 ─────────────────────────────────────────────────────────────────── // ── authoring helpers ───────────────────────────────────────────────────────────────────
/** A regular n-gon in the XY plane. Rings are rings; this is most of chemistry's shapes. */ /** A regular n-gon in the XY plane. Rings are rings; this is most of chemistry's shapes. */
@ -316,34 +371,77 @@ export const listMolecules = () => Object.keys(MOLECULES);
* scanner's cyan says "this object has been identified" the same rim language the wall uses, * 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. * so molecules belong to the world instead of being stickers on it.
*/ */
export function createMoleculeMaterial({ scan = 0x7fdfff, scanGain = 0.55 } = {}) { export function createMoleculeMaterial({ scan = 0x7fdfff, scanGain = 0.55, matcap = null } = {}) {
return new THREE.ShaderMaterial({ return new THREE.ShaderMaterial({
defines: matcap ? { USE_MATCAP: '' } : {},
uniforms: { uniforms: {
uScan: { value: new THREE.Color(scan) }, uScan: { value: new THREE.Color(scan) },
uScanGain: { value: scanGain }, uScanGain: { value: scanGain },
uKey: { value: new THREE.Vector3(0.35, 0.72, 0.6).normalize() }, 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 */` vertexShader: /* glsl */`
attribute vec3 color; attribute vec3 color;
varying vec3 vColor; varying vec3 vN; varying vec3 vView; attribute float aMat;
varying vec3 vColor; varying vec3 vN; varying vec3 vView; varying float vMat;
void main() { void main() {
vColor = color; vColor = color;
vMat = aMat;
vec4 mv = modelViewMatrix * vec4(position, 1.0); vec4 mv = modelViewMatrix * vec4(position, 1.0);
vN = normalize(normalMatrix * normal); vN = normalize(normalMatrix * normal);
vView = -mv.xyz; vView = -mv.xyz;
gl_Position = projectionMatrix * mv; gl_Position = projectionMatrix * mv;
}`, }`,
fragmentShader: /* glsl */` fragmentShader: /* glsl */`
uniform vec3 uScan, uKey; uniform float uScanGain; uniform vec3 uScan, uKey; uniform float uScanGain, uMatCount, uMolten, uTime;
varying vec3 vColor; varying vec3 vN; varying vec3 vView; #ifdef USE_MATCAP
uniform sampler2D uMatcap;
#endif
varying vec3 vColor; varying vec3 vN; varying vec3 vView; varying float vMat;
void main() { void main() {
vec3 N = normalize(vN), V = normalize(vView); vec3 N = normalize(vN), V = normalize(vView);
// Key light in VIEW space: it follows the camera, so a molecule tumbling in flight
// never rotates into an unlit pose. Wrapped (0.30 floor) so nothing goes to silhouette.
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);
float fres = pow(1.0 - max(0.0, dot(N, V)), 3.0); float fres = pow(1.0 - max(0.0, dot(N, V)), 3.0);
vec3 col = vColor * diff + vec3(1.0) * spec * 0.55 + uScan * fres * uScanGain; 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); gl_FragColor = vec4(col, 1.0);
#include <colorspace_fragment> #include <colorspace_fragment>
}`, }`,
@ -363,7 +461,7 @@ const templates = (detail) => {
return { SPHERE, CYL }; return { SPHERE, CYL };
}; };
function appendGeo(dst, src, matrix, colorHex) { function appendGeo(dst, src, matrix, colorHex, mat = 0) {
const nm = new THREE.Matrix3().getNormalMatrix(matrix); const nm = new THREE.Matrix3().getNormalMatrix(matrix);
const pos = src.attributes.position, nor = src.attributes.normal; const pos = src.attributes.position, nor = src.attributes.normal;
const c = new THREE.Color(colorHex); const c = new THREE.Color(colorHex);
@ -374,6 +472,7 @@ function appendGeo(dst, src, matrix, colorHex) {
dst.position.push(v.x, v.y, v.z); dst.position.push(v.x, v.y, v.z);
dst.normal.push(n.x, n.y, n.z); dst.normal.push(n.x, n.y, n.z);
dst.color.push(c.r, c.g, c.b); dst.color.push(c.r, c.g, c.b);
dst.mat.push(mat);
} }
} }
@ -391,32 +490,45 @@ const Y = new THREE.Vector3(0, 1, 0);
* tells the player what a thing is worth before they read a * tells the player what a thing is worth before they read a
* single colour, and it costs nothing because it's just true. * 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 {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) * @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. * @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 } = {}) { export function buildMolecule(id, { fit = 1, unit = 0, detail = 2, material = null, bondRadius = 0.13, repr = 'ball-stick' } = {}) {
const spec = MOLECULES[id]; const spec = MOLECULES[id];
if (!spec) throw new Error(`[molecule] unknown molecule "${id}". Have: ${listMolecules().join(', ')}`); if (!spec) throw new Error(`[molecule] unknown molecule "${id}". Have: ${listMolecules().join(', ')}`);
const { SPHERE: sph, CYL: cyl } = templates(detail); const { SPHERE: sph, CYL: cyl } = templates(detail);
// Recentre on the atom centroid and solve the scale that makes it `fit`, so every molecule // 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. // 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 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); const centre = ps.reduce((acc, p) => acc.add(p), new THREE.Vector3()).multiplyScalar(1 / ps.length);
ps.forEach((p) => p.sub(centre)); ps.forEach((p) => p.sub(centre));
const extent = Math.max(...ps.map((p, i) => p.length() + el(spec.atoms[i].e).r)) || 1; const extent = Math.max(...ps.map((p, i) => p.length() + radiusOf(spec.atoms[i].e))) || 1;
const k = unit > 0 ? unit : fit / extent; const k = unit > 0 ? unit : fit / extent;
const dst = { position: [], normal: [], color: [] }; const dst = { position: [], normal: [], color: [], mat: [] };
const m = new THREE.Matrix4(); const m = new THREE.Matrix4();
for (let i = 0; i < ps.length; i++) { for (let i = 0; i < ps.length; i++) {
const a = spec.atoms[i], r = el(a.e).r * k; 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)); m.compose(ps[i].clone().multiplyScalar(k), new THREE.Quaternion(), new THREE.Vector3(r, r, r));
appendGeo(dst, sph, m, el(a.e).col); appendGeo(dst, sph, m, el(a.e).col, famIndex(a.e));
} }
for (const [i, j, order = 1] of spec.bonds) { for (const [i, j, order = 1] of (filling ? [] : spec.bonds)) {
const A = ps[i].clone().multiplyScalar(k), B = ps[j].clone().multiplyScalar(k); const A = ps[i].clone().multiplyScalar(k), B = ps[j].clone().multiplyScalar(k);
const dir = B.clone().sub(A), len = dir.length(); const dir = B.clone().sub(A), len = dir.length();
if (len < 1e-6) continue; if (len < 1e-6) continue;
@ -436,8 +548,9 @@ export function buildMolecule(id, { fit = 1, unit = 0, detail = 2, material = nu
// means you can read what's bonded to what without any of the balls being visible. // means you can read what's bonded to what without any of the balls being visible.
for (const half of [-1, 1]) { for (const half of [-1, 1]) {
const c = mid.clone().addScaledVector(dir.clone().normalize(), (len / 4) * half); 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)); m.compose(c, q, new THREE.Vector3(rr, len / 2, rr));
appendGeo(dst, cyl, m, el(spec.atoms[half < 0 ? i : j].e).col); appendGeo(dst, cyl, m, el(e).col, famIndex(e));
} }
} }
} }
@ -446,10 +559,11 @@ export function buildMolecule(id, { fit = 1, unit = 0, detail = 2, material = nu
geo.setAttribute('position', new THREE.Float32BufferAttribute(dst.position, 3)); geo.setAttribute('position', new THREE.Float32BufferAttribute(dst.position, 3));
geo.setAttribute('normal', new THREE.Float32BufferAttribute(dst.normal, 3)); geo.setAttribute('normal', new THREE.Float32BufferAttribute(dst.normal, 3));
geo.setAttribute('color', new THREE.Float32BufferAttribute(dst.color, 3)); geo.setAttribute('color', new THREE.Float32BufferAttribute(dst.color, 3));
geo.setAttribute('aMat', new THREE.Float32BufferAttribute(dst.mat, 1));
geo.computeBoundingSphere(); geo.computeBoundingSphere();
const mesh = new THREE.Mesh(geo, material || createMoleculeMaterial()); const mesh = new THREE.Mesh(geo, material || createMoleculeMaterial());
mesh.name = `molecule ${id}`; 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 }; 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; return mesh;
} }