Repository navigation
Conversation
There was a problem hiding this comment.
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-weightdetectScene()(e.g., compile AST +compileSceneonly) or reusing the already-compiledgraphas 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. IfSceneRenderer.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:1buildCamera()currently accepts unvalidated values forfov/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 hasnumberKw()andvectorKw(), prefer using them here (and optionallyassertKnownKeywords) to enforce the documented contracts (finite numbers;pos/targetare vec3).
shaders/src/geometry/gltf-loader.js:1parseGLB()doesn’t validate that a BIN chunk was found. If the GLB is missing BIN (or if the file is malformed),binChunkremainsnullandparseGLTFInternal()will later throw less actionable errors when constructing TypedArrays. Consider adding an explicitif (!binChunk) throw new Error('GLB missing BIN chunk')before callingparseGLTFInternal().
shaders/src/scene/node.js:1getWorldMatrix()allocates a new matrix every time a node is dirty (composeTransform()returns a freshmat4). In larger scenes or with per-frame animation bindings, this will create steady GC pressure. Prefer writing into the preallocatedthis._localMatrix(e.g., viamat4.fromRotationTranslationScale(this._localMatrix, ...)) or evolvingcomposeTransform()to accept anoutparameter.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
aayars
marked this pull request as draft
August 16, 2026 17:19
aayars
force-pushed
the
scene-graph
branch
2 times, most recently
from
August 25, 2026 06:09
10e59b7 to
06866a5
Compare
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>
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>
# Conflicts: # package.json
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.rstanddeferred-rendering.rstpage titles carry "(Preview)", the grammar marksSceneCalland everything reachable from it, andpipeline-3d.rstsays 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:
filter/stipplehash 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.mjsactually runs for the first time and catches stipple diverging between backends by a max delta of 251. The fix takes it to bit-identical.replaceEffectbuiltin guard.test_transform.jsassertslistStepsreturns 2 steps; it returns 3, and has been failing atmainindefinitely without anyone noticing (see below). Registering the test surfaces it; thekinddiscriminator fixes it.glDatadeclaration intest/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 towardmain(unionpackage.json;main'sgenerate_fixtures.pyand itsvhsHalfcoverage 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 newtest:shaders:parity-attestations, andtest_artistic_effect_release(401 cross-backend cases, stipple bit-identical). Fullnpm testis not verifiable in this environment —checkEnums.jsneeds a Python reference with numpy — but this branch no longer modifies any file involved in it.What's included
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.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 hoistosc()descriptors out of the tree.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.shaders/src/geometry/) — procedural primitives, a glTF/GLB loader, and marching cubes.waitForIdle().docs/shaders/scene.rstanddocs/shaders/deferred-rendering.rst, both marked preview, plus corrections tolanguage.rstwhere 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 theArgproduction 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 inlanguage.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 undersearch filtersilently rendered nothing. Now onlyscene()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 includinggroup(), scene errors carry real line/col (value literals now hold positions — the first fix had only covered Call nodes), and the diagnosticcolumnfield 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.rstdocuments 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
_onErrorexactly 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:
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;
r32freadPixels 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:
Geometry.toPackedTextures), one canonical shape; byte-identity with both deleted implementations proven twice, independently.primitives.jsis the single source;generate.cjsis a serializer over it. Regeneration fixedcube.obj/icosphere.objshipping with 100% of triangles wound backwards.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-fragments.jsemits 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.renderLit3dstays outside the emitter by measured decision (~55 shared lines against a third field variant);meshRenderis not re-based (the shell exceeds the duplication removed;meshLoaderalready rides the unified packing path).filter/stipplecarry 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 vianpm run test:shaders:pins, covering the legacy voxel branch the attestation schema cannot.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.osc(),midi()andaudio()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.llms-full.txtcoversscene()/volume(); the scene viewer is linked and shipped by the bundler; runtimevolumeSizeupdates that would shear a marched atlas are refused with a warning that survives recompiles.Decided terminal states (maintainer-signed):
geo0–geo7and two-argumentwrite3dstay;marchingCubesis 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 intest_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:test_parser.js:9andtest_compiler_phase2.js:29catch, printFAIL, and never set an exit code.test_parser.jsruns in CI. Both emit zero FAIL lines today.llms-full.txtGAP-023 namestest_compiler_phase2.jsspecifically and is still open.test_phase2_integration.jsis modified, failing, and unwired. It asserts texture pooling thatcompiler.jscomputes intograph.allocationsand never reads. Left failing rather than re-baselined to assert the no-op.test:shaders:invariantsis not in CI..github/workflows/shaders.ymlruns onlylang,runtime,render:webgl2,structure. Alsonpm run test:shadersnow requires a display, becausetest_spawnpoint_wgsl_vertical_mirror.mjslaunchesheadless: false.mainas of1ee891a2, which also adds a shader parity-attestation system. This branch no longer touchestest/effects.test.jsat all — it is byte-identical tomain, includingmain'srgbEffectDatanaming and its newvhsHalfregression. TheglDatadeclaration this branch previously carried was superseded and has been dropped.eslint.config.jsstill removes the now-deadglDataglobal, whichmainleft behind.test_transform.js'slistStepsassertions fail onmaintoday 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
scripts/bundle-site.js:131hardcodes an importmap that omitsgl-matrix; esbuild inlines it into the core bundle instead, which means the lazy-load rationale does not hold for the bundled artifact.gltf-loader.jsandprocedural.jsare 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 bybundle-site.js.test:shaders:structure,generateManifest, and the i18n string tables all key offshaders/effects/*/, which the JS-template-generated scene shaders bypass.recompile()re-parses independently. Known-open; a follow-up, not a blocker..gitignorestill has no.DS_Storeentry, though a tracked one is deleted here.Correctness work folded in
filter/stipplepointillize rendered differently per backend and per ANGLE backend. Its fract-based jitter hash contracted to FMA inconsistently, andfract()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_writenode and rewrite a chain's output wiring.StepInfonow carries a kind discriminator and builtins are rejected.127.0.0.1URL, and nine navigations to a manifest path that does not exist. The last of these is what madetest_artistic_effect_release.mjsrun for real and catch the stipple divergence above..DS_Storethe 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·NdotLdenominator) identical between GLSL and WGSL; G-buffer packing with no off-by-one; WGSL uniform struct offsets (writer and shader agree, sincewebgpu.jsparses the same declaration); the six cube-face basis pairs; dirty-flag propagation and IR→tree wiring;o0–o7discipline; no DOM reads in the scene render path; and no 2D regression from the new clear handling (no existing effect setsclear: true).🤖 Generated with Claude Code