Repository navigation
A three.js bridge: GLB exporter, a wild-field demo, and the air crossing with it - #45
Merged
Merged
Conversation
… keep Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A4dBsLoHroiPssTXX1L1E6
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
The simulation goes out to the rest of the web: a GLB converter downstream of the Blender bridge's
.json+.binpair, a mutation-tested validator, a render proof in stock three.js, anddemo/— 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 failurestools/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 MBdemo/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-limitedFindings a reviewer should know, all documented in-repo:
UnrealBloomPassrenders 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 because50_geom.jsemits 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
src/, not incanalisation.html— nosrc/changes at all;canalisation.htmluntouched, so no rebuild was needed (CI's staleness gate confirms)node build.jsand committed the regeneratedcanalisation.html— n/a, artifact unchanged; build verified to parsenode test/smoke.mjspasses🤖 Generated with Claude Code
https://claude.ai/code/session_01A4dBsLoHroiPssTXX1L1E6
Generated by Claude Code