A bridge to a path tracer, and the first film - #40
Merged
Conversation
…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]>
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]>
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]>
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]>
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.
A bridge to a path tracer, and the first film
src/is untouched —git diff main..HEAD -- src/ canalisation.htmlis empty.This adds
tools/and two lines to.gitignore, nothing else. Smoke's 73checks,
views.mjs(named species, 23 checks) andviews.mjs(garden ofeight) 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 shippeddrawSpecimen, andthe only thing it supplies is a
Buffersthat keeps a second copy of whatpassed through. Same argument that keeps
test/views.mjsdriving the realdrawSpecimenthrough a stand-in App: a bridge that re-implemented the drawwould 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()hererecords
(a, b, w0, w1)and dropsside; Blender gets a curve with a radiusper 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 --turntableisthe check on that claim.
The deliverables
shots/EMBER_STAND_4k.png— 3200x4000, 512 spp, a hero and a stand.shots/EMBER_LIFE_24fps.mp4— 20.2 s, 1080x1350, 485 frames. An EmberCreeper 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), anambient term by normal, which is why nothing in the browser is ever unlit.
_sky()reproduces that split physically withIs Camera Ray: camera rays getthe
BG_FSbackdrop, everything else gets theMESH_FSambient, both fromcolours already in the export header.
The trap under it: those are two gradients of two different things and I
collapsed them into one.
BG_FSgradients on screen height;MESH_FSon thesurface normal. A vertical stem has horizontal normals, so through a
photographic horizon at -0.30 it sampled t=0.25 — near
ambBot— where thebrowser gives it the exact midpoint.
skyat 1, 4, 6 and 14 all producedidentical 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_refscales 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=2500is set by eye and is the only number in the rig that could nothave 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 byApp.step'saccumulator) 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 ofrand powers itat
r*r, whereris the subject's extent. That was fixed earlier on thisbranch 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/pivotare computed once off the union bbox over all485 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:
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=autois 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.
plantGardenspreads 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 —
plantGardenpicks it with a PRNG too — and the arcmoves where the camera is pointed, not what grows. Worth watching that line.
Bugs found here that were not visible on screen
(
cycles_curves.shape = "THICK").Buffersis sized for a framebudget and drops geometry; a file is the wrong place for that constraint. The
emitters grow now, and a saturated buffer exits non-zero.
bgkey that does not exist (a species carriesbgTop/bgBot/bgGlow), soJSON.stringifydropped it and every consumerfell 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 "nobodyasked".
checking.
live.delete(p)was a secondclosehandler registered after thecompletion 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 thatcalls
process.exit(fail ? 1 : 0). A run with failed frames would also haveexited 0. Found by running the real 485-frame job, not by re-reading the code.
What this does NOT solve, stated plainly
ones. A world volume makes the background blacker, not foggier —
volume_bounces = 4truncates multiple scattering, so at infinite opticaldepth most paths die before reaching the sky. Needs a finite volume; unbuilt.
near-black albedo at roughness 0.92.
groundships off for the hero.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.
future orbit or close-up will not.
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 leafdrop. 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.mdrather than made. ⚠ Note that compressing it by raising thestride is the one edit the aliasing measurement above forbids.
🤖 Generated with Claude Code