Skip to content

feat(scene): 3D scene graph and deferred renderer (1.5) - #280

Closed
aayars wants to merge 45 commits into
mainfrom
scene-graph
Closed

aayars wants to merge 45 commits into
mainfrom
scene-graph

Conversation

@aayars

@aayars aayars commented Aug 14, 2026 •

Copy link
Copy Markdown
Member

Adds a third dimension to the DSL. A scene() program describes a camera, lights, and a mesh hierarchy with PBR materials; a deferred renderer turns it into pixels and writes the result into an ordinary pipeline surface, so 3D output composes with the existing 2D filter chains rather than needing its own post-processing stack.

Carries a minor bump to 1.5.

The entire scene surface ships as a preview feature — experimental and subject to change, scheduled to be finalized in Noisemaker 2.0. Node names, keywords, defaults, argument shapes and rendered output may all change without a deprecation period. This is stated on every entry point: the scene.rst and deferred-rendering.rst page titles carry "(Preview)", the grammar marks SceneCall and everything reachable from it, and pipeline-3d.rst says explicitly that volumetric rendering is stable while this is not.

This PR is deliberately long-lived. It stays open until the new subsystem is reconciled against 1.0 — in particular until the scene renderer and the existing volumetric system are one system rather than two. It is published now for visibility into that process, not because it is ready to land. The review below is deliberately unflattering; every item was independently verified against the code.

Scope

This PR is the scene graph and nothing else.

Three things that look like drive-by cleanup are deliberately kept, because the test-suite work below cannot go green without them:

  • The filter/stipple hash fix. Nine checked-in tests navigate to /shaders/manifest.json, a path that does not exist. With that corrected to /shaders/effects/manifest.json, test_artistic_effect_release.mjs actually runs for the first time and catches stipple diverging between backends by a max delta of 251. The fix takes it to bit-identical.
  • The replaceEffect builtin guard. test_transform.js asserts listSteps returns 2 steps; it returns 3, and has been failing at main indefinitely without anyone noticing (see below). Registering the test surfaces it; the kind discriminator fixes it.
  • The glData declaration in test/effects.test.js, without which the file throws partway through and six of the eight parity failures stay invisible.

Everything else in the "known gaps" section is filed for separate upstream fixes and will be folded in as those land.

Rebased onto main @ 7f61a360. Earlier conflicts were resolved toward main (union package.json; main's generate_fixtures.py and its vhsHalf coverage kept outright); the latest rebase over the dep bumps was conflict-free.

Green after the rebase: run-js-tests --skip-parity, test:shaders:lang, test:shaders:runtime, main's new test:shaders:parity-attestations, and test_artistic_effect_release (401 cross-backend cases, stipple bit-identical). Full npm test is not verifiable in this environment — checkEnums.js needs a Python reference with numpy — but this branch no longer modifies any file involved in it.

What's included

  • DSL surface — scene, camera, light, mesh, material, group, environment, surface, emit. The parser and validator accept scene blocks and lower them to a scene IR; the expander and compiler route that IR to the scene renderer. Oscillator descriptors embedded in the IR evaluate against the same normalized loop time as effect uniforms, so scene animation and effect automation share one clock.
  • Scene graph (shaders/src/scene/) — parent/child transforms with dirty-marked world matrices, perspective camera, directional/point/spot lights, transform math on gl-matrix, a clock, and animation bindings that hoist osc() descriptors out of the tree.
  • Rendering (shaders/src/rendering/) — a G-buffer deferred path: albedo/metallic, normal/roughness, position/emission and depth targets, Cook-Torrance PBR, SSAO, screen-space and planar reflections, an amortized reflection probe, and an exposure-controlled tonemap.
  • Geometry (shaders/src/geometry/) — procedural primitives, a glTF/GLB loader, and marching cubes.
  • Backends — WebGL2 and WebGPU both grew MRT support, cube textures, depth attachments, cull-mode handling, and waitForIdle().
  • Docs — docs/shaders/scene.rst and docs/shaders/deferred-rendering.rst, both marked preview, plus corrections to language.rst where the grammar had drifted from the parser (positional and keyword arguments were documented as mutually exclusive after the parser had already dropped that restriction, and the Arg production admitted neither keyword arguments, array literals, object literals, nor nested chains despite all four being accepted).

Review findings — found, then fixed on this branch (three cycles)

The first independent review of this branch produced six blocking findings. Rather than merging around them, they were fixed here, each test-first, and then a second adversarial review of the fix layer itself produced another round of findings — also fixed. Summary of both rounds, with the corrections the process forced on this PR's own earlier claims:

1. Argument-ordering regression — mixed positional/keyword calls silently dropped positionals in every effect call (blur(0.9, angle: 0.5, 4) discarded the 4; previously a hard SyntaxError). Fixed with a positional cursor in the validator and all three parser resolvers, an excess-positional diagnostic, and a hex-splat that fills exactly the slots no keyword claims. The binding rule is now documented in language.rst: a positional displaced by a keyword shifts to the next unfilled slot, deliberately.

2. Scene programs did not round-trip — unparse() emitted _scene.scene(_ast: [object Object]). Fixed with a scene AST printer; the second review then broke the printer four more ways (exponent notation the lexer can't read, silent deletion of unhandled nodes, string re-escaping growth, inlined let bindings) — all fixed; the printer now throws on an unknown node type rather than corrupting source. Both demo scenes round-trip with byte-identical IR.

3. Scene names swallowed diagnostics — the eleven scene names matched at any chain position, so a solid() typo under search filter silently rendered nothing. Now only scene() is a chain element (and must start its chain); the other ten name the constraint in an S001. Unknown keywords are errors on every scene node including group(), scene errors carry real line/col (value literals now hold positions — the first fix had only covered Call nodes), and the diagnostic column field actually populates.

4. The bit-identical claim was false — no test compared the backends. A pixel comparison was added and measured maxDelta 35 over 14.5% of channels. Bisection by pass proved the G-buffer bit-identical and isolated two SSAO orientation bugs in the WGSL variants (the lighting pass sampled SSAO vertically mirrored; the noise rotation used a top-left origin). Fixed: maxDelta 4 over 0.0004%, gated at ceiling 6 via --cross-backend-only, with the residual understood (one SSAO sample crossing a hard threshold under compiler reassociation — quantized, not reducible without changing the look). deferred-rendering.rst documents the measurement instead of the claim.

5. Frame ordering was inverted — the pipeline blitted the previous frame's scene texture. The scene is now awaited before the pipeline, overlapping renders collapse to one, the in-flight promise is failure-guarded (a rejection reaches _onError exactly once and never becomes an unhandled rejection), a long shader recompile no longer stalls the 2D pipeline, and the pending render is cleared on dispose/recompile. The graph also compiles once per edit instead of twice on the first-compile path.

6. Crashes, NaN, and leaks — a scene without camera() threw every frame (now: the documented defaults); osc() in camera/light vectors NaN'd silently (now: compile errors); mesh params were unvalidated (tube: 0 → NaN geometry, segments: 100000 → ~5e9 vertices; now: validated against the primitive specs); the first draw into each mesh FBO landed on the canvas (framebuffer binding now restored); depth attachments leaked on resize on both backends (released / LRU-bounded with per-eviction deferred destruction); mesh passes allocated per frame against the project rule (built once per node, mutated in place — reviewed for cross-contamination and clean for three independent verified reasons).

Corrections this process forced on earlier claims in this PR:

  • The two invariant-hash rebaselines were not caused by the clear-handling change — that hypothesis was tested and refuted. The unmodified merge-base source produces the new hashes in this environment; the deltas are between ANGLE/driver builds, not revisions. The test comments now say so.
  • The "uncaptured error per scene load" from the eager single-target pipeline was refuted by probing a real device — Dawn permits extra fragment outputs. The fix removes a wasted pipeline compile, not error noise.

Verification depth: a test-integrity review ran 32 sabotages — reverting each production fix individually — and every one turned its test red. The runner holds 92 uniquely-registered entries. Four latent backend hazards were also hardened (silent struct-layout fallback now warns naming the struct; cube+MRT throws on both backends; r32f readPixels decodes correctly — verified on hardware; multi-output programs skip the useless eager pipeline).

Reconciliation — complete

The scene system and the pre-existing volumetric system grew the same architecture independently, with zero shared code. That is no longer true. Every row of the original overlap inventory is resolved or at a decided terminal state:

Overlap Resolution
Vertex packing / mesh data shape One implementation (Geometry.toPackedTextures), one canonical shape; byte-identity with both deleted implementations proven twice, independently.
Primitive generators primitives.js is the single source; generate.cjs is a serializer over it. Regeneration fixed cube.obj/icosphere.obj shipping with 100% of triangles wound backwards.
Volumetric content in scenes volume(volN, threshold, mode, transforms).material(...) — bounding-box rasterization into the shared G-buffer, hardware depth compositing against meshes on both backends, lit by scene lights/SSAO/SSR with zero changes to those passes. Smooth isosurface and voxel DDA modes; the voxel wall math deliberately fixes render3d's half-cell error. Volumes appear in planar reflections (clip plane applied to the marched hit) and the reflection probe.
Marcher implementation Single-sourced: marcher-fragments.js emits the legacy effect shaders byte-identically (gated by an emitter byte-compare test), and the scene marcher assembles from the same fragments with its documented deltas body-compared by a drift gate. renderLit3d stays outside the emitter by measured decision (~55 shared lines against a third field variant); meshRender is not re-based (the shell exceeds the duplication removed; meshLoader already rides the unified packing path).
Cross-backend parity The four legacy volumetric renderers and filter/stipple carry epsilon-0 parity attestations — the claimed fragcoord divergence was measured, refuted, and the wrong claim corrected at its source. Scene parity gates: mesh maxDelta 4 (ceiling 6), smooth volume 7 (8), voxel 6 (7). Per-backend pixel pins with committed baselines run via npm run test:shaders:pins, covering the legacy voxel branch the attestation schema cannot.
Camera / cubemap drivers Scene programs export cubemaps through the same renderCubemap() contract (six faces, one instant — a priming render puts the volume atlas on the export instant; the probe is captured once and frozen). Tiled export renders scenes by sub-frustum: geometry/marching/lighting/tonemap stitch exact to 1 LSB, SSAO's dither is tile-phase-corrected and its seam bounded by its kernel's reach.
Animation Scene bindings evaluate osc(), midi() and audio() through the pipeline's own evaluators against the same state record; descriptor enum spellings (number/member/ident) mean the same thing on both sides, proven by an 11-row cross-system oracle.
Model loading glTF/GLB is wired beside OBJ on the same packing path (multi-primitive merge, normalized accessors, honest unsupported-list).
Capability closures llms-full.txt covers scene()/volume(); the scene viewer is linked and shipped by the bundler; runtime volumeSize updates that would shear a marched atlas are refused with a warning that survives recompiles.

Decided terminal states (maintainer-signed): geo0–geo7 and two-argument write3d stay; marchingCubes is not the bridge (2 MB/frame readback) and may become a compile-time opt-in for static volumes later; surface() textures on volumes stay rejected (no UVs on an isosurface); the vol atlas stays 64³.

Honest caveats, documented in the docs rather than hidden: stateful simulation chains (cellularAutomata3d, reactionDiffusion3d) advance one generation per render call, so their six cubemap-export faces are six consecutive generations — fixing that needs a replay mechanism the pipeline does not have; local SSR is untileable in principle (the planar reflector path is tile-exact and the docs steer there); unresolvable enum members default silently in 2D and error in scenes; surface readback row order is backend-relative (identically for 2D and scene paths).

Test suite

The runner used an explicit list with no globbing, so ~50 test files were never executed by anything. Registered those that both pass and can fail, plus the new fix-layer tests: 37 → 92 entries, with orphans down from 55 to 3. Eight tests caught their own exceptions, printed FAIL and exited 0; six were given real failure gates.

Verified state on this branch: run-js-tests.js --skip-parity, test:shaders:{lang,runtime,structure,invariants,parity-attestations} all green, plus 15/15 GPU cases across both backends in test_visual_playwright.js (which no longer dirties the tree — its output goes to a gitignored .artifacts/ directory).

Known gaps, stated rather than papered over. All of these pre-date this branch and reproduce on main — they are being fixed upstream separately and folded in as they land, rather than expanding this PR further:

  • Two tests still cannot fail a build. test_parser.js:9 and test_compiler_phase2.js:29 catch, print FAIL, and never set an exit code. test_parser.js runs in CI. Both emit zero FAIL lines today. llms-full.txt GAP-023 names test_compiler_phase2.js specifically and is still open.
  • test_phase2_integration.js is modified, failing, and unwired. It asserts texture pooling that compiler.js computes into graph.allocations and never reads. Left failing rather than re-baselined to assert the no-op.
  • test:shaders:invariants is not in CI. .github/workflows/shaders.yml runs only lang, runtime, render:webgl2, structure. Also npm run test:shaders now requires a display, because test_spawnpoint_wgsl_vertical_mirror.mjs launches headless: false.
  • The eight drifted JS/Python parity fixtures are fixed on main as of 1ee891a2, which also adds a shader parity-attestation system. This branch no longer touches test/effects.test.js at all — it is byte-identical to main, including main's rgbEffectData naming and its new vhsHalf regression. The glData declaration this branch previously carried was superseded and has been dropped. eslint.config.js still removes the now-dead glData global, which main left behind.
  • test_transform.js's listSteps assertions fail on main today and always have. The file has no failure gate and is registered in no runner, so nothing has ever reported it.

Other notes for reviewers

  • gl-matrix is the project's first runtime dependency. It is imported in exactly one file and lazily, so 2D pages never load it — but it is a bare specifier, so any page compiling a scene needs an import map. scripts/bundle-site.js:131 hardcodes an importmap that omits gl-matrix; esbuild inlines it into the core bundle instead, which means the lazy-load rationale does not hold for the bundled artifact.
  • gltf-loader.js and procedural.js are 769 lines with tests and no production consumers. Shipped parked.
  • demo/shaders/scenes/ is unreachable — linked from no page, mentioned in no .rst, and not copied by bundle-site.js.
  • Scene shaders are invisible to the effect tooling — Shade MCP, test:shaders:structure, generateManifest, and the i18n string tables all key off shaders/effects/*/, which the JS-template-generated scene shaders bypass.
  • The recompile (live-edit) path still parses the source twice — the first-compile double-compile is fixed, but recompile() re-parses independently. Known-open; a follow-up, not a blocker.
  • .gitignore still has no .DS_Store entry, though a tracked one is deleted here.

Correctness work folded in

  • filter/stipple pointillize rendered differently per backend and per ANGLE backend. Its fract-based jitter hash contracted to FMA inconsistently, and fract() is discontinuous at integers, so a last-bit difference could swing a Voronoi seed a whole cell. Replaced with exact integer hashing; parity went from max delta 251 to bit-identical.
  • replaceEffect() would target the compiler's terminal _write node and rewrite a chain's output wiring. StepInfo now carries a kind discriminator and builtins are rejected.
  • Automation min/max are a normalized [0,1] sub-range scaled by the consuming parameter's spec; stale docstrings and an out-of-date integration test were corrected.
  • Fixed a hardcoded Windows Chrome path, a 127.0.0.1 URL, and nine navigations to a manifest path that does not exist. The last of these is what made test_artistic_effect_release.mjs run for real and catch the stipple divergence above.
  • Removed per-frame frame-time telemetry with no readers, and a tracked .DS_Store the site bundler was copying into production builds. The broader dead-code sweep that was here has been reverted — see Scope.

Verified independently

Areas checked and found sound: primitive winding and normals (all six box faces, sphere body and poles, cylinder side and caps, torus, plane — consistently CCW-front with outward normals); the inverse-transpose normal matrix; all PBR terms (GGX D, Smith/Schlick-GGX G with the correct direct-lighting k, Schlick F, the 4·NdotV·NdotL denominator) identical between GLSL and WGSL; G-buffer packing with no off-by-one; WGSL uniform struct offsets (writer and shader agree, since webgpu.js parses the same declaration); the six cube-face basis pairs; dirty-flag propagation and IR→tree wiring; o0–o7 discipline; no DOM reads in the scene render path; and no 2D regression from the new clear handling (no existing effect sets clear: true).

🤖 Generated with Claude Code

@aayars
aayars requested a balanced review from Copilot August 16, 2026 17:09
@aayars aayars self-assigned this Aug 16, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds a preview 3D scene-graph + deferred renderer path to the shaders runtime, wiring scene() DSL programs to render into a pipeline texture so they can compose with existing 2D effects.

Changes:

  • Introduces scene() DSL passthrough in the validator plus a scene compiler that produces scene IR, scene tree nodes, bindings, and supporting math/geometry modules.
  • Extends the runtime/compiler/expander + CanvasRenderer to recognize scene programs, render them into scene_color, and blit into standard surfaces.
  • Expands test coverage and documentation for scene/deferred rendering, plus fixes/updates around cube textures, invariants baselines, and version bump to 1.5.

Reviewed changes

Copilot reviewed 89 out of 97 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
test/effects.test.js Restores shared regression input data for removed glitch block.
shaders/tests/test_wind_halftone_render.mjs Updates expected hash baseline for line pattern.
shaders/tests/test_webgpu_binding_parser.js Adds cube-texture classification regression test.
shaders/tests/test_webgl_uniforms.js Adds unit coverage for WebGL uniform vector setters.
shaders/tests/test_validator.js Ensures node test runner exits nonzero on failures.
shaders/tests/test_transform.js Adjusts expectations to account for builtin steps in listSteps().
shaders/tests/test_tiling_parity.mjs Updates manifest URL for WebGPU bootstrap.
shaders/tests/test_spawnpoint_wgsl_vertical_mirror.mjs Hardens mirror test against blank frames + adds headed WebGPU rationale.
shaders/tests/test_scene_tree.js Adds scene tree/node hierarchy tests.
shaders/tests/test_scene_parser.js Adds parser coverage for scene DSL + literals + mixed args.
shaders/tests/test_scene_math.js Adds math utility tests for transforms/reflection/planes.
shaders/tests/test_scene_graph_wiring.js Validates compileGraph wiring for scene programs and SCENE_COLOR texture.
shaders/tests/test_scene_bindings.js Covers oscillator bindings collection/evaluation for scene nodes/lights.
shaders/tests/test_resources.js Ensures node test runner exits nonzero on failures.
shaders/tests/test_procedural.js Adds CPU marching-cubes procedural geometry smoke tests.
shaders/tests/test_post_shaders.js Verifies post shader generators (GLSL/WGSL) output structure.
shaders/tests/test_pond_ripples_plastic_wrap_invariants.mjs Updates baseline hash + manifest URL for WebGPU bootstrap.
shaders/tests/test_phase2_integration.js Fixes namespace registration + sets process exit code on failure.
shaders/tests/test_midi_audio_integration.js Updates automation min/max semantics to normalized range + consumer mapping.
shaders/tests/test_median_exact.mjs Updates manifest URL for WebGPU bootstrap.
shaders/tests/test_integration.js Ensures node test runner exits nonzero on failures.
shaders/tests/test_grain_original_output.mjs Updates manifest URL for WebGPU bootstrap.
shaders/tests/test_gltf_loader.js Adds minimal GLB parsing + Geometry conversion tests.
shaders/tests/test_glmatrix_smoke.js Adds gl-matrix import smoke test.
shaders/tests/test_geometry_primitives.js Adds primitive generation + winding-vs-normal invariants tests.
shaders/tests/test_gbuffer.js Adds deferred renderer shader contract tests (GLSL/WGSL).
shaders/tests/test_expander.js Updates expander expectations (node-scoped programs + terminal blit).
shaders/tests/test_emboss_gray_invariants.mjs Updates manifest URL for WebGPU bootstrap.
shaders/tests/test_cube_texture.js Expands cube texture testing (formats, renderability, WebGPU views, depth caching).
shaders/tests/test_corrective_mode_variants.mjs Updates manifest URL + rebaselines hashes with deterministic jitter hash.
shaders/tests/test_compiler_phase2.js Filters out builtins when asserting effect steps.
shaders/tests/test_comment_roundtrip.js Ensures node test runner exits nonzero on failures.
shaders/tests/test_clock.js Adds scene clock unit tests.
shaders/tests/test_artistic_effect_release.mjs Updates manifest URL for WebGPU bootstrap.
shaders/tests/test-define-recompile.mjs Makes define-variant recompilation test portable/self-contained + headless.
shaders/src/scene/tree.js Adds SceneTree builder/traversal helpers from scene IR.
shaders/src/scene/node.js Adds SceneNode hierarchy + transforms + dirty propagation.
shaders/src/scene/mesh-node.js Adds MeshNode specialization with mesh/material/reflector flags.
shaders/src/scene/math.js Adds gl-matrix-based scene math utilities and helpers.
shaders/src/scene/light.js Adds LightNode specialization with light parameters.
shaders/src/scene/clock.js Adds animation clock for elapsed/delta/normalized loop time.
shaders/src/scene/camera.js Adds CameraNode with view/projection matrix helpers.
shaders/src/scene/bindings.js Adds osc() bindings collection/evaluation for scene transforms/lights.
shaders/src/runtime/pipeline.js Exports evaluateOscillator() for reuse by scene bindings.
shaders/src/runtime/expander.js Seeds pipeline input from scene_color for scene steps + allocates texture spec.
shaders/src/runtime/compiler.js Compiles scene IR during graph compilation and annotates graph with _isScene.
shaders/src/runtime/backends/webgl2.js Adds cube texture format/usage support, cube face selection, clear/cull modes, waitForIdle, typed-array vec uniforms.
shaders/src/runtime/backend.js Introduces optional backend waitForIdle() API.
shaders/src/rendering/scene-compiler.js Adds AST-to-scene-IR compiler + settings/materials/transforms/reflection validation.
shaders/src/rendering/post-shaders.js Adds present + tonemap shader generators (GLSL/WGSL).
shaders/src/rendering/mesh-renderer.js Adds mesh pass construction + geometry packing/upload pipeline.
shaders/src/renderer/canvas.js Adds lazy scene module loading + scene render integration into CanvasRenderer.
shaders/src/lang/validator.js Allows scene DSL functions to pass through as _scene.* steps and preserves AST.
shaders/src/lang/transform.js Makes builtins non-targetable and adds kind discrimination to listSteps().
shaders/src/lang/parser.js Adds object literals + mixed positional/keyword arg parsing support.
shaders/src/geometry/primitives.js Adds procedural mesh primitive builders (sphere/box/plane/cylinder/torus).
shaders/src/geometry/gltf-loader.js Adds GLB/GLTF parser + Geometry conversion with normal/uv generation.
shaders/src/geometry/geometry.js Adds Geometry container class.
shaders/effects/filter/stipple/wgsl/stipple.wgsl Replaces jitter hash with integer-only deterministic variant.
shaders/effects/filter/stipple/glsl/stipple.glsl Replaces jitter hash with integer-only deterministic variant + rationale.
scripts/run-js-tests.js Registers new scene/deferred/geometry tests in JS test runner.
pyproject.toml Bumps Python package version to 1.5.
package.json Bumps npm version to 1.5, adds gl-matrix dep, expands shader test scripts.
llms-full.txt Updates documentation about builtin step kind and GAP-022 resolution.
eslint.config.js Removes glData global declaration.
docs/shaders/specs.rst Adds scene + deferred rendering pages to specs TOC.
docs/shaders/scene.rst Adds scene graph DSL reference and behavior notes (preview).
docs/shaders/pipeline-3d.rst Adds anchor + clarifies distinction from scene renderer.
docs/shaders/language.rst Adds scene grammar + mixed args + object literal notes (preview).
docs/shaders/deferred-rendering.rst Adds deferred renderer architecture/spec (preview).
docs/conf.py Bumps docs version/release to 1.5.
demo/shaders/scenes/viewer.html Adds standalone scene viewer page with import map + loading/error handling.
demo/shaders/scenes/materials-lab.dsl Adds comprehensive scene showcase DSL.
demo/shaders/scenes/hello-engine.dsl Adds introductory scene DSL.
demo/shaders/index.html Adds gl-matrix import-map entry for demo hosting.
.github/workflows/site.yml Updates GitHub App token step inputs (now uses client-id).
.github/workflows/shaders.yml Updates GitHub App token step inputs (now uses client-id).
.github/workflows/pull-shade-mcp.yml Updates GitHub App token step inputs (now uses client-id).
.github/workflows/export-kits.yml Updates GitHub App token step inputs (now uses client-id).
.github/workflows/downstream.yml Updates GitHub App token step inputs (now uses client-id).
.github/workflows/docs-site.yml Updates GitHub App token step inputs (now uses client-id).
.github/workflows/dependabot-auto-merge.yml Updates GitHub App token step inputs (now uses client-id).
Suppressed comments (5)

shaders/src/renderer/canvas.js:1

  • This compiles the full graph once just to detect _isScene, then later compilation happens again when building/rebuilding the pipeline. That doubles a relatively expensive compile/expand/allocation path and can noticeably impact interactive compile latency. Consider extracting a lighter-weight detectScene() (e.g., compile AST + compileScene only) or reusing the already-compiled graph as the source of truth when creating the runtime pipeline so the work is not repeated.
    shaders/src/renderer/canvas.js:1
  • _renderScene() triggers an async render every frame but does not await or serialize calls. If SceneRenderer.render() sometimes spans multiple ticks (e.g., shader compilation on light-count changes, GPU backpressure, or transient stalls), this can queue overlapping renders and create unbounded pending promises / GPU work. A concrete fix is to store the in-flight promise (e.g., this._sceneRenderPending) and either await it in the render loop or skip starting a new scene render while one is still pending.
    shaders/src/rendering/scene-compiler.js:1
  • buildCamera() currently accepts unvalidated values for fov/near/far/pos/target (including wrong arity arrays, non-finite numbers, or even objects), which can later break matrix math in ways that are hard to trace back to a scene parse error. Since this module already has numberKw() and vectorKw(), prefer using them here (and optionally assertKnownKeywords) to enforce the documented contracts (finite numbers; pos/target are vec3).
    shaders/src/geometry/gltf-loader.js:1
  • parseGLB() doesn’t validate that a BIN chunk was found. If the GLB is missing BIN (or if the file is malformed), binChunk remains null and parseGLTFInternal() will later throw less actionable errors when constructing TypedArrays. Consider adding an explicit if (!binChunk) throw new Error('GLB missing BIN chunk') before calling parseGLTFInternal().
    shaders/src/scene/node.js:1
  • getWorldMatrix() allocates a new matrix every time a node is dirty (composeTransform() returns a fresh mat4). In larger scenes or with per-frame animation bindings, this will create steady GC pressure. Prefer writing into the preallocated this._localMatrix (e.g., via mat4.fromRotationTranslationScale(this._localMatrix, ...)) or evolving composeTransform() to accept an out parameter.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread .github/workflows/site.yml
@aayars
aayars marked this pull request as draft August 16, 2026 17:19
@aayars
aayars force-pushed the scene-graph branch 2 times, most recently from 10e59b7 to 06866a5 Compare August 25, 2026 06:09
aayars and others added 24 commits August 28, 2026 00:16
Adds a third dimension to the DSL. A scene() program describes a camera,
lights, and a mesh hierarchy with PBR materials; a deferred renderer turns it
into pixels and writes the result into an ordinary pipeline surface, so 3D
output composes with the existing 2D filter chains rather than needing its own
post-processing stack.

DSL and compilation
  New scene surface: scene, camera, light, mesh, material, group, environment,
  surface, emit. The parser and validator accept scene blocks and lower them to
  a scene IR; the expander and compiler route that IR to the scene renderer
  instead of the effect pipeline. Oscillator descriptors embedded in the IR are
  evaluated against the same normalized loop time as effect uniforms, so scene
  animation and effect automation share one clock.

Scene graph (shaders/src/scene/)
  tree/node/mesh-node with parent-child transforms and dirty-marked world
  matrices, perspective camera, directional/point/spot lights, transform math
  on gl-matrix, a clock, and animation bindings that hoist osc() descriptors
  out of the tree and advance them in place with no per-frame allocation.

Rendering (shaders/src/rendering/)
  A G-buffer deferred path: albedo/metallic, normal/roughness,
  position/emission and depth targets, Cook-Torrance PBR lighting, SSAO,
  screen-space and planar reflections, and an exposure-controlled tonemap.
  scene-compiler turns scene IR into passes, scene-renderer drives them, and
  mesh-renderer handles draw submission.

Geometry (shaders/src/geometry/)
  Procedural primitives (sphere, box, plane, cylinder, torus), a glTF/GLB
  loader, and a marching-cubes implementation.

Backends
  WebGPU and WebGL2 both grew multiple-render-target support, cube textures,
  depth attachments and cull-mode handling to serve the G-buffer, plus
  waitForIdle() for GPU backpressure. Every scene path is implemented once and
  runs on both backends; the render tests assert bit-identical output between
  them.

Demo
  A scene viewer with hello-engine and materials-lab scenes, plus reference
  screenshots for both backends.

Correctness work folded in
  - filter/stipple pointillize rendered differently per backend and per ANGLE
    backend. Its fract-based jitter hash contracted to FMA inconsistently, and
    fract() is discontinuous at integers, so a last-bit difference could swing
    a Voronoi seed a whole cell. Replaced with exact integer hashing; parity
    went from max delta 251 to bit-identical.
  - replaceEffect() would happily target the compiler's terminal _write node
    and rewrite a chain's output wiring. StepInfo now carries a kind
    discriminator and builtins are rejected.
  - Automation min/max are a normalized [0,1] sub-range scaled by the consuming
    parameter's spec; stale docstrings and an out-of-date integration test were
    corrected to match.
  - test/effects.test.js referenced an undefined glData, silenced by listing it
    in eslint's globals rather than fixed, so the file threw partway through
    and every later assertion was unreachable. Declaration restored, globals
    entry dropped.

Test suite
  The runner used an explicit list with no globbing, so roughly fifty test
  files were never executed by anything. Audited each by running it and
  registered those that both pass and can fail, taking the runner from 49 to 82
  entries. Eight of those tests caught their own exceptions, printed FAIL and
  exited 0 — structurally incapable of failing a build — and were given real
  failure gates. Added a test:shaders:invariants suite for backend-invariant
  render checks. Fixed a hardcoded Windows Chrome path and a 127.0.0.1 URL in a
  checked-in test, and nine navigations to a manifest path that does not exist.

Dead code removal
  Completed one-shot codemods, tooling referencing deleted paths, tests whose
  subjects no longer exist, per-frame frame-time telemetry with no readers,
  backend methods with no callers, and orphaned fixtures. A tracked .DS_Store
  that the site bundler was copying into production builds is gone.

Known open items, deliberately not papered over
  - Eight JS/Python parity fixtures are out of sync (vhs 0.155, ripple 0.120,
    falseColor 0.068, snow 0.058, nebula 0.023, grime 0.007, sketch 0.002,
    crt 0.00006), so the default npm test fails at the first one. Most deltas
    are far above float noise, so these are more likely a JS regression than
    stale fixtures; adjudicating needs a Python environment to run the
    reference implementation.
  - compiler.js computes allocateResources() into graph.allocations and nothing
    ever reads it, so texture pooling is a compile-time no-op. Passes reference
    upstream values through a per-node alias, so liveness analysis cannot see
    when an intermediate dies. test_phase2_integration is left unwired and
    failing rather than re-baselined to assert the no-op.
Adds two specs to the RST corpus and brings the DSL spec up to date with the
language surface the scene work introduced.

docs/shaders/scene.rst
  The scene() vocabulary end to end: structure and grammar, the settings table,
  camera, the three light types and which keywords apply to each, environment,
  every mesh primitive with its shape parameters, groups and transforms, the
  material system (solid/surface refined by pbr/emit), reflector(), oscillator
  animation, and how a scene composes with the 2D pipeline. Defaults are read
  from scene-compiler.js and primitives.js rather than restated from memory.

  Records the name-resolution rule that is easy to get wrong: scene function
  names are a fallback, not a reservation. Calls resolve against registered
  effects first, so solid() under `search synth` is still the 2D generator.
  Also documents the gl-matrix import-map requirement, which only bites pages
  that actually compile a scene, since the scene modules load lazily.

docs/shaders/deferred-rendering.rst
  Frame order and pass sequence, G-buffer channel packing, the Cook-Torrance
  terms, SSAO, both reflection branches, planar mirroring, the reflection
  probe, environment sampling, tonemap, and backend parity mechanics.

  Four things here contradict what the code looks like at a glance and are
  called out explicitly: the environment map is never drawn (it lights surfaces
  and shows in reflections, but background pixels take flat background);
  `ambient` is not a shader term, only the scalar that fills sky and ground;
  SSAO is a mix factor that attenuates ambient alone, not direct light or
  emission; and SSR has no environment fallback, being purely additive on a lit
  colour that already carries environment and probe specular.

  Two behaviours documented because they surprise: `falloff: 0` disables point
  light attenuation entirely rather than defaulting to inverse-square, and the
  reflection probe is amortized — six faces on first activation, then one face
  per frame, so a full refresh takes six frames.

  Two backend divergences recorded rather than smoothed over: face culling
  differs on the G-buffer fill (single-sided on WebGL2, double-sided on
  WebGPU), and the zero-mesh clear-pass path may be invalid on WebGPU. The
  latter is marked unverified — no test covers it and it was not confirmed
  against a running WebGPU device.

docs/shaders/language.rst
  Corrects a statement that had become false: positional and keyword arguments
  were documented as mutually exclusive within a call, but the parser dropped
  that restriction, which is what lets scene() interleave settings with child
  nodes. Extends the grammar with the scene productions and fixes the Arg
  production, which admitted neither keyword arguments, array literals, object
  literals, nor nested chains despite all four being accepted. Adds a Scenes
  section under Language Features.

docs/shaders/pipeline-3d.rst
  Mutual disambiguation with the scene doc. Both subsystems are "3D" and a
  reader landing on either will assume it covers the other; volumetric field
  marching and mesh rendering are unrelated paths that merely both terminate in
  surfaces.

Sphinx is not installed here, so the build was not run. Validated instead with
a checker over heading underlines, :ref: targets, list-table row arity against
:widths:, and toctree resolution: 31 files, 12 labels, 15 refs, no problems.

Not covered: llms-full.txt still has no mention of scene(). That is a separate
document with its own conventions and was left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Minor bump for the scene graph and deferred renderer. Covers the three
places the version is declared: the npm package, the Python package, and
the Sphinx short/full version pair.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The scene commit folded in a round of dead-code removal that has nothing
to do with the scene graph: one-shot codemods, tooling for deleted paths,
tests whose subjects no longer exist, and several JS/Python parity
fixtures. None of it is load-bearing for the scene renderer in either
direction — nothing references these files, and nothing needed them gone.

Restoring them keeps this PR to the feature under review. The cleanup may
well be correct, but it belongs in its own change where it can be argued
on its own merits. The parity-fixture deletions in particular deserve that
scrutiny: test/effects-fixture.test.js covers wormsParams, which still
exists, and it was failing on value drift rather than API rot.

demo/shaders/img/.DS_Store is deliberately left deleted. It is tracked
macOS junk that the site bundler was copying into production builds, and
restoring it to keep the diff tidy would be the wrong trade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same reasoning as the restored files: copyTexture() is unrelated to the
scene work and its removal is drive-by cleanup. It was dead at upstream —
defined on the abstract base and both backends, called by nobody — so
removing it is defensible, but it is public backend surface and dropping
it in a minor release belongs in its own change, not buried in a feature
PR.

Restored byte-identical to upstream/main in all three files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nine files whose only change was deleting unused exports, none of which
the scene work touches: asyncLoop, shuffle, setLogger, getSeedValue,
getPresetsPath (plus a setPresetsPath behavior change), stripEnumPrefix,
FACE_PX..FACE_NZ, createEffectDefCallback, setAnimationDuration, and a
pure no-op reorder of the lexer's brace cases.

pipeline.js keeps one line from the scene work — evaluateOscillator is
exported so scene bindings can share the effect oscillator evaluator —
and is otherwise back to upstream.

Removing these is probably right, but it is cleanup riding along in a
feature PR, so it goes in its own change. Unlike the stipple hash fix and
the replaceEffect guard, nothing in the test suite depends on them, so
they revert cleanly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Flags the entire scene surface as experimental and subject to change,
to be finalized in Noisemaker 2.0.

Page titles carry "(Preview)" so the status is visible in the toctree and
nav rather than only once the reader is already on the page. Each of the
three entry points — scene.rst, deferred-rendering.rst, and the Scenes
section of language.rst — gets a note admonition matching the existing
house style in cubemaps.rst.

The grammar block marks SceneCall (preview) with a following paragraph
naming everything reachable from it. ObjectLiteral is called out too: it
was added to serve scene() and is only meaningful there, so it carries the
same provisional status despite being a general expression form.

pipeline-3d.rst says explicitly that volumetric rendering is stable while
the scene graph is not, since that page is where a reader comparing the
two subsystems will land.

Validated with the RST checker used for the original spec commit: heading
underlines and :ref: targets, 12 labels, 15 refs, no problems. Sphinx is
not installed here so the build was not run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ndex

Allowing positional and keyword arguments to interleave — needed so scene()
can carry renderer settings as keywords beside child nodes as positionals —
left the argument binders indexing the dense positional list by parameter
slot. `call.args` holds only positionals; a keyword occupies a slot but not
an entry. So the first keyword shifted every later positional by one and the
trailing one fell off the end, silently:

  blur(0.9, angle: 0.5, 4)  ->  quality = 1   (the 4 was discarded)

Before the relaxation both forms were a hard SyntaxError, so this converted a
loud parse error into wrong render output, in any effect call, in any existing
program. Four sites carried the same defect: the validator's spec loop and the
osc()/midi()/audio() resolvers in the parser. All four now walk positionals
with their own cursor.

Two consequences fixed alongside:

  - Leftover positionals bound to nothing at all. There was no arity check
    anywhere, only an unknown-keyword loop. Excess positionals now raise S001
    rather than vanishing.
  - The hex-colour splat across an r/g/b triple was guarded on `!kw`, which
    was sound only while mixing was illegal. `tint(#ff8000, alpha: 0.5)`
    silently produced white plus a misleading out-of-range diagnostic. The
    guard now tests the colour's own slot, and stands down only when an
    explicit g:/b: keyword would be overwritten.

Tests fail before the fix and pass after: four in test_validator.js covering
both orderings, the arity diagnostic, and the splat; two in test_oscillators.js
covering osc() mixed order with an all-positional control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three ways a mistake in a scene program compiled clean and rendered nothing.

Scene child names were chain elements anywhere. The validator matched all
eleven scene names at any chain position, and the expander turned anything the
scene compiler did not claim into a passthrough. So `noise().camera(fov: 60)`
compiled to a no-op, and — worse — `solid()` with no synth in the search order
stopped being "Unknown effect" and started silently rendering nothing. That is
the ordinary forgot-my-search-order typo, and the branch had removed its
diagnostic. Only scene() is a chain element now; its vocabulary lives inside
the call as AST and never reaches that loop. The other ten report an unknown
effect again, and say why they cannot appear there.

Scene errors reported line 0 col 0, contradicting language.rst's promise of a
line and column. The cause was not what it looked like: substitute() does
rebuild Call nodes, but the parser never attached `loc` to a Call in the first
place, so there was nothing to drop. Calls now carry their name token's
position, and substitute() preserves it across the rebuild.

Unknown keywords on scene nodes were discarded in silence, because
assertKnownKeywords was wired only to the four material terms. `camera(fov: 60,
wobble: 3)` compiled and dropped `wobble`. Every node is now checked against
its own vocabulary: camera, environment, scene settings, lights by light type
(angle/penumbra are spot-only), and mesh shape parameters per mesh type,
mirroring geometry/primitives.js.

Tests fail before and pass after: three in test_validator.js for the chain-
position rule, and two blocks in test_scene_compiler.js for source locations
and per-node keywords. Both demo scenes still compile unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three ways a scene reached the GPU in a state nothing could diagnose.

A scene with no camera() threw once per frame. compileScene left ir.camera
null, SceneTree only builds a CameraNode when it is set, and mesh-renderer
dereferences it before the mesh loop — so it threw even with zero meshes.
Because render() is async the rejection landed in canvas's catch, which logs:
a black canvas plus a console.error inside the render loop, itself a banned
pattern. Every camera keyword already has a default, so an absent camera() is
not an error; it is those defaults. compileScene now fills them in, and
ir.camera is always present.

osc() in a camera or light vector became NaN. buildCamera and buildLight read
their vectors with a bare kw(), skipping the checks buildTransform performs
through vectorKw, so an oscillator descriptor object travelled intact to
uniform3fv and the light position or view matrix turned to NaN — silently, with
the scene simply going black. Both now validate arity and finiteness, and
reject what they cannot animate. Bindings only ever supported osc() on
transform channels and light intensity, so intensity stays permissive and is
sanitized by collectBindings as before.

Mesh shape parameters were copied to the geometry builders unchecked.
mesh("torus", tube: 0) divides by zero and fills the vertex buffer with NaN;
segments: 0 does the same; segments: 100000 asks for roughly 5e9 vertices on
the main thread. Each parameter is now validated against a spec mirroring
geometry/primitives.js — positive extents, integer segment counts capped at
512, vec3 sizes.

Tests fail before and pass after, covering the default camera's documented
values, all four osc()-in-a-vector positions, and five bad mesh parameters with
a valid control. Both demo scenes compile unchanged, and a camera-less scene
now produces a finite view matrix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The scene draws into SCENE_COLOR_TEXTURE and the pipeline's blit is what
carries that texture into a surface, so the ordering is load-bearing. Both call
sites fired SceneRenderer.render() without awaiting it and then ran the
pipeline synchronously — and that render is genuinely async, awaiting shader
compilation whenever the light count changes and awaiting GPU backpressure. So
the pipeline blitted the previous frame's colour: a permanent one-frame lag,
with frame 1 blitting an uninitialized texture. Both call sites carried comments
asserting the opposite ordering.

render() and the render loop now await the scene. render() becoming awaitable
is the part that matters for offline capture and the test harness: a single-shot
render(t) followed by a readback previously never saw the scene contribution for
the frame it had just asked for.

Overlapping renders are also collapsed. Nothing serialized the async render, so
a slow frame could stack passes over the same textures with unbounded pending
promises behind them. An in-flight render is now reused rather than restarted.

Separately, CanvasRenderer.compile() compiled every program twice: once to read
graph._isScene, then again inside createRuntime. That is a full parse, validate,
expand and allocate pass, paid on every interactive edit, for scene and non-scene
programs alike. createRuntime now accepts the already-compiled graph.

Tests fail before and pass after, in a new test_scene_integration.js wired into
the runner: ordering via fakes that record the sequence, render() returning a
promise, one scene render across three overlapping frames, and graph reuse
proved by handing createRuntime a graph alongside source that would not parse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cull mode diverged in both directions. The WebGPU MRT pipeline set no cullMode
at all, so it defaulted to 'none'; WebGL2 enables CULL_FACE and culls back
faces. Every G-buffer fill is MRT, so the entire scene was single-sided on one
backend and double-sided on the other — which changes the normals written to
the G-buffer, and so SSAO, SSR and the lit result downstream. Meanwhile the
single-output 3D pipeline hardcoded cullMode 'back' and ignored pass.cullMode
entirely, so a pass asking for 'none' was honoured on WebGL2 and silently
ignored on WebGPU.

Both WebGPU paths now derive primitive state from one exported helper, and
mesh passes state their cull mode explicitly rather than relying on a default
the two backends spell differently. The helper keeps frontFace 'cw', which is
the subtle part: the WGSL mesh vertex shader flips clip-space Y, reversing
triangle winding in framebuffer space, so 'cw' is the WebGPU spelling of
WebGL2's frontFace(CCW). Setting cullMode 'back' with the default 'ccw' would
have culled exactly the faces meant to be kept. Both pipeline cache keys now
include the cull mode, so pipelines are not cross-reused between modes.

The zero-mesh G-buffer clears were not clears. All three submitted a fullscreen
triangle with scene_present — a single @location(0) program — against
drawBuffers: 4. WebGPU rejects that pipeline at interface matching, taking the
whole command buffer with it. WebGL2 accepts it and writes only attachment 0,
leaving normal, position and depth undefined rather than cleared, so lighting
read garbage. They now clear the four targets directly through clearTexture,
which behaves identically on both backends and matches what the `depth <= 0`
no-hit sentinel expects.

Verified on real GPUs, not just headlessly: all 14 scene cases pass across
WebGL2 and WebGPU, the artistic release gate holds at 401 cross-backend cases,
and tile parity holds at 30. Headless tests cover the draw-buffer/output
invariant, the explicit cull default, and the cull-state mapping.

test_visual_playwright's animation case now awaits render(). It read rotation
synchronously after calling it, which only worked while the scene render was
accidentally synchronous; the assertion is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hments

Three defects on the resize path, all reachable from SceneRenderer.resize(),
which destroys and recreates all nine scene textures on every resize event.

The first draw into each new mesh FBO landed on the canvas. executePass binds
the target FBO, calls ensureDepthBuffer, then draws — but on the creation path
ensureDepthBuffer bound the FBO itself and finished by binding null, without
restoring the caller's binding. So the draw went to the default framebuffer:
a visible flash of raw G-buffer albedo, gl.clear(DEPTH_BUFFER_BIT) clearing the
wrong depth, and the real G-buffer left at clear colour for that frame. The
cached path returns before touching bindings, so this hit the first scene frame
and then once per resize. The binding is now saved and restored.

WebGL2 leaked a depth renderbuffer per FBO per resize. destroyTexture deletes
single-texture and MRT framebuffers but never their depth attachments, and
depthBuffers is keyed by the WebGLFramebuffer object itself — so the storage
leaked and the Map retained an entry keyed by a dead object. Both are now
released with the framebuffer.

WebGPU leaked a depth24plus texture per distinct viewport size. The cache is
keyed "WxH" and had no eviction at all; a slow window drag allocated one per
size and held them until destroy(). It is now bounded, with recency refreshed
on hit. Eviction is deferred behind onSubmittedWorkDone rather than immediate,
because destroying a texture an in-flight command buffer still references is a
validation error — which is why the earlier size-keyed eviction was removed
instead of fixed.

Tests fail before and pass after, driving the real methods through GL and
device stubs: binding restoration on the creation path with a cache-hit control,
renderbuffer release on destroyTexture, cache bounding across 40 resize steps,
reuse of a current size, and that nothing is destroyed until submitted work
completes. All 14 scene cases still pass on both real backends.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
unparse() is a public export and the path every tool takes to read a program
back out — replaceEffect, parameter-override save paths, editors. Scene steps
are emitted with `scene: true` and carry their original AST in args._ast rather
than validated args, because scene calls are interpreted later by the scene
compiler instead of being checked against the effect registry. The unparser
special-cased builtins by `step.builtin` and had no case for them, so they fell
through to the generic path and stringified as:

  _scene.scene(_ast: [object Object])

which does not reparse. Any save-and-reload of a scene program destroyed it.
Object literals had no case either, in scene args or in a let binding, where
`let cfg = {x: 1}` became `let cfg = [object Object]`.

Scene ASTs are now printed directly, which is what the absence of an arg spec
requires: calls with positional and keyword arguments, chains for
mesh().material(), array and object literals, oscillators emitted in the
parser's parameter order so they reparse to the same node, members, colours
back to hex, and strings quoted.

test_scene_parser.js only asserted assert.ok(ast) and never called unparse,
which is how this shipped. The new test_scene_roundtrip.js asserts stability
rather than a fixed string: parse -> unparse -> parse -> unparse, with both
texts required to agree, across a minimal scene, full settings with lights and
meshes, a chained material, an oscillator inside a transform, and an object
literal in a let binding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The project bans per-frame allocation in render loops, and Pipeline builds its
passes once at compile time and iterates them. buildMeshPasses rebuilt
everything on every frame instead — per mesh, per frame: a full JSON.stringify
of the mesh params (on the geometry cache-*hit* path), a mat4.create for the
normal matrix, four template literals for texture names, an array spread for
the base colour, and three object literals. scene-renderer.js's own comment
says render loops must not allocate.

Pass state is now built once per node and rewritten in place, keyed by pass
variant and then weakly by node so a recompile does not pin the old tree. The
geometry cache key is derived once per params object rather than re-serialized
on every hit. Only identity is stable; contents are rewritten each frame.

Reusing the objects is safe because neither backend dirty-checks uniforms by
identity — both re-upload every value each pass. That was verified rather than
assumed; it is the assumption the change rests on.

Two leaks closed alongside. MeshRenderer uploads three textures per distinct
geometry and never evicted them, while SceneRenderer.dispose() released only
ALL_TEXTURES and the probe targets — so editing mesh("sphere", segments: N)
live stranded a set per distinct N for the lifetime of the backend. And
recompiling from a scene program to a non-scene one nulled _sceneRenderer
without disposing it, keeping the same pipeline and backend alive; recompiling
back then created new textures under the same ids, orphaning the old handles
again.

Tests fail before and pass after: pass, uniform, input and normal-matrix
identity held across two frames, and dispose() leaving no global_<meshId>_*
textures behind. All 14 scene cases still pass on both real backends, which is
what actually exercises the in-place mutation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The scene work claimed bit-identical output across WebGL2 and WebGPU, and the
docs stated it as fact. Nothing tested it: every visual case ran one backend at
a time and asserted only that the canvas was not flat and the console was clean.

Measured now, by rendering one fixed scene on both backends at a fixed time and
comparing scene_lit_color: maximum channel delta 35, across roughly 14.5% of
channels. So the claim is not merely unsupported — it is false.

Some drift is inherent. The G-buffer and lighting run in float, each shader is
hand-written twice rather than cross-compiled, and the two compilers reassociate
differently. But 35/255 is well beyond float noise and is an open divergence
rather than an accepted tolerance. The new case gates the delta against a
ceiling so it cannot silently widen while the causes are found, and says so in
its own output rather than reporting a bare OK.

deferred-rendering.rst no longer asserts bit-identical output. It carries the
measurement, names it as open, and points at the gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Making the WebGPU MRT pipeline honour cull mode fixed the scene divergence but
over-reached: it applied a 'back' default to every MRT pipeline, and that path
carries more than mesh passes. Compute-style effects are lowered to fullscreen
render passes with multiple outputs and go through the same code, so they
started being culled away. test_spawnpoint_wgsl_vertical_mirror caught it —
blank frame, variance 0.0000, on WebGPU.

WebGL2 was the reference all along: its cull block sits inside the
`drawMode === 'triangles'` branch, so it never culls points or fullscreen
passes. The WebGPU defaults now match that scoping — 'none' on the MRT path,
'back' on the triangle-mesh path — while an explicit pass.cullMode still wins
on both. Mesh passes state 'back' themselves, so the original divergence stays
fixed.

Caught by running the full invariants suite rather than the scene tests alone,
and confirmed by bisecting against the pre-fix commit, where the test passes.
The helper's tests now cover both fallbacks and explicit override, and the
scene cross-backend delta is unchanged at 35, so the mesh-side fix still holds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rgence

Bisecting the cross-backend delta by reading every intermediate buffer on
both backends localized it precisely: the G-buffer — albedo, normal, and
position targets — is bit-identical between WebGL2 and WebGPU, so the mesh
rasterization path (vertex fetch, Y-flip, culling, depth) was exactly right
all along. The entire divergence lived in SSAO and in how the lighting pass
consumed it.

The underlying convention: on WebGPU the mesh vertex stage negates clip-space
Y, so the G-buffer lands in GL row order — but fullscreen-pass outputs do not
go through that flip, so they are stored vertically mirrored relative to the
G-buffer. ssrWGSL already compensates for this in three places. Two places
missed it:

  - The WGSL lighting shader sampled u_ssao at v_texCoord directly, applying
    occlusion upside down. It now samples at (x, 1-y), with the convention
    documented at the site.
  - The WGSL SSAO noise rotation seeded from @Builtin(position), whose origin
    is top-left, where GLSL uses gl_FragCoord (bottom-left) — so the two
    backends genuinely computed different rotation angles for the same
    surface point. Both now derive the seed from v_texCoord scaled by the
    depth-texture dimensions, which equals gl_FragCoord.xy on both.

Measured effect on the fixed comparison scene: scene_ssao delta 94 over 5.1%
of channels -> 31 over 0.001%; scene_lit_color maxDelta 35 over 14.5% -> 4
over 0.0004% (16 channels out of 4.2M).

The residual is irreducible and understood: the surviving deltas are
quantized to exact kernel steps (pow(1 - 1/12, 1.5), 3-vs-2 occluded
samples), i.e. one SSAO sample crossing the hard occlusion threshold where
the two shader compilers reassociate the reprojection arithmetic
differently. Softening the threshold itself would change the look; the
parity ceiling is tightened 40 -> 6 instead (one threshold flip is worth
~5/255 in this scene's ambient term), and the deferred-rendering note now
records the measurement and the diagnosis in place of the old open warning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven small defects, each fixed test-first.

Spot lights: LightNode defaulted angle to PI/4 radians while its only
consumer treats angle as degrees, so a directly constructed light got a
cos(0.0137)-wide cone. The default is now 45, matching the degree
convention buildLight and _buildLightingUniforms already share.

SceneNode.removeChild left the detached child's world matrix stale — it
still carried the old parent's transform until something else dirtied it.
The child is marked dirty on detach.

Clock: delta went negative once per animation loop, because render() feeds
wrapped timestamps while the render loop feeds raw rAF time. Delta is now
clamped at a backwards jump, with the two time bases documented. normalized
and loopDuration are removed rather than wired up: nothing anywhere read
them, and wiring the canvas's loop duration in at construction would go
stale the moment setLoopDuration() changes it at runtime. Loop position
belongs to the canvas; the class doc now says so.

canvas.js carried a _sceneBackend field that was nulled in the constructor
and destroyed in teardown but never assigned anywhere — deleted.

The two rebaselined hashes in the pond-ripples/plastic-wrap and
wind-halftone tests now carry justifications — and the previously suspected
cause is refuted, not confirmed. The clear-handling change is inert for 2D
effect passes (nothing but the scene renderer sets pass.clear). Running the
unmodified merge-base source in this environment reproduces the NEW hashes,
so the deltas are between ANGLE/driver environments, not between revisions.
The comments record the old hash, the verification method, and the cause.

CameraNode extends SceneNode but builds its view matrix from position and
target directly, so parenting a camera to a moving group silently did
nothing. The limitation is documented on the class and a one-time warning
fires only when a camera actually has a transformed parent — zero cost on
the DSL path, which never parents cameras.

The visual test overwrote four committed reference screenshots on every
run, dirtying the tree. Output now goes to shaders/tests/.artifacts/ (now
gitignored), the committed references stay stable, and two stray
screenshots that matched no naming pattern and were referenced by nothing
are removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four defects that nothing renders through today but each sit one small
change away from a silent failure, plus one found during the divergence
bisection.

compileProgram eagerly built a single-target render pipeline for every
program, including multi-output scene programs whose fragment shaders
declare four outputs. The premise that this produced an uncaptured
validation error was tested and refuted — Dawn permits extra shader
outputs beyond declared targets — so the actual cost was a wasted,
never-used pipeline compile per scene program load. The eager build is now
skipped when the fragment source declares more than one output, counted
entry-point-aware so vertex varyings do not miscount. Single-output
programs keep the eager warm path unchanged.

getBindingStructLayout extracted struct bodies with a regex that stopped
at the first closing brace, so any struct with nesting — or a comment
containing one — failed to parse and fell back silently to the shared
uniform buffer, which cannot represent scene structs: a silent-wrong-render
path. Bodies are now extracted by brace counting over comment-stripped
source, at all three sites that shared the bug, and the fallback warns
once per program naming the struct instead of failing silently.

A cube texture as an MRT target failed differently on each backend, both
silently: WebGL2 rendered every face-write into whichever face was bound
at FBO creation, WebGPU would have attached an invalid cube-dimension view.
Both backends now throw a clear error naming the pass and the limitation.

readPixels returned garbage for single-channel float textures: WebGPU had
no r32float case and reinterpreted the IEEE-754 bytes as unorm, WebGL2
asked for RGBA off a RED-format texture. Both now decode the single
channel and expand it to greyscale in the top-down RGBA8 contract shape,
byte-identical between backends. The GPU path is verified indirectly (the
visual suite proves the surrounding render is unchanged); the headless
tests pin the decode, the format selection, and the row flip.

Four new test files, registered in the runner; each watched failing before
its fix. Full sweep green including 15/15 on both real backends.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An adversarial review of the fix layer found that several of its fixes
treated the case they were tested against rather than the class. Closed
here, each test-first.

The scene printer could still corrupt source, two ways. formatNumber
emitted exponent notation for magnitudes outside [1e-6, 1e21), which the
number lexer cannot read, so pos: [0, 0.0000001, 0] round-tripped to text
that failed to reparse; numbers now expand to plain decimal exactly. And
the printer's default case silently deleted any node it did not know —
intensity: audio(0) became "intensity: )" with zero diagnostics. Audio,
Midi and the remaining ref types now print properly, and the default case
throws naming the node type: a loud unparse failure beats quiet
destruction of user source. Strings also re-escaped on every round trip
(the lexer stores the raw slice; JSON.stringify doubled the backslashes)
— now emitted raw. Let bindings survive too: formatSceneAst honours
_varRef, and formatLetExpr needed the same exponent and ArrayLiteral
repairs or the preserved name would point at an unparseable line.

The loc fix had missed its targets. Call nodes carried positions, but
nearly every scene diagnostic anchors to a value node — Number, String,
Boolean, Color, Object — which had none, so "Unknown keyword 'fow' for
camera()" still said line 0 col 0. Value literals now carry their token's
position, const-folded arithmetic keeps one, and resolveCall's partial-
application branch no longer drops it. The diagnostic location field also
read node.loc.column where the parser writes col, rendering "col
undefined" in the demo error banner.

The hex-splat stand-down mangled the colour it protected: with an
explicit g:, the Color node was consumed into the r slot, rejected by the
float path, and clamped to 1 with a bogus out-of-range diagnostic. The
splat now fills exactly the slots no keyword claims — tint(#804020,
g: 0.25) yields the hex r and b with the keyword g, clean.

Two validation gaps of the same species the fix layer targeted: group()
keywords were never checked (idd: silently dropped) and mesh() ignored
positionals after the type string. Both are errors now. And scene() was
accepted mid-chain, silently discarding its input surface — it is a
generator and must start a chain, which also catches scene().scene()
early.

Docs are back in step: the closed per-primitive keyword sets, the
tightened name resolution (only scene() falls through), the optional
camera and its defaults, and the precise positional-binding rule — a
positional displaced by a keyword shifts to the next unfilled slot,
deliberately; that rule is now stated rather than implied. The two
scene-name tests pin the shipped message so a worthless generic
diagnostic cannot satisfy them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…view

The async-scene fixes shipped with three lifecycle gaps, closed here
test-first.

The in-flight reuse path returned the raw promise: the originating frame
caught a scene-render failure, but any frame that reused the pending
render awaited an unguarded rejection — an unhandled rejection that
skipped the pipeline, the frame callback, and onError. _renderScene now
stores the guarded promise (catch, log, onError, clear — via .finally,
which is spec-guaranteed asynchronous), so it never rejects regardless of
which frame awaits it.

A long scene render — a light-count shader recompile spans tens to
hundreds of ms — stalled the entire canvas, because every rAF frame
awaited the same pending promise before running the pipeline. The loop
now awaits only a render it starts itself; when one is already in flight
it presents the last completed scene texture, which is the old behaviour
in exactly the situation the old behaviour was right about. Single-shot
render() keeps strict ordering. And the pending field survived teardown:
a disposed scene's in-flight promise could serve the first frame of a
newly compiled one. It is cleared on dispose and on both recompile paths.

Depth-texture retirement was batched behind the first
onSubmittedWorkDone(), whose coverage ends at that moment's submits — a
texture appended later could be destroyed while a subsequent command
buffer still referenced it. Retirement is now one promise per eviction
(evictions only happen on resize drags), and destroy() drains the pending
list so a deferred callback cannot touch a destroyed device.

Guards for the latent paths: createLegacyBindGroup names the program and
the reason instead of a null dereference when a multi-output program has
no eager pipeline; clearGBuffer documents the WebGPU immediate-submit
ordering constraint; resolveMRTRenderPipeline notes the WebGL2 triangles-
branch asymmetry a future MRT mesh pass would trip over; the depth-buffer
resize branch unbinds its renderbuffer; the RED readback states its
IMPLEMENTATION_COLOR_READ_FORMAT assumption.

Test quality: the cross-backend parity gate gains --cross-backend-only so
the ceiling is maintainable without the full visual run; litColorFor
resets the scene clock, removing a latent flake the moment any scene pass
takes a time uniform; the tautological r32float sizing assertion is
replaced with a width where the formats genuinely differ, and the
r16float decode path gets its first coverage; the runner's comment groups
match their contents again, 92 entries before and after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The scene mesh path re-implemented obj-parser's vertex-texture packing
near line-for-line — same ceil(sqrt) grid, same w=1 valid-vertex flag,
same zero padding — feeding the same backend.uploadMeshData. And two
in-memory mesh shapes coexisted: obj-parser's de-indexed arrays and the
indexed Geometry the primitives produce, which mesh-renderer then
de-indexed again per geometry.

Geometry is now the canonical shape and owns the only packing path:
meshTextureSize for the grid layout, deindex() for index expansion, and
toPackedTextures() with obj-parser's shipped semantics verbatim,
including the truncation warning for the fixed-grid OBJ path. parseOBJ
returns a Geometry (identity indices — OBJ indexes per attribute, so
re-indexing is not worth its complexity), mesh-renderer packs through the
shared path, and the duplicate implementation is deleted. Dependency
direction stays clean: geometry/ imports nothing outside itself.

The two implementations were probed for divergence before unification and
found byte-identical, so this changes no bytes on the GPU: uploads for
every primitive and the OBJ path were diffed element-by-element against
the pre-change code. The intentional behavioural split is preserved and
now stated in one place — OBJ callers fix a 256x256 grid and may
truncate; the scene path sizes the grid to fit and never does.

Verified: full non-parity sweep, runtime suite, and 15/15 GPU cases with
cross-backend parity unchanged at maxDelta 4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sphere, box, torus and cylinder were generated twice — a build-time Node
CLI (share/meshes/generate.cjs) emitting the committed OBJ assets, and
the runtime primitives.js feeding scene() meshes — by two algorithms with
two tessellations. One concept, one way: primitives.js gains the three
shapes only the CLI had (cone; capsule, whose height now means total
extent to match createCylinder and whose mid-band is a true cylinder wall;
icosphere via midpoint-cached subdivision), and generate.cjs is reduced to
a serializer — CLI args, OBJ text emission, file writing, zero geometry.
The scene DSL's mesh vocabulary is deliberately unchanged; the generators
exist, the language decision is separate.

The committed assets are regenerated, which the consumer inventory
justifies: they feed only the demo meshLoader dropdown, no test pins
their content, and the bundlers copy them verbatim. Regeneration also
fixed a latent bug it was impossible not to notice: obj-parser reverses
face order on read (OBJ CW to GL CCW), and the old generator was
self-inconsistent — cube and icosphere were authored CCW, so every one
of their triangles reached the GPU wound backwards and rendered
inside-out. All seven assets now parse with zero inverted triangles, and
an A/B through the real demo path shows cube and icosphere brighter and
higher-contrast (front faces now survive culling) with the others
statistically identical apart from the documented torus orientation and
capsule band changes.

A new generator round-trip test spawns the CLI, parses its output back
through parseOBJ, asserts winding, bounds, unit normals and triangle
counts against primitives.js, and digest-checks the committed assets —
a stale asset now fails the suite instead of drifting silently. The
icosphere port also caught its own bug test-first: a numeric midpoint
cache key collided as the vertex count grew, averaging antipodal vertices
to zero-length normals; the key is now a string.

Verified: full non-parity sweep, geometry invariants, generator
round-trip, and 15/15 GPU cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
aayars and others added 17 commits August 28, 2026 00:16
Phase 1a of the volumetric unification: the language surface and IR. A
volume() scene child takes one of the pipeline's eight volume surfaces —
the precedent is environment(oN), a surface where mesh() takes a primitive
name — plus threshold and the standard transform keywords. It compiles to
an ir.nodes entry of type 'volume' beside meshes and groups, builds a
VolumeNode in the scene tree with world matrices, group parenting and
osc() bindings working unchanged, and interns materials exactly as mesh()
does. surface() materials are rejected with a diagnostic, directly and
when inherited from a group: a raymarched isosurface has no UVs.

Diagnostics carry real positions throughout — missing or wrong-kind
positionals, out-of-range volN, second positionals, unknown keywords,
malformed thresholds and transforms all name volume() and a line/col.
VolRef parser nodes carry no loc, so errors about the positional anchor
to the call rather than reporting line 0. volume() at chain position gets
the same only-valid-inside-scene() S001 as the other children.

Round-trip is semantic, not just textual: the approved example parses,
unparses, reparses, and compileScene of the reparse deep-equals the
original IR. The docs gain a Volumes section (keyword table, error
strings, the previous-frame atlas contract, and the extent, resolution
and reflection notes for the renderer), and the grammar gains VolumeCall
in both rst files, marked preview.

The renderer half lands separately; with this commit alone a
volume-bearing scene compiles, builds its tree, and renders nothing new.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 1b: the renderer half. Each volume node rasterizes its bounding box
— the shared box geometry through the existing cache — as an MRT
triangles pass with cullMode 'front', so the pass gets a real depth
attachment on both backends and hardware depth testing composites volumes
against meshes for free; a fullscreen inverse-VP march would have had no
depth test on either backend. The vertex stage is the mesh vertex shader
verbatim; the new fragment pair derives the ray from the interpolated
world position, slab-tests the local unit box, marches the 64-cube atlas
with the established Z-slice-lerp sampling, bisects the threshold
crossing, and writes all four G-buffer targets plus fragment depth as
clamp(clip.z/clip.w * 0.5 + 0.5, 1e-6, 1) — the hit's depth, not the
box's, and the exact expression SSR reconstructs. A miss discards, so the
box leaves no trace. Lighting, SSAO, SSR and the probe consume the
G-buffer unchanged — that is the point of the unification.

One deviation from the design, and it is load-bearing: the design gave
volume passes their own pass ids, but the WebGL2 backend keys its MRT
framebuffer cache on pass id plus outputs, and the depth renderbuffer
hangs off the framebuffer — so mesh and volume passes writing the same
four targets under different ids got two framebuffers with two
independent depth buffers, and the volume tested against depth nothing
had written. WebGPU keys depth by size alone, so the backends disagreed
silently: exactly zero volume pixels on WebGL2 whenever a mesh was
present. Both renderers now share one G-buffer pass id, a regression test
pins the invariant, and the backend-side question — whether pass id
belongs in that cache key at all — is flagged as a known trap rather than
changed here.

The G-buffer output of the new shader pair is near bit-identical across
backends: maxDelta 0 on albedo, position and depth, 1 on 0.00005% of the
normal buffer. The lit result gates at maxDelta 7 (ceiling 8) — the
residual is the same hard-threshold SSAO quantization the mesh parity
case documents, propagating into ambient. Depth compositing is asserted
identical on both backends, buried and raised. The mesh parity case is
untouched at maxDelta 4, ceiling 6.

Phase 1 limits, stated in code and docs: volumes do not yet appear in
planar reflections or the probe (those pass lists are meshes only), and
a volume's body space is the local [-1,1] cube, so scale 1 spans two
world units where a unit box spans one.

Verified: full non-parity sweep, runtime suite, and 18/18 GPU cases
including the three new volume cases on both backends.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reference-valued keywords reported line 0 col 0. The located() helper was
added for exactly this and used in one place; the anchor path now routes
through it everywhere — litValue carries a fallback into array elements
and object properties, and every keyword, transform, material, mesh,
camera, light, environment and settings error anchors to the offending
node or, when the node is a loc-less reference, to its call. Twelve cases
pinned, five on volume() and seven on the pre-existing sibling paths the
same class reached.

Unknown chain links no longer vanish. walkNode filtered links down to
material() and reflector() and dropped the rest, so
mesh("sphere").pos([0,0,-4]) compiled clean and left the node at the
origin — a trap that cost a reviewer a probe iteration. Any other link is
now an error naming the link, the node kind, and what is allowed.

reflector() on a volume was rejected for being the wrong kind of mesh.
The diagnostic now says planar reflection is not supported on volume()
nodes, the group case likewise, and the plane requirement stays for
meshes.

The VolumeCall grammar production claimed an ordering and a token kind
the parser does not enforce: let-bound references, keyword-first calls
and trailing commas all parse. Both grammars now say ArgList with the
real constraint in prose, phrased off Write3DCall's VolRef-or-Ident.

The round-trip test compared the compiler against itself, so a bug that
dropped volume nodes from both parses stayed green — demonstrated by
sabotage. The source parse is now pinned structurally (node type,
surface, threshold, material values) before the reparse comparison, so
the self-comparison has an external anchor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A volume hit inside the near plane destroyed the frame. The depth
expression clamped clip.z/clip.w to the front of the depth range, so a
body enclosing the camera with a pushed-out near plane won the depth test
against everything while publishing a depth every downstream pass reads
as the no-hit sentinel: the revert probe measured the entire frame as sky
on both backends — worse than the review described. Both shader languages
now discard when clip.w <= 0 or clip.z < -clip.w, the clip-space
near-plane test, before any write. The same expression also closes the
0/0 NaN reachable with the camera inside the isosurface. A GPU case pins
the fixed behaviour with a near-plane control on both backends.

A producer at any volumeSize other than x64 was decoded at the wrong
slice stride, silently — the write3d blit stretches the source atlas in
2D and the marcher reads a sheared volume that renders plausibly wrong.
compileGraph now correlates each volume() surface back through the blit
to its producing chain's resolved volumeSize and fails compilation with a
message naming the surface, the producer's size, and the fix. The runtime
hole is stated in a comment rather than papered over: a slider dragging
volumeSize after compilation bypasses the check, and closing that lives
with the canvas parameter-update path, not here.

mat4.invert's null return was unchecked in both renderers; for a volume
the inverse is the ray transform, and on later frames the buffer held the
previous frame's value. A shared invertOrIdentity falls back to identity
and warns once per node, naming the degenerate scale. The test first
passed vacuously against a fresh buffer — identity is invert's untouched
state — and was rewritten to run a healthy anisotropic frame first so the
staleness is visible.

The atlas sampling convention gains a numeric gate. The briefed
silhouette-centroid check measurably cannot catch a uvw*(volSize-1) to
uvw*volSize regression — the body box clips the change and the centroid
moves under a pixel — so the gate pins an analytic symmetry instead: an
on-axis sphere field must produce x/y statistics equal to the last bit,
and under the regression the diagonal splits by two orders of magnitude
over the measured floor. Verified failing under the regression, passing
after restore. Two frozen empty material objects also replace the last
per-frame literals on the volume and mesh no-material paths.

GPU suite: 20 cases on both backends; volume parity unchanged at
maxDelta 7 ceiling 8, mesh parity at 4 ceiling 6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every primitive generator now clamps its subdivision inputs — segments
floor 3 (the minimum closed cross-section, matching the scene compiler's
spec), rings floor 1, subdivisions floor 0 — closing NaN holes the review
found in cone and capsule and four more of the same class found by
sweeping the rest, plus a zero-length normalize in createTorus at
tube: 0 that produced 1683 NaN normals. The committed assets are
untouched: all recipe parameters sit above the floors and the tube > 0
path is byte-identical, which the digest test confirms.

The Geometry constructor no longer mints NaN. Missing uvs default to
zeros — the behaviour the deleted _expandIndices had and the rewrite
dropped — and meshTextureSize(0) returns a 1x1 grid whose single zero
texel reads back as an invalid vertex instead of {0, NaN} silently
uploaded.

The generator test's recipe table is now load-bearing. Only the index
count was ever read from it, so a reviewer changed the torus radii,
regenerated, and everything passed. Parsed positions, normals and uvs are
now compared elementwise against the recipe's own geometry at the
measured serialization tolerance; recipe drift fails against the
committed assets.

Docs: README-SHADERS records the three deliberate asset differences —
the cube and icosphere previously rendering inside-out (12/12 and 320/320
triangles wound against the parser's face-order reversal, re-verified
against the old assets), the sphere/capsule x-mirror from the normal
formula sign, and the torus orientation. The visual suite's gate claim is
now true: npm run test:shaders:visual runs it, deliberately outside the
headless test:shaders chain, and deferred-rendering.rst says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ecompile

The compile-time atlas-size check had a documented hole: a program
compiled at x64 could be dragged to x32 by a UI slider, reaching the
sheared-decode state the check exists to prevent, with no signal. The
correlation (volume surface -> blit -> producer chain) is now computed
once at compile and carried on the graph keyed by the uniform scope a UI
update actually writes, and every in-place update path — the two canvas
parameter APIs, the chain-scoped broadcast that ProgramState funnels
through, and Pipeline.setUniform including its unscoped fan-out — refuses
a non-64 write to a marched chain with a warn-once, leaving the uniform
untouched. Sibling chains writing unmarched surfaces still resize freely.
Stated rather than papered over: an automation-bound volumeSize never was
a number at compile time and remains outside both halves of the check,
and the upstream device clamp still applies first at graph load — a
device too small for the 64-cube atlas cannot render a scene volume
either way; pin-wins ordering on the runtime paths is documented at the
site.

recompile() also stops paying the double parse. The first-compile path
reused the already-compiled graph; the live-edit path re-parsed the
source inside recompile regardless. It now accepts the graph the caller
already built, semantics otherwise untouched — the post-swap sequence is
pinned by test so the pipeline-state reuse cannot silently degrade, and
both real-GL recompile tests pass unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 4 of the unification. buildVolumePasses gains the outputs, passId
and clipPlane options its two new call sites need, with per-variant pass
state keyed the way the mesh renderer's is — the same node now renders
in the main view, the mirrored view, and six probe faces per frame, and
one shared state object would leak the last variant's camera into every
earlier one. Each variant's volume passes share that variant's pass id
with its mesh passes, the invariant the WebGL2 framebuffer cache makes
load-bearing; the ids are hoisted and renamed gbuf, since "mesh" in a
shared id was actively misleading. Zero-content clears are joint over
meshes and volumes in all three groups.

The mirrored view clips the marched hit, not the bounding box: the
volume fragment discards when the hit lands behind the reflector's
plane, same expression and epsilon as the mesh shaders, placed before
the normal's six field samples. Cull mode stays 'front' in every
variant, and that is derived rather than assumed — the reflection view
is built by lookAt from a reflected eye, an orthonormal frame with
measured determinant +1, so no orientation flip ever reaches the
rasterizer; the planar mesh passes' 'none' exists for single-sided plane
geometry, a problem a closed box does not have. The speculative cullMode
option was refused accordingly: every call site wants the same value.

GPU evidence, identical on both backends: the mirrored volume's
reflected pixel count equals its mirrored-view count exactly, as a flat
reflector predicts; a volume below the floor appears in the mirrored
G-buffer only when the clip plane is dropped, which discriminates the
clip from the pass wiring; and a volume behind the camera — absent from
the main G-buffer and beyond SSR's reach — appears in a metal sphere
via the probe alone. 23 GPU cases, parity gates unchanged at 4/6 mesh
and 7/8 volume.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
volume() gains mode: "smooth" | "voxel" — the isosurface remains the
default; voxel is the 3D-DDA traversal the legacy render3d carries,
adapted to the G-buffer fill contract. Selection is a per-node int
uniform on the one program, the convention this shader pair and the
legacy family already use for runtime behaviour, and the only one
available here: render3d's compile-time define is injected by the
expander, machinery the scene renderer does not have, and two volumes in
one scene can want different algorithms.

Two derivations recorded at the sites. The cell wall is computed from
the boundary directly rather than render3d's voxelToWorld(voxel + step),
which lands half a cell past the wall on every axis — invisible in a
shader that only shades, wrong here where the hit becomes the G-buffer
world position and the hardware depth. And the face normal goes to world
through the inverse-transpose like any normal: for an axis-aligned face
under plain T·R·S the naive path happens to agree, but a volume parented
to a non-uniformly scaled rotated group has a sheared world matrix where
the two genuinely diverge. Face shading is deliberately not baked into
albedo — lighting belongs to the deferred pass.

The blocky signature is gated numerically, not by eye: mean horizontal
run length of identical encoded normals measures 1.04px smooth vs
23.45px voxel — a 22x separation, identical to the last digit on both
backends — gated at 4x with a distinct-normal ceiling. Voxel
cross-backend parity measures maxDelta 6 over 0.0003% (ceiling 7), lower
than smooth for a structural reason: no bisection and no gradient, so
the only float sensitivity is the DDA's min-of-three. Depth compositing
re-proven buried and raised. 26 GPU cases; existing gates unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phases 0-1 of the legacy re-basing. The twelve marcher functions that
render3d and renderCubemap3d carried as verbatim duplicates — in both
languages, ~340 GLSL and ~332 WGSL lines — now live once, in
marcher-fragments.js, and the committed effect shaders are emitted from
them by generate-marcher-shaders.mjs. The extraction was proved before
it was performed: a throwaway slicer asserted the fragments rejoin to
the committed sources character-for-character, so the emitter reproduced
all four files byte-identically on its first run. The only difference
that ships is a five-line generation note inside each leading comment.

Unchanged output is enforced twice. A registered test runs the emitter
into a temp directory and byte-compares against the committed files —
fragment drift that would change a legacy effect fails the suite, and
the gate is proven non-vacuous by asserting the shared body appears in
at least two files per language and that removing it changes the output.
And pin-effect-pixels.mjs pins each effect's rendered output per backend
against its own prior self: twelve of twelve pins identical across both
effects, both marching modes, and renderLit3d and renderCubemapSurface
besides. The pins also document, case by case, that the two backends
legitimately disagree on these effects — the fragcoord-origin divergence
— which is why the gate is per-backend and why no cross-backend
attestation is fabricated here.

analyticalGeo: "inputGeo" is removed from all four legacy definitions:
declared as a pass input, referenced by zero shaders, zero occurrences
repo-wide afterwards, output-neutral by the same pins. The manifest and
string catalogs regenerate to no diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI verifies that any dual-language effect changed since the PR base
carries a parity case and attestation at epsilon 0. This branch changed
five: filter/stipple and the four legacy volumetric renderers. All five
are now attested — and the interesting part is what did not need to
happen first.

The working theory, written into the pin tool's own header and an
earlier design document, was that the render family legitimately
diverges cross-backend on the fragment-coordinate origin and could never
attest at epsilon 0. Measured before any fix was attempted: maxDiff 0,
mean 0, zero mismatched pixels, on all four effects, both marching
modes' shared isosurface path, at 192x192. Confirmed against the cheap
counter-explanation with a side-by-side capture written in display
order: the two backends' frames are pixel-identical and the same way
up. The origin difference is real at the builtin level but is reconciled
by the WebGPU surface path's internal Y-flip before it reaches pixels;
the pin tool's per-backend hashes differ only because the two readbacks
return opposite row orders. Its header now says that instead of the
refuted claim. Zero shader sources were edited — applying the planned
"fix" would have introduced the exact mirror it was meant to remove.

stipple attests cleanly because this branch fixed its jitter hash to be
bit-identical months of drift ago. Cases follow the pixelSort precedent:
minimal deterministic DSL, epsilon 0, colour-variation guard. The gate
is green in all three modes it runs under — dirty tree, CI semantics
with the PR base, and the tag-time full sweep. Known gap, stated: the
voxel compile-time define is a second program the one-case-per-effect
schema does not exercise; both filtering modes remain covered by the
per-backend pins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Scene bindings supported one of the three descriptor kinds the 2D
pipeline evaluates, so MIDI and audio-reactive scenes were not
expressible. The compiler's oscillator-only readers are generalized into
one descriptor family — canonicalMidi and canonicalAudio mirror the 2D
validator's defaults exactly, so a given midi() means the same thing on
a scene transform as on an effect uniform — and the symmetry is pinned
by an invariant test across nine channels and three kinds: the
descriptors are accepted together or rejected together, everywhere.
Object-literal impostors are rejected for all three, and the shared
isAnimationDescriptor predicate keeps the compiler and the per-frame
evaluator structurally unable to disagree.

Evaluation dispatches to the pipeline's own evaluateMidi and
evaluateAudio (export-only changes there) against the pipeline's own
externalState record — the same object setMidiState/setAudioState write
into, passed by reference, so scene bindings can never read stale state
a copy would hold. Absent state falls back to each descriptor's min,
the pipeline's contract for an unconnected input and the same value the
t=0 sanitization uses, so there is no NaN path. One Date.now() per frame
keeps MIDI trigger falloffs coherent within a scene.

Round-trip covers midi() and audio() in transforms to identical IR, and
the docs replace every "only osc()" with the three-descriptor truth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Hi-res exports tile through setTileOffset, which every 2D and volumetric
pass honours and the scene path ignored entirely. Scenes now tile by
construction rather than by shader edits: the camera's projection is
sliced to the tile's rectangle — perspective is by definition a
symmetric frustum, so a tile owning a pixel rectangle owns the same
fraction of the near plane, and an off-centre frustum over that slice
renders exactly that tile. Everything downstream follows the matrices:
mesh and volume fills, SSAO and SSR reprojection, and the mirrored
planar camera, which inherits the same sub-frustum. The probe never
tiles — it is a world-space cubemap. The untiled path keeps calling
mat4.perspective, and a degenerate whole-image region is treated as
untiled, so the byte-identical regression pin holds to the ulp.

Measured on both backends, a 2x2 stitch against the untiled render:
geometry, volume marching, deferred lighting and tonemap are exact —
maxDelta 1 on 0.04% of pixels with no seam structure. The residuals are
SSAO's alone and are quantified rather than hidden: a genuine occluder
seam confined to its kernel's projected reach (~32px here, exactly as
far as SSAO can see), and a per-tile dither phase shift from the
tile-local fragment coordinate feeding the kernel rotation hash — the
standard tile-offset fix for that lives in the shared G-buffer shaders
and is left recorded, not silently tolerated. Local SSR is not tileable
in principle (rays leaving the tile find nothing); the planar reflector
path is tile-exact and the docs steer tiled exports to it. The gate
follows the repo's tiling precedent: tight interior, quantified band.

Scene bindings also receive the pipeline's externalState at the one call
site the MIDI/audio work needed threaded. 28 GPU cases; all parity
ceilings unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ppears

Phases 3 and 5 of the re-basing. renderCubemapSurface joins render3d and
renderCubemap3d under the emitter — its shared set is the atlas sampling
core, extracted with the same prove-the-slice-first method — and
volume-shaders.js now assembles the scene marcher from the same shared
fragments where the text is genuinely common: atlas sampling, the slab
test, the isosurface march and bisection, and the gradient normal. What
stays bespoke in the scene variant stays for stated reasons at the
existing site comments: the G-buffer packaging, the clip and near-plane
tail, and the voxel DDA's wall-boundary computation, which deliberately
does not inherit render3d's half-cell error. renderLit3d stays outside
the emitter per the design's measurement — roughly fifty shared lines
against a third getField variant to carry — recorded in the emitter's
header so the decision is visible where a maintainer would look.

The gates that make this safe all re-ran clean: the emitter byte-compare
test (committed files are exactly what the fragments emit), the
per-backend pixel pins, the epsilon-0 attestations re-written for the
effects whose source hashes moved — each re-attestation run re-proving
the two backends still agree exactly — and the three scene parity
ceilings re-measured at their prior values: mesh 4 of 6, smooth volume
7 of 8, voxel 6 of 7. The verbatim duplication between the legacy
family and the scene marcher is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Scene programs export cubemaps through the same renderCubemap() API as a
cubemap-renderer program: six faces through cube-face cameras at the
scene camera's position, fov 90, square, the full pass stack per face,
faces returned in GL order under the 2D path's orientation contract —
derived from the marcher math and pinned by unprojecting the actual
view-projection at ten points per face. All six faces are one instant:
scene state advances once per export. The reflection probe is captured
for the first face and frozen for the rest — a world-space cubemap does
not change between export faces — with the live amortization state saved
and restored, pinned against the unmodified probe behaviour. The
non-scene path is regression-pinned byte-identical. One pre-existing
finding recorded in the docs rather than papered over: surface readback
row order is backend-relative, identically for the 2D and scene paths.

The glTF loader stops being an orphan: loadGLTFFromURL and
loadGLTFFromString sit beside the OBJ pair on the same packing and cache
path, multi-primitive files merge into the one Geometry a mesh surface
holds, normalized accessors rescale (the 65535-means-1.0 exporter case),
bufferView-less accessors read as zeros per spec, and the module header
states honestly what remains unsupported.

SSAO's tiled-export dither is fixed at the seed: the kernel rotation now
hashes the fragment coordinate plus the tile's pixel offset, so tiles
share one dither phase. Untiled output is byte-identical — all three
cross-backend ceilings unchanged — and the tiled interior residual
measured 32 down to 12 of 255 on both backends, so the interior gate
tightens from 48 to 16 to pin the fix.

The rest of the closure list: llms-full.txt covers scene() and volume()
in its own four-capability voice with verbatim-verified error strings;
the scene viewer is linked from the demo and shipped by the site
bundler with its paths transformed like the main demo page; language.rst
stops documenting a meshRender parameter that does not exist;
midi-audio.rst points at scene animation; and the cubemap, scene and
3D-pipeline docs carry the new export route.

30 GPU cases across both backends, all suites green, both attestation
modes green including CI semantics against the PR base.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review found the pin infrastructure inert: a tool and six cases with no
npm script, no committed baselines, and no way for anyone else to run
--check — so the marcher single-sourcing presented a one-shot manual
verification as a standing gate, and the legacy voxel branch, the one
path the one-case-per-effect attestation schema cannot exercise, had no
auditable pixel evidence at all. Baselines for all six cases are now
committed and npm run test:shaders:pins checks every case against them —
per backend against its own prior self, never cross-backend, which is
the attestations' job. The renderCubemap3d pair also stops confounding
two variables: both cases sit at the threshold where voxel mode has
structure, varying only the filtering define.

The two docblocks that still described the pre-fix SSAO dither behaviour
— one of which quoted a figure that would now fail the tightened gate —
are corrected to the measured post-fix numbers, a sentence whose
arithmetic did not survive inspection is restated with numbers that do,
an export nothing imported is made internal, and a test artifact PNG
that had been force-added past the gitignore is untracked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review refuted the MIDI/audio parity claim with a table: the same
descriptor text meant different things as an effect uniform and as a
scene value, both ways. The 2D validator silently dropped bare-number
enum arguments — audio(2) compiled to the low band with no diagnostic,
the identical silent-drop class this branch fixed for effect arguments —
while the scene compiler honored them; and the bare-identifier spelling
that works on effect uniforms, audio(band: high), was a hard error in a
scene. Both sides now accept Number, Member and Ident through one shared
rule in descriptorEnums.js, each caller supplying its own resolver so
the enum tables stay where they belong. The proof is the test the
invariant suite lacked: a cross-system oracle compiling each of eleven
descriptor spellings through both paths and comparing the canonical
objects field-by-field — eight of eleven rows disagreed before, none
after. The 2D behaviour change is justified the way the arg-binding fix
was: intent that was being silently discarded is now honored, no
checked-in program anywhere used the affected spelling, and the pinned
2D integration suite passes with zero expectation changes. The residual
asymmetries — unresolvable members default silently in 2D and throw in
scenes, and the validator's resolver also sees program symbols — are
measured and documented, not hidden.

Two runtime repairs alongside. The volumeSize guard's warn-once memo
survived graph swaps, so after any recompile the refusal — whose whole
point is not being silent — stopped warning; the memo now resets at the
one place the graph is swapped, with the post-swap sequence test
extended so the call cannot be dropped. And the recompile-failure
replacement path built its pipeline without replaying MIDI/audio state —
newly load-bearing, since scene bindings read the pipeline's state
record, so every midi()/audio() scene binding silently pinned at its
minimum after that path — and re-parsed the source a third time. It now
replays both states and reuses the compiled graph.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review found the export path had escaped the concurrency discipline the
render loop earned earlier: it neither awaited nor set the in-flight
guard, so an export could run concurrently with a stalled loop render —
two renders bracketing one shared WebGPU command encoder, mutating the
same reused pass state, racing a resize — a reentrant export clobbered
the saved probe amortization state permanently, and a dispose landing
mid-export threw from the cleanup itself. The export now drains any
in-flight render, holds the same guarded never-rejecting promise the
loop respects for its whole duration, rejects reentrancy with a stated
reason, and captures every object it needs at start so teardown can no
longer null them out from under it; a teardown during the drain returns
the empty export, the same answer as having no scene.

The six faces are also now genuinely one instant. The scene render ran
before the pipeline's own render each face, so face 0 marched whatever
the volume atlas held from the last pre-export frame while faces 1-5
read the export instant. One priming render now runs inside
renderCubemap after the resize and before the face loop, gated to the
scene path — after the resize so everything a face reads is produced at
the size it reads it at, gated because a 2D graph's passes are the face.
Stated, not hidden: stateful simulation chains advance one generation
per render call regardless of time, so their six faces remain six
consecutive generations; fixing that needs a replay the pipeline does
not have.

The voxel DDA gains the standard zero-component guard in both languages
— an exactly axis-aligned ray poisoned two tMax lanes with -Inf, and
NaN comparisons kept the loop from ever exiting: a false miss after 512
wasted fetches, reachable by construction at the face centres of
odd-sized exports. The guard is componentwise selection, not a blend,
because Inf times zero is the NaN being guarded against — and the
review's own claim needed correcting: only four of the six axis rays
were lost; the Z pair survived by accident of branch order, which the
transcription test now pins as an accident. The drift gate also stops
taking the correspondence table on faith: the shared function bodies
are extracted from both the assembled scene shader and the fragments
and compared with the documented deltas undone from the fragment's own
text — the transposed-atlas sabotage that previously passed everything
now fails by name. And the export clears the 2D tile state around the
faces and restores size in a finally, so a throwing face no longer
strands the pipeline at export resolution.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
aayars and others added 4 commits September 3, 2026 10:15
Regressions to existing 2D behaviour introduced on this branch:
- render() and the render loop had become asynchronous for every program,
  deferring each 2D draw past a microtask. The await is now gated on scene
  programs, and the frame-timing API (getFrameTimeStats, lastRenderTime,
  resetFrameTimeStats) that downstream apps read is restored from main.
- The deployed scene viewer imported ../lib/demo-ui.js, whose sibling
  modules the site bundler never copies. copySceneDemos now rewrites it to
  the bundled core and fails the build if any source-tree path survives.
- js/bin/noisemaker-js reported 1.0.

Scene defects:
- The GLSL environment lookup mapped the bottom of the 2D program to the
  sky while WGSL mapped the top. GLSL now matches WGSL; measured
  byte-identical on both backends with the image top overhead.
- Comments inside argument lists were a syntax error; the parser skips them.
- group() silently dropped camera/light/environment children, bare
  positionals in scene() were ignored, and descriptor min/max outside 0..1
  were clamped without a word. All are located errors now, and every
  "Unknown ..." error lists the accepted set.
- Camera and light vectors rejected descriptors with a misleading message.
- node.position[0] = x read back correctly but never re-rendered; world
  matrices now detect component and ancestor changes by value.
- A same-backend recompile never released cached mesh geometry.
- scene() settings, light and environment intensity reached the renderer
  unchecked, and a second environment() replaced the first silently.
- The scene render path allocated ~30 typed arrays and ~36 objects per
  frame. Pass descriptors, frame state and camera matrices are built once
  and rewritten in place; the pass stream is byte-identical across nine
  scenes before and after.
- The scene G-buffer bypassed the colour-attachment byte budget.
  initialize() now throws a readable error when the device cannot host it,
  and the pipeline's byte table scores r32f as 4 bytes.
- Re-emitted scenes came back as one line; block calls now emit one
  argument per line.

Pre-existing bugs found on the way: let bindings inside midi()/audio()
never resolved; a hex colour with an r: keyword lost g and b; marchingCubes
emitted inverted winding; the texture-pool liveness analysis never resolved
the expander's per-node input aliases, so test_phase2_integration.js could
not pass. It passes now and is wired into the runner.

Test scripts: the headed WebGPU spawnpoint test moves from
test:shaders:invariants to test:shaders:visual so invariants is
headless-capable; the Playwright tests close their own browser on
SIGINT/SIGTERM; CI runs test:shaders:i18n. gl-matrix's MIT notice is in
CREDITS.md because the bundles inline it without comments.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
scene.rst is restructured around getting a scene running: where to run it,
coordinate system and units, rotation order, defaults, the host API,
hosting steps and a limits list, with every claim checked against the
code. Wrong statements corrected: pbr rejects rather than clamps; osc(saw)
is the generator; the environment is never drawn; descriptor ranges differ
per channel; the scene vocabulary; the stale "known divergences"; the
cubemap readback row order, measured opposite between the backends for 2D
and scene programs alike; and the automation examples in language.rst and
midi-audio.rst, whose min/max values silently pinned parameters at their
maximum.

integration.rst gains the scene members, the compile-error shape and a
scene hosting section; renderer-output.rst covers scene programs; the
scene page moves from Specs to Features; README, llms.txt and llms-full.txt
mention the feature and its diagnostics.

Demo: both scenes keep their camera on the +Z side the default camera
uses, so +X is on the right in every example (each scene is turned 180
degrees about Y; hello-engine renders pixel-equivalent). The viewer gains
scene and backend pickers, a docs link and window.renderer; the demo note
says scene programs run in the main editor, which they do; the reference
screenshots are recaptured.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@aayars aayars closed this Oct 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants