Skip to content

A three.js bridge: GLB exporter, a wild-field demo, and the air crossing with it - #45

Merged
aj-dev-smith merged 19 commits into
mainfrom
claude/procedural-threejs-landscape-ojast9
Aug 15, 2026
Merged

aj-dev-smith merged 19 commits into
mainfrom
claude/procedural-threejs-landscape-ojast9

Conversation

@aj-dev-smith

Copy link
Copy Markdown
Owner

What this changes

The simulation goes out to the rest of the web: a GLB converter downstream of the Blender bridge's .json+.bin pair, a mutation-tested validator, a render proof in stock three.js, and demo/ — a wild field of 132 grown specimens (8 seeds, 4 species, adults + juveniles + one senescing) standing in a procedural night landscape, moved by the engine's own wind field, filmed by a forager-behaviour camera. Plus two deploy lanes (GitHub Pages and S3/CloudFront, both manual-trigger and inert until wired) so the demo runs on visitors' own GPUs.

Does it add a spatial prior?

No. src/ is untouched — not one line — and SCIENCE.md's list is unchanged. The demo's terrain, grass, rocks, pond, moon and sky are authored, and its HUD says so plainly: they are environment in the same category as the wind, and the plants are the part nobody drew. The one rule crosses the bridge intact: environment may be modelled, organisms may not.

Numbers

Nothing here touches the simulation, so the gates are unchanged by construction — verified rather than assumed:

  • node test/smoke.mjs — all 73 checks pass (run on this branch)
  • node test/gltf.mjs — new, 117 checks, 0.4s, parses the GLB with its own DataView reader sharing no code with the writer; mutation-tested: ten deliberate converter breaks each produce named failures
  • tools/gltf_shot.mjs — Cathedral Fern seed 21 renders in stock three.js 0.161: 835,224 lamina verts / 278,408 tris, 236,880 vein tube verts, 2.360 m tall (the meters bake, matching the browser), gated by loader-error, triangles > 0, and centre-crop near-black and flatness checks (a valid decoy GLB with two mm triangles a km apart passes every other gate; both watched failing)
  • VEINS=lines (conifer foliage as the browser's own additive lines): 116 → 43.6 MB
  • demo/jerk.mjs — flight camera bounded at max 2.29°/frame (the 55°/s cap exactly), from a measured worst of 135° in one frame before the orientation was rate-limited
  • Demo stills reproduce bit-exact at fixed plant time (diff 0.0000); +1.5 s of wind moves 2.5% of pixels — the canopy demonstrably sways under the shipped field

Findings a reviewer should know, all documented in-repo: UnrealBloomPass renders black in headless GL on every backend (bisected; bloom is ~60 owned lines instead), additive emissive stacks to Inf in half-float targets and Inf through ACES is NaN (clamped at the door), strand chaining recovers only 0.6% on leaf veins because 50_geom.js emits them traffic-sorted (real fix is strand-order metadata in the bridge — noted in tools/README.md, deliberately not a converter-side weld), and the film scout's dynamics had to be made identical to the film's (sub-stepped at 24 Hz) before its framings could be trusted.

Checklist

  • Changes are in src/, not in canalisation.html — no src/ changes at all; canalisation.html untouched, so no rebuild was needed (CI's staleness gate confirms)
  • Ran node build.js and committed the regenerated canalisation.html — n/a, artifact unchanged; build verified to parse
  • node test/smoke.mjs passes
  • Read docs/TUNING.md if I changed a constant — no simulation constants touched; the demo's own look constants are documented where they live
  • Added a note to docs/JOURNAL.md if I tried something that did not work — the negative results (UnrealBloomPass, the runtime-lookAt whip, the lying scout) are written up in demo/README.md, tools/README.md and the commit messages, beside the code they concern

🤖 Generated with Claude Code

https://claude.ai/code/session_01A4dBsLoHroiPssTXX1L1E6


Generated by Claude Code

claude added 19 commits August 14, 2026 18:19
…der proof

tools/gltf_export.mjs consumes the Blender bridge's .json+.bin pair — the
bridge stays the single grower and capturer — and writes a self-contained
binary glTF 2.0: the lamina as a triangle soup, veins chained into strands
and swept as indexed tubes (radius = the half-width the chemistry grew,
baked to meters via unitM like every position), points with a _SIZE
attribute, palette and provenance in scene extras. No dependencies.

tools/gltf_shot.mjs proves the claim that matters — 'it parses' and 'it
renders in real three.js' are different claims — by vendoring pinned
three.js 0.161 into tools/.cache, serving it locally, loading the GLB with
stock GLTFLoader under Playwright, and asserting the render is neither
near-black (the documented black-PNG pitfall) nor flat: a decoy GLB with
two mm-scale triangles a km apart renders a perfect background and passes
any brightness test, so a modal-luminance ink fraction gates flatness too.

Measured on Cathedral Fern seed 21 at peak: 278,408 lamina triangles,
23,756 segments -> 237,560 tube triangles, 2.360 m tall in three.js,
43.6 MB. Strand chaining recovers only 0.6% on leaf veins because
50_geom.js emits them traffic-sorted for the LOD prefix — axis ribbons
chain, the vein network arrives shuffled. VEIN_MIN_W is the LOD lever
(0.008 drops 78.6% of segments, 34.4 MB); strand-order metadata in the
bridge is the real fix and a separate decision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4dBsLoHroiPssTXX1L1E6
test/gltf.mjs is 109 checks in 0.4s with no plant grown: it synthesises a
tiny canalisation-specimen-1 pair, runs the converter on it four ways, and
parses the result with its own DataView reader that shares no code with the
writer — a check whose reference is the thing being checked is not a check.
Mutation-tested: ten deliberate breaks in the converter each produce named
failures. Its sections run as functions so one throw costs one check, not
the rest of the file; and it says plainly that its alignment assertions
cannot currently fail (every emitted buffer is a whole number of 4-byte
units by arithmetic), rather than passing them off as coverage.

gltf_shot.mjs now solves the framing exactly from the bbox corners instead
of fitting the bounding sphere — a plant is tall and narrow, so the sphere
fit left the specimen in a third of the frame, which is the tree_shot.mjs
lesson: 'looks lost' and 'is too sparse' are the same picture. All three
of its failure gates have now been watched failing, including the one that
justifies the file: a valid GLB with two mm triangles a km apart loads,
reports triangles=2, and renders a perfect picture of the background.

tools/README.md documents the three.js bridge; CLAUDE.md's asserting-test
roster goes twelve to thirteen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4dBsLoHroiPssTXX1L1E6
demo/ is the showcase the pipeline exists for: five grown specimens on a
terrain in stock three.js. build_assets.mjs is the manifest — species,
seed, steps, nothing else, because growth is deterministic and the stand
reproduces bit for bit. The scene's look is read out of the species
palettes riding in the GLB extras (sky dome, fog, both lights); the
terrain is the one modelled thing in frame and the HUD says so. The
emissive weight the exporter bakes into COLOR_0's alpha comes back as
glow through one onBeforeCompile line, and the horizon is stitched by
giving the sky dome the fog's own colour at its floor — both seams were
found by looking, which is this repo's standing instrument.

VEINS=lines is the converter growing its first real LOD decision: a
conifer's foliage IS its segments, so a width cut strips the crown
rather than slimming it, and sweeping 235k unchained needle stubs into
prisms cost 81 MB to say what the browser says with additive glowing
lines (43.6 MB, and the browser's own idiom). At full additive strength
the crown summed to a solid white cone — watched, not guessed — so the
demo pays opacity 0.16 where the browser's grade and LOD normally pay.

test/gltf.mjs grows eight checks for the mode (117 total): the absence
of the tube node is the feature, and an unknown VEINS value is refused
rather than defaulted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4dBsLoHroiPssTXX1L1E6
…no conifer

The demo now mixes the grown plants into the procedural-landscape kit the
three.js ecosystem trades in: ridged-fbm far hills, 42k instanced grass
blades wearing the terrain's own moss tint, displaced-icosahedron rocks,
a carved pond under a fresnel disc, hashed stars. All of it is modelled
environment and the HUD says so — the category line (environment may be
authored, organisms may not) is the project's one rule crossing the
bridge intact.

The stand is eight distinct seeds now, four species, two life stages,
with a peak Cathedral Fern as hero — its palette drives the sky, fog,
lights, terrain, rocks and water. The conifer is out: its single
dominant vein strand is correct Picea and the reticulate network is the
only channel this engine is visible through, which is the ROADMAP 13
needle verdict carrying over to the export.

Three look bugs, all found by eye against the shot tool's captures:
stars drew as squares (a hash cell at this resolution is ~0.26 deg, so
lighting the whole cell is several pixels — a round falloff inside the
cell fixes it); the pond read as a hole twice, first from a fresnel with
no floor, then from faithfully reflecting a zenith that is near-black in
this world — water at grazing angle mirrors the horizon, and the
horizon here is the fog; and the hero camera stood inside a creeper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4dBsLoHroiPssTXX1L1E6
…ture

A dozen adults placed by hand read as a diorama, and the fix is ecology
rather than art, all three parts of it: NUMBERS (148 plants sown, not
11), AGE STRUCTURE (six juvenile assets — the same species grown fewer
steps, which is honest size variation with nothing scaled), and
CLUSTERED DISPERSAL (each species gets drift centres and members
scatter gaussian around them with collision radii, adults keeping more
ground than juveniles, plus stragglers between drifts so the patches do
not read as islands). Everything is seeded; the field reproduces.

Nothing sows inside a camera any more — a Sun Coral drift landed
exactly on the pond framing and that capture was the inside of a
canopy. Fog comes down 0.055 -> 0.044 so the mid-field reads instead of
dissolving, the grass count goes to 64k over a wider radius with a
brighter colour range (foreground blades read as black spikes), and the
grove camera stands up out of the grass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4dBsLoHroiPssTXX1L1E6
… owns

The aesthetic pass, and the native look finally crossing the bridge: the
engine's renderer draws veins as additive light and grades the frame, so
the flat lit-surface picture was never going to read as canalisation.
Bloom is HAND-ROLLED — threshold, quarter-res separable gaussian,
additive composite with ACES and a vignette — because UnrealBloomPass
renders black in headless GL on every backend (bisected: raw render
fine, plain composer fine, bloom pass 0.0000 under default, angle and
swiftshader alike). Owning sixty lines beats shipping a dependency that
hands some viewers the documented black frame; ?post=none, the bisect
switch that found it, stays in the file.

Two bugs worth their comments: additive emissive lines stack to Inf in
a half-float target, and Inf through the ACES rational is NaN, which
the blur smears into black rectangles — clamp at the door. And three
only tone maps when rendering to SCREEN, so a render-target pipeline
that forgets to grade is silently linear.

The moon is one direction shared by three readers — the sky dome's
disc, the key light, the water's streak — or the picture disagrees with
itself about where its own light comes from. Its low elevation turned
every away-facing blade facet black (watched as speckle across the
drifts), paid for with sky fill rather than by raising the moon.
Ground mist is billboard fog cards; spore motes are additive points the
bloom turns into fireflies; wide now faces the moon and the old
overview stays as a fifth framing, field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4dBsLoHroiPssTXX1L1E6
The page imports src/37_wind.js ITSELF — served raw, since it depends
only on 00_math.js and both are real ES modules — bakes the mode table
once, and injects windGLSL()'s emitted source into every plant and
grass material. One field, three readers: the vertex shaders lean the
canopy, windAt() advects the mist cards and spore motes on the CPU,
and both sum the same numbers the simulation sums. A copy in demo/
would be the two-airs bug that module exists to prevent, reintroduced
by hand; serving the shipped file is the whole point.

The response is approximated and says so: a quasi-static first-mode
lean weighted (y/H)^1.5 off each plant's own exported height, because
the bend solver is a simulation and this page is a scene. The ladder's
slow octaves dominate the loading, so quasi-static is honest for what
carries the motion; what is forgone is ringing. The wind is evaluated
at the VERTEX rather than the plant base — the big octaves are far
larger than a plant so it still moves as one thing, and the small ones
put per-leaf texture on top for free.

Stills sample the ladder at one fixed plant time and reproduce
bit-exact (measured: same t diffs 0.0000; t+1.5s moves 2.5% of pixels
by >12 levels, so the canopy demonstrably sways). __frame(name, tSec)
A/Bs the wind; demo/clip.mjs records the webm, the only artifact that
shows the point of all this.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4dBsLoHroiPssTXX1L1E6
clip.mjs recorded wall-clock, and under software GL that is a slideshow
by construction — the renderer manages about a frame a second and the
recorder faithfully writes down that nothing happened in between. The
motion was never wrong; the clock was the machine's.

demo/film.mjs owns the clock instead: hold the RAF loop, drive the
scene through window.__stepMs with fabricated timestamps exactly one
frame apart, screenshot each frame, and hand the sequence to a
vendored static ffmpeg (curl, like the three.js vendor). True-rate
motion at a solid 24fps regardless of how slow the renderer is — the
same reason blender_clip.mjs renders one frame per Blender process
instead of screen-recording a viewport. The stepper reuses the live
drift path verbatim, so the film and the browser show one motion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4dBsLoHroiPssTXX1L1E6
The performance question answered the only honest way available: nothing
rendered in this container can quote an fps (software GL runs this scene
at ~1 fps by construction, and the repo's docs already say headless
frame rates are not worth reading), so the demo now MEASURES ITSELF
wherever it actually runs — an always-on meter showing fps, frame time,
draw calls and triangles. Which surfaced an instrumentation bug worth
its comment: renderer.info resets per render() call, so after the bloom
passes it holds the composite quad — 1 call, 1 triangle — and a perf
probe faithfully reported that as the whole scene. The scene pass's
numbers are snapshotted before the composer touches them.

What the scene actually is, structurally, is already release-shaped:
static geometry (growth happened offline — no simulation in the page),
wind in the vertex shader, one instanced draw call for 64k grass
blades, quarter-res bloom. The knobs for weaker machines are URL
parameters: ?dpr= ?grass= ?plants= ?post=none.

All URLs go relative so the site serves from a subpath, and
.github/workflows/demo-pages.yml grows the stand (cached on
manifest+source hash), assembles a static site, and deploys to GitHub
Pages on manual trigger — the demo runs on each visitor's own GPU,
which is the only place its performance means anything. README carries
the honest weight problem: ~50 MB of GLBs, and the three real fixes
(strand-order chaining, meshopt, lamina weld), none of which is a hack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4dBsLoHroiPssTXX1L1E6
…flown

?cam=flight (and the film tool's default) replaces the orbit with a
Catmull-Rom spline threaded through the SOWN field — waypoints found off
the actual claimed plant positions so the path adapts to what grew: in
low over the far drifts, shoulder-height past two adults, half a loop
around the hero, out through the coral drift, a skim across the pond,
then up and out. Terrain clearance, a soft push off any stem the spline
strays into, a two-frequency flutter bob in centimetres, and a bank into
the turns capped gentle — a camera that moves like an insect and cuts
like one are different things, and only the first is wanted.

Two things the 1fps scout pass caught before the real render paid for
them: the exit flies AWAY from the moon's azimuth, so the old
look-ahead ended the film on featureless sky — the gaze now swings back
over the shoulder during the pond skim and the film closes on the
moonlit field; and the fps meter was baked into every frame — the film
tool hides it now (#fps), a meter in a film frame being a boom mic in
shot. Scout at one frame a second before filming at twenty-four; the
scout is 4% of the cost and caught 100% of the mistakes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4dBsLoHroiPssTXX1L1E6
The 480-frame flight lost its browser to the OOM killer at frame 465,
with every rendered frame safe on disk. RESUME=1 keeps them, replays
the drifters' INTEGRATED state cheaply — the mist and motes accumulate
position, unlike the camera and the sway, which are pure functions of
time — via a __skipDraw flag that steps the world without rendering it,
then renders only the remainder. 465 frames of a 16-minute render cost
45 seconds to finish instead of 16 minutes to redo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4dBsLoHroiPssTXX1L1E6
A viewer said the middle of the film 'starts flipping around and gets
jerky', and the jerk probe (demo/jerk.mjs, kept) turned the complaint
into a number: 135 DEGREES in one frame at 13.4s, mean 2.35, p95 8.28.
Three stacked causes, one of them structural: the per-frame plant
push-off was a force that kicks in and out (jerk by construction), the
hero loop was tight enough to whip the look-ahead, and — the flip — the
avoidance bake left a cusp near the pond where the runtime lookAt's
ahead-point crossed BEHIND the camera.

The fix is to stop computing orientation at runtime at all. The path
bakes avoidance into the curve samples and smooths them; the GAZE bakes
too — per-sample forward directions from a wide displacement window,
the farewell-to-the-moon blended in at bake time — and then a hard cap
of 1 degree per sample is enforced over the sequence, so a whip is
impossible by construction rather than unlikely by tuning. Runtime is
interpolation of two arrays, a flutter bob that translates camera and
look target together (and so cannot turn the view), and a followed
bank. Re-probed: max 1.13 deg/frame, mean 0.54, p95 1.08 — bounded by
the cap, as designed. The hero loop is also wider.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4dBsLoHroiPssTXX1L1E6
…ying

The flight is a behaviour model now, not a tour. A forager works
stations: dart toward a flower in a fast straight line FACING where she
is going — which is what kills the lateral slide a viewer called
sporadic — brake into a hover, two seconds with attention ON the
flower, then leave for the next. The gaze is the goal: in transit it
holds the next station, on station it holds the plant, and the whole
orientation (not a forward-vector proxy — the probe caught gimbal
amplification exceeding the vector cap by half again on the pitch-down
to the water) follows under one Quaternion.rotateTowards rate cap.
Darts ease zero-to-zero so every join is smooth by construction. The
route is one progressive sweep, entry to exit, because the first
itinerary zig-zagged and demanded 180-degree head-turns no deliberate
turn rate can deliver. The pre-turn is real bee behaviour: she commits
her heading to the next flower before she leaves this one.

Stations aim at what a bee can see: each sown plant records its
asset's grown height (a parasol is 0.78 m and the bee was hovering
above the canopy staring over it at empty field) and its CANOPY
CENTROID after yaw (these plants arch, so the visual mass sits off the
root and lands somewhere different per clone — one coral framed
perfectly while its twin missed until this).

And the instrument that cost three rounds of phantom fixes: the 1fps
scout stepped the dynamics once per frame against a dt clamp, so its
gaze follower ran at a tenth of the film's rate — the scout showed
plants at frame edges while the 24fps probe read 0.0 gaze error,
because they were different dynamical systems. film.mjs now sub-steps
every output frame at 24Hz whatever the capture rate: a scout is the
film sampled less often, never a different simulation. The gaze-error
and where-am-I debug exports stay in main.js; they found the height
and centroid bugs and will find the next one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4dBsLoHroiPssTXX1L1E6
…udFront

The repo carries no record of the earlier AWS deployment, so this encodes
the standard pattern where the next person can find it: the same cached
asset grow and site assembly as demo-pages.yml, then OIDC into a deploy
role (no long-lived keys), a two-pass s3 sync — immutable assets a day of
cache, entry points five minutes so a deploy never serves new code against
old assets — and a CloudFront invalidation only if a distribution is
wired. Inert until DEMO_BUCKET and the role ARN are set; it refuses to
run unwired rather than half-deploying.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4dBsLoHroiPssTXX1L1E6
…ramework

The composite is assembled from named slots (uv, light, grade, final)
plus a replaceable base sampler, pre-passes chain between scene and
bloom in priority order, and scene-side installs get refs to the sky,
pond, grass, terrain and the moon key. Each effect is a served module
in demo/fx/ — an absent file skips silently, so the pipeline is
identical with zero, some or all present, and effects can be built in
parallel without nine hands in one shader. ?fx=none|csv selects.
rtScene carries a depth texture now (DoF and motion blur read it), and
the flight exposes focusDist — the distance to whatever the bee is
attending to, which is what the camera will focus on.

Verified: with zero modules present, all five framings capture with
means identical to pre-refactor to four decimals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4dBsLoHroiPssTXX1L1E6
shadows, water and dof, committed mid-flight because delivery is rolling
— three more modules (mblur, rays, grade) are still being written and
none of the six have been integration-tested together yet. The
verification-and-integration commit follows; treat this as a snapshot,
not a landing. The agents' .verify_* scratch scripts stay untracked;
they are deleted when each agent finishes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4dBsLoHroiPssTXX1L1E6
All six modules from the parallel build are in — attention-driven depth
of field, camera motion blur, volumetric moonlight with one shooting
star timed into the film's farewell, soft moon shadows off a
derived-not-dialled shadow camera, wind-driven water with the streak
broken into glints, and the grade: CA, anamorphic streak, split-tone,
grain, and the dither that kills the dark-gradient banding.

One integration bug, and it was the framework's, not any module's: a
framing change is a CUT, and stateful effects smeared across it — the
first integrated capture drenched every still in motion blur because
prevViewProjection still held the live orbit's camera, and DoF focused
on the orbit's subject. __frame now sets the framing's own focus
distance and draws twice: the first settles every pass's state at the
new camera, the second is the still, ds = 0, so motion blur is the
identity and the grain is frozen. The module's own contract ('two draws
at one time are identical') was honest; the tool only drew once.

Jerk probe unchanged. All five framings gate green with the full stack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4dBsLoHroiPssTXX1L1E6
…tself

README gets the demo section with a committed preview — tweet traffic
lands on the front door, and the front door now shows the field. The
Pages workflow gains configure-pages with enablement:true, so the first
run creates the site itself and the one manual settings step is gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4dBsLoHroiPssTXX1L1E6
@aj-dev-smith
aj-dev-smith merged commit 518c984 into main Aug 15, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants