Skip to content

A bridge to a path tracer, and the first film - #40

Merged
aj-dev-smith merged 18 commits into
mainfrom
worktree-blender-bridge
Aug 5, 2026
Merged

A bridge to a path tracer, and the first film#40
aj-dev-smith merged 18 commits into
mainfrom
worktree-blender-bridge

Conversation

@aj-dev-smith

Copy link
Copy Markdown
Owner

A bridge to a path tracer, and the first film

src/ is untouched — git diff main..HEAD -- src/ canalisation.html is empty.
This adds tools/ and two lines to .gitignore, nothing else. Smoke's 73
checks, views.mjs (named species, 23 checks) and views.mjs (garden of
eight) all pass.

What it is

The simulation, out of the browser and into Cycles, so a specimen can be
photographed with a physical camera instead of screenshotted.

It adds no geometry code, and that is the whole design. It grows a specimen
with the shipped makeSpecimen, draws it with the shipped drawSpecimen, and
the only thing it supplies is a Buffers that keeps a second copy of what
passed through. Same argument that keeps test/views.mjs driving the real
drawSpecimen through a stand-in App: a bridge that re-implemented the draw
would drift from the renderer inside a week.

One thing is deliberately thrown away and it is the interesting one. A vein is
drawn in the browser as a camera-facing ribbon, because that is how you get
a hairline to read as a thickness in a rasteriser. Exported as triangles that is
a billboard baked at one camera position, and the first orbit of a turntable
would show every vein in the plant turn edge-on and vanish. So ribbon() here
records (a, b, w0, w1) and drops side; Blender gets a curve with a radius
per point
, which Cycles renders as a real tube from any angle. The
view-dependence was never in the vein, it was in the rasteriser, so this is a
lossless swap and not an approximation. tools/blender_shot.py --turntable is
the check on that claim.

The deliverables

  • shots/EMBER_STAND_4k.png — 3200x4000, 512 spp, a hero and a stand.
  • shots/EMBER_LIFE_24fps.mp420.2 s, 1080x1350, 485 frames. An Ember
    Creeper from seed to peak.

Both gitignored; they are outputs, not source.

The findings, in the order they cost time

The background is a light, and that is why every stem was black. The two
complaints — "dark stem" and "black background" — were one bug. In a path tracer
the background is the light, and this rig's environment was contributing
0.001. The shipped shader carries amb = mix(ambBot, ambTop, N.y*0.5+0.5), an
ambient term by normal, which is why nothing in the browser is ever unlit.
_sky() reproduces that split physically with Is Camera Ray: camera rays get
the BG_FS backdrop, everything else gets the MESH_FS ambient, both from
colours already in the export header.

The trap under it: those are two gradients of two different things and I
collapsed them into one.
BG_FS gradients on screen height; MESH_FS on the
surface normal. A vertical stem has horizontal normals, so through a
photographic horizon at -0.30 it sampled t=0.25 — near ambBot — where the
browser gives it the exact midpoint. sky at 1, 4, 6 and 14 all produced
identical black-stemmed pictures; the diagnosis came from rendering sky=40,
which lit them and proved the link was live and the mapping wrong.

⚠ The width floor is a PIXEL floor, so a preview is a different plant.
Measured on one stand: 6.73 mm at 720x900 against 1.51 mm at 3200x4000, with
100% of strand ends at the floor in both. Every look decision in a ladder of
previews was taken on veins 4.5x fatter than the deliverable, and the first 4K
came back papery with the tracery faint — which reads as a sampling or a
material problem and is neither. px_ref scales the floor to a reference canvas.
The bracket is why a preview could not have settled it: at 4K, 1.51 mm is papery
and 6.05 mm merges the marginal veins into a crust along every leaf edge,
but at 720x900 the 6 mm version looked right because a whole leaf is 90 pixels
there and the sampler was doing the blending the veins are supposed to do.
px_ref=2500 is set by eye and is the only number in the rig that could not
have been computed — same category as the wind's uRef.

A life is twenty seconds, so there was never a timelapse to make. The solver
runs at 125 steps/s (WORLD.ptPerSec, matched exactly by App.step's
accumulator) and peak is step 2527 — 20.2 s of real time for an entire life.
The plan was "a growth timelapse OR a wind clip"; both halves were wrong. At 24
fps, stride 5.21, the growth and the wind are the same footage at natural speed.

It matters beyond naming: a timelapse has to pick a stride, and a stride is a
sampling rate. The stem's bending mode is 0.56-0.64 Hz and the fastest shipped
gust is 1.78 Hz, so anything under ~3.6 Hz aliases the wind into a judder that
reads as a bug in the solver.

The rig is composed once, from the clip and not from a frame — and the garden
fix was this fix.
film() places every lamp at a multiple of r and powers it
at r*r, where r is the subject's extent. That was fixed earlier on this
branch so a garden's clearing could not light the hero; a growing plant walks
into the identical trap from the other side. Rebuild per frame and a seedling
gets a seedling's key, so the exposure crawls and all four lamps slide outward
for twenty seconds. span/pivot are computed once off the union bbox over all
485 frames.

Verified rather than assumed: luminance drifts smoothly 38.6 -> 42.1 (the plant
filling more frame) and the second difference, where frame-independent noise
lands, has p90 0.037 and max 0.062 — 3.7% and 6% of one 8-bit level. No
flicker. The width floor comes out temporally stable for free at 2.43 mm on every
frame, the same physical width as the 4K still.

A static set is invisible as static, and here is the arithmetic. Per-frame
sway (5.2 mm median at the busiest station) against the f/2.8 defocus circle, in
pixels of a 1200-wide frame:

distance sway defocus
9 m (hero) 1.93 px/frame 0.0 px
15 m 1.16 px/frame 5.4 px
25 m 0.69 px/frame 8.6 px
40 m 0.43 px/frame 10.4 px

At 15 m a background plant's whole travel is a fifth of its own blur circle, so a
set exported once costs 128 MB instead of 62 GB. And a defocused set does not
need solver-resolution lamina: MESH=auto is 96k triangles against 1.35M,
visually identical, and takes the import from 10.2 s to 1.1 s a frame.

An arc, not a ring. plantGarden spreads a stand evenly around the hero,
which is right for a scene you can orbit and wrong for a photograph: a hero
framed at 3.2 m through a 100 mm lens sees an ~11 degree cone, so a full ring
puts one plant in ten anywhere near it. ⚠ This is the first piece of staging in
the bridge and it is flagged as such in the source.
Where a plant stands was
never a simulation result — plantGarden picks it with a PRNG too — and the arc
moves where the camera is pointed, not what grows. Worth watching that line.

Bugs found here that were not visible on screen

  • Cycles was rendering the veins as the billboards the exporter deleted
    (cycles_curves.shape = "THICK").
  • The first conifer export shipped 80% shortBuffers is sized for a frame
    budget and drops geometry; a file is the wrong place for that constraint. The
    emitters grow now, and a saturated buffer exits non-zero.
  • Blender's AUTO sensor fit flipped the framing on the one portrait shot.
  • The palette header wrote a bg key that does not exist (a species carries
    bgTop/bgBot/bgGlow), so JSON.stringify dropped it and every consumer
    fell back to its own default — an Ember Creeper in a blue-grey room. An
    undefined that survives serialisation as an absent key is the quiet version
    of this: pal.get('bg', default) cannot tell "no such colour" from "nobody
    asked".
  • The clip driver rendered all 485 frames and reported success without
    checking.
    live.delete(p) was a second close handler registered after the
    completion test, and node runs handlers in registration order. I first wrote
    this up as a hang; it is not — node drains its event loop and exits status 0
    with no summary
    , which is worse, because done() is the only thing that
    calls process.exit(fail ? 1 : 0). A run with failed frames would also have
    exited 0. Found by running the real 485-frame job, not by re-reading the code.

What this does NOT solve, stated plainly

  • No atmospheric perspective. Distant plants are exactly as bright as near
    ones. A world volume makes the background blacker, not foggier —
    volume_bounces = 4 truncates multiple scattering, so at infinite optical
    depth most paths die before reaching the sky. Needs a finite volume; unbuilt.
  • The plants float. Every ground variant read as a studio cyc wall, including
    near-black albedo at roughness 0.92. ground ships off for the hero.
  • 100% of strand ends sit at the width floor at whole-plant framing, at every
    resolution tried. The floor is not lifting a thin tail — it is setting every
    vein's width, so the hierarchy canalisation grew is flattened. These frames
    show the network's topology, not its traffic.
  • Veins are one-sided (real tubes on one lamina face). A still hides it; any
    future orbit or close-up will not.
  • The flicker metric is a global mean, so it would average away a localised
    boil. That one still wants an eye.

Not taken, and written down instead

dead() lands at step 8908 — the full life is 71.3 s and 71% of it is the leaf
drop.
A seed-to-death film is 1710 frames, 3.5x the render, spending 51 of its
71 seconds dying. That is a composition call, so it is recorded in
tools/README.md rather than made. ⚠ Note that compressing it by raising the
stride is the one edit the aliasing measurement above forbids.

🤖 Generated with Claude Code

aj-dev-smith and others added 18 commits August 3, 2026 18:10
…de it

Grow a specimen in Node, hand its geometry to Blender, let Cycles light it.
`tools/blender_export.mjs` writes a header + float32 payload; `tools/blender_import.py`
rebuilds it as a mesh, a Curves and a PointCloud and frames it.

THE POINT IS THAT IT ADDS NO GEOMETRY CODE. It grows with the shipped
`makeSpecimen`, draws with the shipped `drawSpecimen`, and supplies only a
`Buffers` subclass that keeps a second copy of what passes through it. Same
argument as `test/views.mjs` driving the real draw through a stand-in App —
a bridge that re-implemented the draw would drift within a week. The stand-in is
copied from that file deliberately, so a new read off `this` breaks both loudly.

Four differences from the browser, all deliberate:

- VEINS CROSS AS CURVES. A vein on screen is a camera-facing ribbon, i.e. a
  billboard baked at one eye position; as triangles, every vein would turn
  edge-on and vanish on the first frame of a turntable. `ribbon()` records the
  segment and its two widths and drops `side`; Cycles renders a strand with a
  radius. The view-dependence was never in the vein, it was in the rasteriser.
- THE VEIN LOD IS OFF AND `MINW` IS NOT A PIXEL. ~90% of a hero's veins are
  sub-pixel in the browser and drawn at one uniform width. Here the hierarchy
  canalisation grew is legible as thickness for the first time.
- `MESH=full`, THE DEFAULT, IS THE REAL PRIZE. `bladeMesh` is clamped by the
  leaf's own lattice, so asking for more is not invented detail — it is not
  discarding solved tissue. Cathedral Fern seed 21: 20,154 tri at the browser's
  LOD, 274,568 at the solver's.
- TWO-SIDED LEAVES ARE `60_render.js:99`, NOT A STYLE CHOICE. Without the flip
  node every blade turned away from the key rendered solid black — found by
  looking, and it read as a material bug rather than as a missing line from the
  shader this reproduces.

`STAGE=peak` stops at the last step before senescence — the fullest the plant
ever is, which has no name in `Plant.stage()`. It costs a second growth because a
plant cannot be rewound, and the two passes assert against each other rather than
trusting determinism.

The ruler is real: `unitM` was fixed by the wind and the falling blade, so a
Cathedral Fern imports at 2.36 m and 85mm f/2.8 is the depth of field a 2.4 m
plant would have. None of the real-time grade crosses; a path tracer does its own.

`src/` is untouched. `node test/smoke.mjs` 73/73.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
`cycles_curves.shape` shipped at RIBBONS, which draws every strand as a flat
camera-facing sheet — precisely the artefact the curve path exists to remove.
The export was correct, the import was correct, and the setting quietly put the
billboard back one layer further down. THICK now.

Found by looking. Three renders passed through it before a `cells` view drew a
stem as a flat band; on `natural` the two are indistinguishable, because a
vein is a fraction of a millimetre across and a plane and a tube of that
diameter are the same handful of pixels. Nothing numeric here could have caught
it — the geometry, the radii and the counts are identical either way.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…rve claim

The one substantive claim this bridge makes about geometry is that a vein stops
being a camera-facing ribbon and becomes a tube. A still cannot tell those apart
— the RIBBONS bug in the previous commit survived three of them. An orbit can:
a billboard plant thins and flickers as the camera comes round.

The lights deliberately do not orbit with the camera. A rig that follows the
lens gives every frame identical shading, which hides exactly the wrongness this
exists to show.

`setup_scene` now stashes pivot/dist/elev on the camera so the orbit reuses its
framing instead of re-deriving it and drifting from the still it is checking.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
… said so

`Buffers` is sized for a clearing at 60 fps and drops geometry when it fills.
That is right for a frame and wrong for a file: an Ashfall Spire at the solver's
own lamina resolution wants ~1.44 M triangles against a 279,620-triangle buffer,
so the export went out with four fifths of the tree missing.

Nothing about that picture would have announced itself — a truncated plant is
merely a sparser plant. It was caught by `saturated()`, which exists because
silent dropping had already cost two sessions, and which this file was already
printing and then carrying on past.

Two changes. The capture buffers GROW (double on demand, overriding the three
emitters) so "big enough" is not a number anyone has to be right about. And
saturation is now FATAL for an export rather than a warning — a truncated file
is worse than no file, because it looks like a plant.

Conifer, seed 3, STAGE=peak: 3,001 organs, 223,922 vein and needle segments.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
`60_render.js` draws the line and point passes with blendFuncSeparate(SRC_ALPHA,
ONE) and depthMask(false), and the whole fragment shader is `vec3 c = vC * vE;`.
No lighting term. No occlusion. A vein is emitted light with a colour and a
strength, and the import was giving it a diffuse response it does not have and a
depth it does not write.

The tell was the `flux` view. Its ghost stem is weighted 0.14 exactly so the
tissue reads through it; as a lit Principled tube it came out an opaque white
pillar down the middle of the plant. A weight is a brightness, and a brightness
only reads as transparency if the thing is additive.

Add Shader(Emission, Transparent BSDF) is the exact analogue — the transparent
branch passes the ray through unchanged, the emission adds on top — plus
visible_shadow off, because light does not cast one. `vein_emis_mul` drops 7.0 ->
1.0, which is now a faithful default rather than a number picked to look right:
1.0 IS `vC * vE`.

`solid_veins=True` restores the lit surface, for using an export as a model
rather than as a picture of the piece.

Two things this also fixed that were being blamed on exposure: the lamina no
longer washes to white, because half of what was on it was vein surfaces lit as
well as emitting, and the palette's colour comes back.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
`setup_scene`'s distance arithmetic assumes the sensor maps to the horizontal
axis. On AUTO, Blender maps it to whichever image axis is LONGER — so at
1200x1800 the field of view silently became vertical, the camera stopped short,
and the conifer lost its bottom metre.

That failure looks like the plant being too big rather than like a camera bug,
which is exactly the trap `tools/tree_shot.mjs` exists to avoid: a 46-unit spire
in the wrong frame makes "looks lost" and "is too sparse" the same picture. Pin
it HORIZONTAL and the arithmetic is what it always claimed to be. `margin` is a
parameter now rather than a literal 1.25.

Ashfall Spire seed 3 at STAGE=peak: 240 axes, 3,001 organs, 1,434,382 triangles
(the 1.44 M the growable buffers predicted), 223,922 strands, 2.73 m, 174 MB.
Import 4.3 s, render 20 s at 64 samples.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…y render it

`tools/blender_hero.py` is art direction, deliberately separate from
`blender_import.py` so a look-dev choice can never be mistaken for a fidelity
one. `tools/blender_shot.py` is a headless entry point.

THE IDEA: the veins already ARE emission — `vec3 c = vC * vE` — and `makeSpecimen`
pulls the lamina DOWN by `laminaMul` so they win. A rasteriser can only add that
light to the pixels a vein covers. A path tracer lets it leave the plant: light
the tissue in front of it, scatter in the air, fall on what is near. So the rig
is a dark room with a plant that glows, plus the minimum to read a silhouette.

Six things that were wrong, five of them found by looking:

- THE COMPOSITOR'S GROUP INPUT IS NOT THE RENDER. In 5.x a scene compositor is a
  node group, so the obvious reading is that its Image input is the frame. It is
  not; you need a Render Layers node INSIDE the group. Measured on a 200px
  sphere: no compositor and an RLayers graph write byte-identical 31,426-byte
  files, the passthrough writes 9,354 bytes of blank. It costs no render time,
  which is the trap — wall clock fell 4.6s -> 0.2s and read like the render being
  skipped rather than the picture being discarded at the last step.
- `ShaderNodeMix` HAS FOUR INPUTS NAMED "A". They differ only by identifier
  (`A_Float`, `A_Color`, ...) and `inputs["A"]` silently returns the float. A
  colour graph written by name links, renders and is wrong.
- THE LIGHT RIG WAS IN WORLD SPACE WHILE THE CAMERA ORBITED. At azimuth 285 the
  "rim" had swung to the front and become a second key, so every lighting
  judgement made at one angle was void at the next. Camera-relative now. Four
  stills at four azimuths did not show this; each looked like a plausible design.
- BACKLIGHT HAS TO LEAD, and it cannot be in the volume. A lamina is translucent
  — front-lit these are red flakes, back-lit the light comes through the tissue
  and the vein network shows inside it. But a backlight strong enough to do that
  also fills the volume and turned the frame into a milky backdrop. Practicals
  are switched out of volume scatter, so they shape tissue and the only thing in
  the air is light the plant emitted. Which is what the file claimed at the top,
  before it was true.
- A BOUNDING BOX IS THE WRONG THING TO FRAME. A creeper's extremes are three
  outlying leaves, so the box centre lands beside the plant. Percentiles of the
  drawn points, 2% trimmed.
- AND THE EXPORTER WAS WRITING `bg: undefined`. There is no `bg` key — a palette
  carries `bgTop`/`bgBot` — so JSON dropped it and every consumer fell back to
  its own default. An Ember Creeper, whose background is 0.030/0.010/0.010 of
  warm near-black, had been rendering in a blue-grey room the whole time. The
  world runs the real gradient now, and the full palette is exported.

Also: rendering from an interactive Blender over MCP stopped working mid-session
— `{'FINISHED'}` in 0.07s with an empty result, in a NEW scene with one sphere.
Nothing recovered it from a script. `blender_shot.py` runs a fresh process per
render, which cannot reach that state, and does not block the app you are
looking at the scene in.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
AJ's verdict, unsoftened: considerably worse than the custom WebGL renderer.
Fourth time the eye has decided something here. Three measurable causes, none of
them lighting taste:

- THE VEIN WIDTH FLOOR IS NOT AN ARTEFACT. `50_geom.js:591` draws every vein at
  `max(wFloor, ...)` where wFloor is MINW, ~0.004 world units. The exporter sets
  MINW = 1e-4 on the argument that a pixel floor is meaningless to a path tracer.
  Correct about physics, wrong about the picture: on Cathedral Fern seed 21 the
  MEDIAN vein radius is 0.004 — exactly the floor — and the thinnest are 12x
  below it. The browser lifts half the network to a common width; the export
  draws it at its true, invisible size. Hence flat red discs where there should
  be a reticulate network.

  This is the ROADMAP 13 needle lesson restated and I walked into it. There, a
  botanically correct needle was rejected because the reticulate network is the
  only channel through which this engine is visible. Physically correct
  sub-pixel veins are the same mistake in a different hat.

- THE LAMINA SHADER IS A DESIGNED THREE-TERM LOOK whose parameters are species
  palette entries — `key`, `keyCol`, `ambTop`, `ambBot` — plus a `* 3.0` on
  emission. The hero rig invents its own lighting and uses none of them.

- THE GRADE IS NOT A GARNISH: bloom 0.38 at threshold 1.15, exposure, vignette,
  grain, dof, per-species fog. Skipping it is defensible only if the network is
  there for a physical glare to catch, and it is not.

Route out: a WFLOOR knob (one line, probably most of the gap), then a
`--look shipped` mode that reproduces the browser in Cycles, and only then art
direction on top. Match the reference before improving on it.

The current `hero()` defaults are NOT tuned — they were reached by looking at
renders missing most of their vein network.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
AJ's verdict on the path-traced hero was "considerably worse than the
custom webgl renderer". The diagnosis written up last session was right
about the category and incomplete about the specifics, and the thing that
completed it was capturing the SAME specimen out of the browser
(`views_shot.mjs`) and looking at the two side by side — which should have
been the first move, not the fifth.

`tools/blender_look.py` is `60_render.js` rebuilt in Cycles: MESH_FS as an
unlit emission graph, LINE_FS as Add(Emission, Transparent), BG_FS as a
composited background, COMP_FS as the ACES fit, the real
`1 - vig*dot(d,d)*1.6` vignette and `hash(uv*vec2(1024,768))` grain.
`--look shipped` on `blender_shot.py`. Three corrections it forced:

- MINW's module default (0.004) is NOT the floor the piece runs at.
  `70_app.js` passes `cam.dist * px * 1.5` — one and a half PIXELS of
  half-width, ~19x larger at hero framing. That reconciles "ninety percent
  of veins are already sub-pixel" with "the floor is what makes them
  visible": both are true, at different stages. A pixel floor cannot be
  baked into a view-independent export, so it is applied after framing.
- `bgGlow` was missing entirely and is most of the frame: BG_FS adds a
  screen-space halo behind the specimen. A world shader cannot say that,
  so the plant renders on alpha and the background composites under it.
- The compositor has `ImageCoordinates`, so the grade is the real
  expressions rather than approximations.

`blender_export.mjs` now reads MINW out of `src/50_geom.js` rather than
keeping a copy (the `test/stem.mjs` hardcoded-wind-speed lesson), takes
`WFLOOR=` to sweep it, and prints the vein width distribution — the thing
nobody was looking at for a day.

Three silent traps documented in tools/README.md: ImageCoordinates needs
its Image input linked, AlphaOver's sockets are (Background, Foreground,
Factor) in 5.x, and the view transform must be Standard or the ACES fit
is applied twice.

No lights and no BSDF, so nothing bounces: 1600x2000 in ~7s against ~9min
for the hero rig. src/ untouched; smoke 73/73.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
like taste

`tools/blender_film.py` and `--look film`. Fourth layer of the stack —
fidelity (import), reference (look), taste (hero), production (this).

WHAT IT ADDS, and every item is either a physical constant already in the
codebase or a field the plant's own chemistry canalised:

- Lamina thickness is `FALL_DEFAULTS.thickM` = 0.4 mm, which has been in
  `39_fall.js` for months because the falling-blade model needed it. A
  browser blade is a zero-thickness sheet; giving it its measured
  thickness is removing an approximation, not adding a shape. It is also
  what produces the bright rim on a backlit leaf.
- Areole doming is the vein distance field, rebuilt with a Geometry
  Proximity node against the canalised vein curves. `nearVein()` already
  computes exactly this and `50_geom.js` already uses it for `veinTint`
  and for the order a dying blade drains in — a computed channel the
  renderer has never drawn (ROADMAP 0z's argument).
- Real subsurface transport through that sheet, roughness driven by the
  same field, a cuticle, and the vasculature's emission kept intact.
- A room: backlight-led camera-relative rig, world-volume air, a ground,
  physical DOF, AgX, and the `bgGlow` halo carried over from the browser.

FOUR BUGS, ALL OF WHICH PRESENTED AS LOOK PROBLEMS:

1. THE IMPORT IS AN UNWELDED TRIANGLE SOUP (one vertex per corner). It is
   invisible while the mesh is only shaded, and it destroys anything that
   treats it as a solid: Solidify extruded every triangle into its own
   closed slab, so a leaf rendered as ~2,350 tiles and read as fish
   scales. It cost four renders because the artefact looked exactly like
   a material or displacement problem — vein widths 20x, subsurface 4x,
   coat roughness, doming to zero, four byte-similar images. FOUR LARGE
   INDEPENDENT CHANGES THAT ALL DO NOTHING IS NOT A LOOK PROBLEM.
2. Proximity in POINTS mode against 2-point strands measures distance to
   isolated dots, not to veins. Curve To Mesh + EDGES is the real field.
3. The haze was a box, and a box has EDGES — a hard dark band ruled across
   the frame, which is its top face seen from inside. World volume now.
4. The ground blew out white at albedo 0.005, which diffuse cannot do at
   any light level. It is grazing SPECULAR off a low backlight, where
   Fresnel goes to 1 regardless of how dark the surface is. A dark floor
   is not a dim floor.

Also: veins are drawn on ONE face (a screen-space construct in the
browser), so a leaf turned away is a smooth sheet. That is a modelling
fact, not a shading bug, and it is documented rather than papered over.

src/ untouched; smoke 73/73.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
AJ: "the dark stem on a black background makes everything look, well,
kind of bad." Two complaints in one sentence, and they turned out to be
one bug.

In the browser the background is a BACKDROP. In a path tracer it is a
LIGHT. `blender_film.py` shipped with `ambient=0.012` on a world colour
of bgTop*3 (~0.09 for an Ember Creeper), so the environment contributed
about 0.001 — there was no sky. Every photon came from four lamps, three
of them behind the subject, and a stem whose albedo is [0.16,0.06,0.05]
with nothing on its camera side renders exactly what it should. Against
`--look shipped` the same stem is a lit, rounded, warm tube, because
`MESH_FS` carries `amb = mix(ambBot, ambTop, N.y*0.5+0.5)` — an ambient
term by normal that guarantees nothing in the browser is ever unlit.

`_sky()` keeps the shipped shader's split exactly, using `Is Camera Ray`:
camera rays get the BG_FS backdrop, every other ray gets the MESH_FS
ambient. Both in the palette's own numbers, both already in the export
header. It is not an invention — it is a term the engine already has.

THREE THINGS THE LADDER CAUGHT THAT REASONING HAD NOT:

 - TWO GRADIENTS, NOT ONE. BG_FS gradients on SCREEN HEIGHT; MESH_FS on
   the SURFACE NORMAL. Collapsed onto one mapping with a photographic
   horizon at -0.30, a vertical stem — whose normals are HORIZONTAL —
   sampled t=0.25, near ambBot, when the browser gives it the exact
   midpoint. Four values of `sky` produced one picture. Raising it to an
   absurd 40 is what proved the link live and the mapping wrong: when a
   knob does nothing, stop tuning it and go find what you are not moving.
   That is the fish-scale lesson, second time in this file.
 - AN UNBOUNDED VOLUME CANNOT BE ATMOSPHERE. Optical depth to the
   background is density*distance and a world volume has no far wall, so
   the sky is ALWAYS fully scattered. Two ladders came back flat tan
   walls; density changes how fast it saturates, never whether. `haze`
   ships at 0 with the arithmetic written down. `haze_lit` was mine and
   was worse — a 434 W backlight made visible to the volume.
 - `r` HAS TO FOLLOW THE SUBJECT, NOT THE SET. Every lamp is placed at a
   multiple of `r` and powered at r*r. Left at the whole scene's extent
   it becomes the size of the CLEARING the moment a garden exists.

And the answer to the black background is MORE PLANTS. `GARDEN=` grows a
stand into one export — `plantGarden`'s jittered ring reimplemented, not
called, because that function pays its head start off in frame-budget
slices, which is right for a tab and meaningless for a file. The ring is
what matters and it is copied honestly. Not an imported HDRI and not a
scanned rock: the palette IS this piece's look, and set dressing nobody
grew would be the first thing here the simulation did not make.

⚠ GARDEN_RADIUS IS IN WORLD UNITS AND unitM IS 0.0625. Radius 13 is a
0.8 m clearing holding six 2.9 m plants, which renders as one clump.

Co-Authored-By: Claude Opus 5 <[email protected]>
`plantGarden` spreads specimens evenly around the hero, which is right
for a scene you can orbit and wrong for a photograph. A hero framed at
3.2 m through a 100 mm lens sees a cone about 11 degrees wide, so a full
ring puts roughly one plant in ten near it — the first deep stand
rendered as a specimen alone on an empty field, and the plants were not
missing, they were off the sides.

GARDEN_AZ / GARDEN_SPREAD / GARDEN_NEAR / GARDEN_FAR. For a render at
`azimuth` A the camera sits at GL angle -(A-90), so behind the subject is
270-A; at the shipped A=105 that is 165.

⚠ THIS IS STAGING AND IT IS THE FIRST THING IN THE BRIDGE THAT IS. Where
a plant stands was never a simulation result — `plantGarden` picks it
with a PRNG too — and the arc moves where the CAMERA is pointed, not what
grows. Nothing about any plant is chosen by it. Keep it that way.

Also here: `r` follows the subject when `span`/`pivot` are given, so the
lamps stop being sized by the clearing.

TWO NEGATIVE RESULTS, both of which cost renders and neither of which was
predictable from the shader:

 - THE GROUND IS A STUDIO SWEEP AND NOT AN ENVIRONMENT. A flat plane lit
   by lamps at the subject's scale gives a pool of light with a hard
   horizon above it — a cyc wall, in every variant tried including a
   near-black albedo at roughness 0.92. It is available and it ships OFF
   for the hero. The browser has no ground either.
 - WORLD-VOLUME FOG MAKES THE BACKGROUND BLACKER, NOT FOGGIER, which is
   the opposite of the prediction in the previous commit. Volume Scatter
   has albedo 1, so an infinite medium should equilibrate to the source
   radiance — but `volume_bounces = 4` truncates multiple scattering, so
   with infinite optical depth most paths terminate before they ever
   reach the sky and the sky is extinguished instead. That is why
   `haze_lit` looked bright and unlit haze looks black: a lamp inside the
   volume is DIRECTLY sampled at every scatter event and does not depend
   on the bounce budget at all. Atmospheric perspective here needs a
   FINITE volume; there is none, and distant plants do not dim.

Co-Authored-By: Claude Opus 5 <[email protected]>
Found by looking at a 4K frame after a whole ladder of previews had
already settled the look, which is the expensive order to find it in.

`70_app.js` floors a vein at 1.5 px OF THE CANVAS IT IS DRAWING INTO, and
that canvas is about a thousand pixels tall. Carried across as a literal
1.5 px of the RENDER, the same vein on the same stand is:

    720x900     6.73 mm    100% of strand ends at the floor
    3200x4000   1.51 mm    100% of strand ends at the floor

4.5x, in the direction nobody checks. Every look decision in the ladder
was taken on veins four and a half times fatter than the deliverable, and
the first 4K came back papery with the tracery faint — which reads as a
sampling problem or a material problem and is neither.

`px_ref` scales the floor to a reference canvas rather than to this
frame, so a preview and a final are the same plant. `px_ref=0` restores
literal render pixels, which is right for judging the true HIERARCHY and
wrong for judging the LOOK.

AND THE BRACKET IS THE POINT, BECAUSE A PREVIEW COULD NOT HAVE FOUND IT.
At 4K: 1.51 mm papery, 6.05 mm merges the marginal veins into a CRUST
along every leaf edge, 2.42 mm reads as tissue. At 720x900 the 6 mm
version looked RIGHT — a whole leaf is 90 pixels there and the sampler
was doing the blending the veins are supposed to do. `px_ref=2500` is set
by eye and is the only number in this rig that could not have been
computed first, the same category as the wind's `uRef`.

Worth reading off those tables separately: 100% of strand ends sit at the
floor at whole-plant framing at EVERY resolution tried. The floor is not
lifting the thin tail of a distribution — it is setting every vein's
width, so the hierarchy canalisation grew is entirely flattened and these
frames show the network's TOPOLOGY, not its traffic.

Co-Authored-By: Claude Opus 5 <[email protected]>
The plan was "a growth timelapse OR a wind clip". Both halves were wrong,
and the number that says so had not been computed: the solver runs at 125
steps a second (WORLD.ptPerSec, matched exactly by App.step's accumulator)
and an Ember Creeper peaks at step 2527. That is 20.2 seconds of REAL TIME
for an entire life. The whole arc at 24 fps is 485 frames at stride 5.21,
played at natural speed, and the growth and the wind are the same footage.

It matters beyond naming. A timelapse has to pick a stride, and a stride is
a sampling rate: the stem's bending mode is 0.56-0.64 Hz and the fastest
shipped gust is 1.78 Hz, so anything under ~3.6 Hz aliases the wind into a
judder that reads as a bug in the solver. At 24 fps nothing aliases.

  blender_seq.mjs   the arc as 485 specimens. 6.3 GB, exported in 14 s.
  blender_clip.mjs  one Blender per frame, resumable, JOBS=/FROM=/TO=.

A frame is a whole specimen and not a deformation — organs appear, axes
branch, strands canalise, so there is no shape-key route to a growing
plant. 13.2 MB mean, 25.2 MB at peak.

THE RIG IS COMPOSED ONCE, FROM THE CLIP AND NOT FROM A FRAME, and this is
the whole reason it is a film rather than a slideshow of separately-lit
stills. film() places every lamp at a multiple of `r` and powers it at
`r*r`, where `r` is the subject's extent — the fix that stopped a garden's
clearing from lighting the hero. A growing plant walks into the identical
trap from the other side: rebuild per frame and a seedling gets a
seedling's key, so the exposure crawls and all four lamps slide outward
for twenty seconds. So span/pivot are computed once off the union bbox
over every frame. The garden fix was this fix, for free.

Measured rather than asserted: mean luminance drifts smoothly 36.86 ->
40.65 (the plant filling more frame), and the SECOND difference, which is
where frame-independent noise lands, has p90 0.029 and max 0.062 — 3% and
6% of one 8-bit level. No flicker. The width floor also comes out
temporally stable for free, 2.43 mm on every frame, same physical width as
the 4K still, because px_ref is driven by fixed focus/lens/res.

A STATIC SET IS INVISIBLE AS STATIC, and here is the arithmetic that says
so rather than an opinion. Per-frame sway (5.2 mm median, motion.mjs)
against the f/2.8 defocus circle, in pixels of a 1200-wide frame:

  9 m (hero)  1.93 px/frame   0.0 px blur
  15 m        1.16 px/frame   5.4 px
  25 m        0.69 px/frame   8.6 px
  40 m        0.43 px/frame  10.4 px

At 15 m a background plant's whole travel is a fifth of its own blur
circle, so a set exported ONCE costs 128 MB instead of 62 GB. --also
builds it into every frame; GARDEN_ONLY=1 exports a stand with no hero of
its own, since the sequence supplies one at the origin. And a defocused
set does not need solver-resolution lamina: MESH=auto is 96k triangles
against 1.35M, visually identical under 5.4 px of blur, and takes the
import from 10.2 s to 1.1 s a frame.

The composition trade, measured and deliberate: growth is near-linear, so
a frame composed for the final size holds a speck for ~2.5 s and under a
quarter height for ~5 s. That is right — a subject held at constant size
reads as a zoom, not as growth — but PIVOT_F defaults to 0.46 rather than
0.5 because the plant grows up out of its base. The framing derives span
3.611 m / pivot [0.297, -0.034, 1.388] against last session's hand-dialled
3.6 / [0.31, -0.04, 1.46].

src/ untouched; smoke's 73 checks pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
…king

`live.delete(p)` was a SECOND close handler registered after the one that
tests for completion, and node runs handlers in registration order — so the
test still counted the process it was reporting on, `running()` was never
false, and `done()` never fired.

I first wrote this up as a hang. It is not: node drains its event loop and
exits normally, so the run ends SILENTLY WITH STATUS 0 and no summary. That
is the worse version, because `done()` is the only thing that calls
`process.exit(fail ? 1 : 0)` — a run in which frames failed would also have
exited 0 and told a watcher it succeeded.

Found by running the real 485-frame job rather than by reading the code
again: it came back green with its last five lines being ordinary per-frame
progress and no total. Fix verified separately on a three-frame run at
160x200, which now prints its summary and exits 0 for the right reason.

Also recorded: the full life arc measured to `dead()`. Ember Creeper seed 7
senesces at step 2528 (20.2 s, frame 485) and dies at 8908 (71.3 s, frame
1710), so 71% of the life is the leaf drop. The shipped clip stops at 485,
the last frame before senescence. A seed-to-death film is three and a half
times the render and spends 51 of its 71 seconds dying — a composition call,
written down rather than taken. Note that compressing it by raising the
stride is the one edit the aliasing measurement forbids.

Co-Authored-By: Claude Opus 5 <[email protected]>
@aj-dev-smith
aj-dev-smith merged commit 5825350 into main Aug 5, 2026
4 checks passed
@aj-dev-smith
aj-dev-smith deleted the worktree-blender-bridge branch August 5, 2026 01:10
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.

1 participant