[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:
parent
9f42a2e9ab
commit
a99f733452
@ -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.
|
||||
|
||||
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`,
|
||||
`round2_molecules_truescale.png`
|
||||
evidence: `docs/shots/laneA/round2_molecule_pickups.png` (the set + roles),
|
||||
`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
|
||||
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
|
||||
is already known exactly.** Glucose is a hexagonal ring because it is a hexagonal ring. FLUX +
|
||||
TRELLIS would give a plausible blob, in 3–8 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).
|
||||
**MODELBEAST makes the materials. Procedural makes the shapes.** Both halves matter and they
|
||||
are not in competition — the first version of this doc framed it as "don't use the GPU", which
|
||||
was the wrong axis and is corrected below.
|
||||
|
||||
**Geometry is not a generation problem, because a molecule's shape is already known exactly.**
|
||||
Glucose is a hexagonal ring because it is a hexagonal ring. FLUX + TRELLIS would give a
|
||||
plausible blob, in 3–8 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:**
|
||||
|
||||
@ -132,5 +188,14 @@ fixed box — B's call.)
|
||||
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
|
||||
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
|
||||
want instancing or a merged batch, and neither exists yet.
|
||||
- **Not performance-tested at pickup density.** 7 on screen is 7 draws / 26k tris (13 draws /
|
||||
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.
|
||||
|
||||
BIN
docs/shots/laneA/round2_molecule_materials.png
Normal file
BIN
docs/shots/laneA/round2_molecule_materials.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 444 KiB |
@ -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": {
|
||||
"gen_seconds": 8.9,
|
||||
"guidance": 3.5,
|
||||
|
||||
@ -122,6 +122,41 @@ MATCAPS = {
|
||||
p=("spherical material study ball, wet translucent organic tissue, subsurface glow, "
|
||||
"studio black background"),
|
||||
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}
|
||||
|
||||
|
||||
BIN
web/assets/gen/matcap_mol_chrome.webp
Normal file
BIN
web/assets/gen/matcap_mol_chrome.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
BIN
web/assets/gen/matcap_mol_glass.webp
Normal file
BIN
web/assets/gen/matcap_mol_glass.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
BIN
web/assets/gen/matcap_mol_matte.webp
Normal file
BIN
web/assets/gen/matcap_mol_matte.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
web/assets/gen/matcap_mol_molten.webp
Normal file
BIN
web/assets/gen/matcap_mol_molten.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
web/assets/gen/matcap_mol_pearl.webp
Normal file
BIN
web/assets/gen/matcap_mol_pearl.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@ -41,6 +41,21 @@
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"url": "gen/matcap_tissue_wet.webp"
|
||||
}
|
||||
|
||||
@ -32,10 +32,11 @@
|
||||
<div id="dbg"></div>
|
||||
<script type="module">
|
||||
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 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 });
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||
@ -47,8 +48,22 @@ const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x05070a);
|
||||
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.
|
||||
const material = createMoleculeMaterial();
|
||||
// MODELBEAST's material families, via the documented asset contract. `?matcap=0` forces the
|
||||
// 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();
|
||||
scene.add(root);
|
||||
|
||||
@ -66,7 +81,7 @@ function grid() {
|
||||
const rows = Math.ceil(ids.length / cols);
|
||||
const pitch = 2.6;
|
||||
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);
|
||||
root.add(m); spinners.push(m);
|
||||
});
|
||||
@ -79,7 +94,7 @@ function trueScale(unit = 0.42, gap = 0.5) {
|
||||
clear();
|
||||
const order = [...ids].sort((a, b) => MOLECULES[a].atoms.length - MOLECULES[b].atoms.length);
|
||||
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();
|
||||
return { m, r: m.geometry.boundingSphere.radius };
|
||||
});
|
||||
@ -96,7 +111,7 @@ function trueScale(unit = 0.42, gap = 0.5) {
|
||||
|
||||
function one(id) {
|
||||
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);
|
||||
camera.position.set(0, 0, 3.1);
|
||||
camera.lookAt(0, 0, 0);
|
||||
@ -124,6 +139,7 @@ window.DBG = {
|
||||
pose(t = 0.6, w = 1280, h = 720) {
|
||||
this.size(w, h);
|
||||
for (const m of spinners) { m.rotation.set(t * 0.55, t, t * 0.2); }
|
||||
material.uniforms.uTime.value = t;
|
||||
renderer.render(scene, camera);
|
||||
const r = { draws: renderer.info.render.calls, tris: renderer.info.render.triangles };
|
||||
renderer.info.reset();
|
||||
@ -149,6 +165,7 @@ let t = 0, last = performance.now();
|
||||
function frame(now) {
|
||||
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);
|
||||
material.uniforms.uTime.value = t;
|
||||
renderer.render(scene, camera);
|
||||
if (!on('shots')) {
|
||||
const u = spinners.length === 1 ? spinners[0].userData : null;
|
||||
|
||||
@ -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
|
||||
// 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 },
|
||||
C: { col: 0x63666e, r: 0.40 },
|
||||
N: { col: 0x3b60ff, r: 0.40 },
|
||||
O: { col: 0xff3222, r: 0.39 },
|
||||
P: { col: 0xff9020, r: 0.50 },
|
||||
S: { col: 0xf5f52a, r: 0.49 },
|
||||
Cl: { col: 0x35e035, r: 0.44 },
|
||||
Na: { col: 0xab5cf2, r: 0.52 },
|
||||
Fe: { col: 0xe06633, r: 0.55 },
|
||||
Co: { col: 0xf090a0, r: 0.56 },
|
||||
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 }; // 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 ───────────────────────────────────────────────────────────────────
|
||||
/** 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,
|
||||
* 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({
|
||||
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;
|
||||
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() {
|
||||
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;
|
||||
varying vec3 vColor; varying vec3 vN; varying vec3 vView;
|
||||
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);
|
||||
// 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 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);
|
||||
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;
|
||||
col = vColor * diff + vec3(1.0) * spec * 0.55;
|
||||
#endif
|
||||
|
||||
col += uScan * fres * uScanGain;
|
||||
gl_FragColor = vec4(col, 1.0);
|
||||
#include <colorspace_fragment>
|
||||
}`,
|
||||
@ -363,7 +461,7 @@ const templates = (detail) => {
|
||||
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 pos = src.attributes.position, nor = src.attributes.normal;
|
||||
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.normal.push(n.x, n.y, n.z);
|
||||
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
|
||||
* 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 } = {}) {
|
||||
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() + 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 dst = { position: [], normal: [], color: [] };
|
||||
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 = 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));
|
||||
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 dir = B.clone().sub(A), len = dir.length();
|
||||
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.
|
||||
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(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('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 };
|
||||
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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user