diff --git a/.gitignore b/.gitignore index 0c6ba1d5..3fd6a4f5 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,7 @@ website/public/skill.md # Local planning doc — kept out of git per project decision ANIMATIONS.md + +# Generated static-effect-export bench artifacts +bench/static-effect-export/out/ +bench/static-effect-export/shots/ diff --git a/AGENTS.md b/AGENTS.md index 2105a6fa..5d9629a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,9 +90,11 @@ Because `rasterize` is pure (geometry + camera → string), a scene can be rende - **`compileScene(opts)`** (in `glyphcss`, pure) — polygons + camera + options → the `
` string. **Byte-identical to the runtime render** for the same inputs; same defaults as `createGlyphScene`. The foundation for every adapter.
 - **`@glyphcss/compile`** — Node adapters around `compileScene`: `loadMeshFromFile`, `compileFile`, a **Vite plugin** (`import x from "./m.glb?glyph&…"` → baked `
`), a **CLI** (`glyphcss-compile`), and `compileInteractive`.
 - **`GlyphSceneStatic`** (React + Vue) — SSR/SSG component that renders the compiled `
` with no client runtime (mirror of each other; static counterpart to `GlyphScene`).
-- **Interactive export** — `buildGlyphInteractiveExport(polygons, { interactions })` (pure, browser-safe, in `glyphcss`): the declared interactions (`orbit`/`zoom`/`pan`/`fpv`) drive **both** the wired control (only that one is imported → the snippet tree-shakes) **and** the decimation budget (`decimatePolygons` — coarser for orbit, finer when `zoom`/`fpv` let the camera approach). Output is a self-contained CDN+inlined-mesh snippet; `glyphCodepenPrefill` turns it into a CodePen POST (the gallery's "CodePen" button). Less declared interactivity = less mesh + less runtime shipped.
+- **Frame-roll export** — `buildGlyphFramesExport(polygons, { frameCount, durationSec, rotX, rotY, zoom, … })` (pure, browser-safe, in `glyphcss`): bakes a turntable of `frameCount` full `compileScene` renders, stacks them in one `
`, and cycles them with a pure-CSS `steps()` animation — **zero runtime JS**, faithful per-face color, instant paint. Trade-offs: discrete angles (smoothness ∝ `frameCount`), fixed resolution, and payload grows **linearly** with `frameCount` (a colored frame costs several KB gzipped even after cross-frame color-class dedupe) — good for a handful of frames, not for a long or continuous loop.
+- **Interactive export** — `buildGlyphInteractiveExport(polygons, { interactions })` (pure, browser-safe, in `glyphcss`): the declared interactions (`orbit`/`zoom`/`pan`/`fpv`) drive **both** the wired control (only that one is imported → the snippet tree-shakes) **and** the decimation budget (`decimatePolygons` — coarser for orbit, finer when `zoom`/`fpv` let the camera approach). Output is a self-contained CDN+inlined-mesh snippet; `glyphCodepenPrefill` turns it into a CodePen POST (the gallery's "CodePen" button). Less declared interactivity = less mesh + less runtime shipped. An optional `effect: { id, params, blend?, timeScale? }` mounts a live **stock** `@glyphcss/effects` layer: the snippet adds a second `import { getGlyphEffect } from "https://esm.sh/@glyphcss/effects@?deps=glyphcss@"` (same `` as the glyphcss import; `?deps` dedupes the shared glyphcss module instance), resolves the effect by id at runtime (`glyphcss` itself never imports `@glyphcss/effects` — that dependency only points one way), calls `scene.addEffectLayer(...)` after the scene/controls are wired, and — when `timeScale > 0` — appends a small `requestAnimationFrame` loop driving `params.time`. This is the export for a **moving-camera or multi-effect** scene (the gallery's "CodePen" button); it ships the glyphcss + effects runtime from the CDN.
+- **Static effect export (field-synth)** — `buildGlyphFieldSynthStaticExport(polygons, { params, blend, loopSeconds, cols, rows, … })` (pure, browser-safe, in `@glyphcss/effects` — not `glyphcss`, since it needs a stock effect's own math): for an **effect-only, static-camera** scene (fixed mesh + camera, only the field-synth texture animating over `time`), bakes the static base grid plus each covered cell's resolved field-synth domain coordinate **once**, reusing glyphcss's real rasterizer + effect-input machinery, then ships a tiny hand-written vanilla-JS field-synth evaluator that recomputes the pattern every `requestAnimationFrame` — **zero imports, zero CDN, zero `glyphcss`/`@glyphcss/*` at runtime**. Fixed payload regardless of loop length and continuously smooth, unlike a frame-roll export whose payload grows with frame count; see `bench/static-effect-export.md` for the size/quality trade-off that motivated shipping this over more prebaked frames. Always reads the layer's **real** `blend` (`over` vs `replace`), never the effect definition's `defaultBlend` UI metadata. Field-synth only today — the `/synth` page's export button is the reference caller; a future effect id needs its own exported coordinate resolver (mirroring `fieldSynthCoordinate`) plus a hand-written inlined-JS port of its per-cell math, since there's no way to ship an arbitrary `GlyphEffectProgram.evaluate()` without shipping glyphcss's effect runtime alongside it. Two further bakes shrink this for the common flat-surface case (the `/synth` page's default fullscreen plane): when every covered cell's per-cell domain coordinate fits an AFFINE function of grid `(col,row)` within a tight residual (a least-squares fit checked against every cell, not just on average, so a genuinely curved surface can't be mis-fit), the per-cell coordinate table — normally ~86% of the payload — is replaced with 6 fitted scalars and a one-line runtime formula; curved/projected surfaces (cube, sphere, …) fail that check and keep the table, unchanged. Separately, when the effect covers every grid cell with `blend:"replace"` at opacity 1 (the base contributes provably zero weight to every composited cell), the baked base glyph/color grid is skipped too. Both are pure size optimizations of the same bake — output is unchanged for curved/partial/`over`/opacity<1 cases, and matches the un-optimized table path within color-channel rounding for the affine case.
 
-Dynamic Glyph Effect layers are runtime-only in the current slice. Static compilation and the interactive/CodePen exporters do not serialize or evaluate them yet; callers must not imply that an exported scene contains a mounted effect.
+Dynamic Glyph Effect layers are otherwise runtime-only: `compileScene`/`GlyphSceneStatic` and the frame-roll export do not serialize or evaluate a mounted effect. Two paths do carry a live effect: the **interactive/CodePen exporter** mounts a **stock** effect by id from the `@glyphcss/effects` CDN (a custom `defineGlyphEffect` can't cross the CDN boundary), and **`buildGlyphFieldSynthStaticExport`** bakes an effect-only, static-camera field-synth scene into a self-contained inlined-JS pen. Neither generalizes to a moving camera plus effect, arbitrary effects in a static bake, or geometry animation.
 
 ## No per-frame DOM mutation
 
@@ -111,7 +113,7 @@ Every public export gets a `Glyph` prefix. Exceptions are generic math/geometry
 - **Hooks/composables:** `useGlyphCamera`, `useGlyphMesh`, `useGlyphSceneContext`, `useGlyphAnimation`.
 - **Components:** `GlyphScene`, `GlyphSceneStatic` (SSR/build-time `
`, no runtime), `GlyphEffectLayer`, `GlyphPerspectiveCamera`, `GlyphOrthographicCamera`, `GlyphOrbitControls`, `GlyphMapControls`, `GlyphFirstPersonControls`, `GlyphAxesHelper`, `GlyphDirectionalLightHelper`, `GlyphThreePerspectiveCamera`, `GlyphThreeOrthographicCamera`, `GlyphThreeMesh`.
 - **Types:** `GlyphDirectionalLight`, `GlyphAmbientLight`, `GlyphEffectDefinition`, `GlyphEffectProgram`, `GlyphEffectLayerHandle`, `GlyphAnimationMixer`, `GlyphAnimationAction`, `GlyphAnimationClip`, `GlyphAnimationTarget`.
-- **Functions:** `defineGlyphEffect`, `parseGlyphEffectColor`, `createGlyphAnimationMixer`, `injectGlyphBaseStyles`, `compileScene` (pure, DOM-less render → `
` string), `buildGlyphInteractiveExport` / `glyphCodepenPrefill` (polygons + declared interactions → portable self-contained snippet), `decimatePolygons` (core — resolution-target mesh simplification).
+- **Functions:** `defineGlyphEffect`, `parseGlyphEffectColor`, `createGlyphAnimationMixer`, `injectGlyphBaseStyles`, `compileScene` (pure, DOM-less render → `
` string), `buildGlyphFramesExport` (turntable → prebaked-frame `steps()` export), `buildGlyphInteractiveExport` / `glyphCodepenPrefill` (polygons + declared interactions → portable self-contained snippet), `buildGlyphFieldSynthStaticExport` (`@glyphcss/effects` — effect-only static-camera scene → self-contained inlined-JS pen), `decimatePolygons` (core — resolution-target mesh simplification).
 - **Vanilla factories:** `createGlyphScene`, `createGlyphCamera` (ortho alias), `createGlyphPerspectiveCamera`, `createGlyphOrthographicCamera`, `createGlyphOrbitControls`, `createGlyphMapControls`, `createGlyphFirstPersonControls`.
 - **HTML custom elements:** `glyph-` prefix + kebab-case. Existing tags: ``, ``, ``, ``, ``, ``, `` (ortho alias), ``, ``, ``. Any new element follows the same shape.
 - `GlyphCamera` is the ergonomic default alias — it resolves to `GlyphOrthographicCamera`. The voxel render mode and iso/diagrammatic scenes are glyphcss's differentiator; ortho is the more representative default.
diff --git a/bench/static-effect-export.md b/bench/static-effect-export.md
new file mode 100644
index 00000000..86ff6a8f
--- /dev/null
+++ b/bench/static-effect-export.md
@@ -0,0 +1,130 @@
+# Static effect-export benchmark — prebaked frames (A) vs inlined vanilla-JS (B)
+
+Deciding how to export an **effect-only, static-camera** glyphcss animation as a
+self-contained pen (one HTML + CSS + JS, no build step for the consumer).
+
+**Scenario.** One mesh (icosphere, `subdivisions:3`), a **fixed** orthographic
+camera (no rotation), and the stock **field-synth** effect animating its texture
+over `time` (surface-mapped moiré, `space:"surface"`, `blend:"replace"`). Grid
+**100×40** cells (≈2453 covered by the silhouette), **4 s** loop, 12 px cell.
+
+Two strategies:
+
+- **A — prebaked frames (zero JS).** Bake N grids at `time = i/N · loop`, stack
+  them into one `
`, cycle with a pure-CSS `steps(N)` animation. Same idea as
+  the existing `buildGlyphFramesExport`, but varying the effect `time` instead of
+  `rotY` (camera fixed). Rendered here by the **real, pure** glyphcss rasterizer +
+  effect compositor run in Node — byte-faithful to the runtime render.
+- **B — minimal inlined vanilla JS (no libraries).** Bake, once, the per-cell
+  resolved effect-domain coordinate `(x, y, cx, cy)` + Lambert `shade` + cell
+  position, then ship a tiny hand-written field-synth evaluator that recomputes
+  every covered cell per `requestAnimationFrame` and rewrites the `
`.
+  **Zero external dependencies** (no `import`, no `http(s)://`, no CDN, no
+  glyphcss/@glyphcss/effects — verified by grep and by an offline browser run).
+
+## Payload (gzip is the honest metric — CodePen/HTTP serve gzipped)
+
+| Strategy        | Raw     | **Gzip**   | Gzip vs B |
+|-----------------|---------|------------|-----------|
+| A — N = 12      | 464 KB  | **51.0 KB**| 2.7×      |
+| A — N = 24      | 918 KB  | **97.4 KB**| 5.1×      |
+| A — N = 36      | 1365 KB | **141.7 KB**| 7.2×     |
+| **B — inlined JS** | 86 KB | **19.2 KB** | 1.0×    |
+
+**Crossover: A's payload exceeds B at N ≈ 3.6 frames.** A grows linearly
+(≈ 5.8 KB + 3.9 KB·N gzipped — one frame's dithered colored ASCII costs ~3.9 KB
+gz even after cross-frame color-class dedupe). B is **fixed** at 19.2 KB gz
+regardless of loop length or smoothness. So for anything past ~4 discrete frames,
+B is smaller — and the gap widens fast (7× at a still-choppy 36 frames).
+
+### Where B's bytes go (it scales with the grid, not with N)
+
+| Part                         | Raw    | Gzip     |
+|------------------------------|--------|----------|
+| Baked per-cell `(x,y,cx,cy,shade,col,row)` (2453 cells) | 84.1 KB | **16.9 KB** |
+| Runtime evaluator + markup   | 4.3 KB | ~1.5 KB  |
+| Baked params (`CFG`)         | 291 B  | —        |
+
+The field-synth evaluator itself is **~4 KB raw** (~1.5 KB gz); **86 %** of B is
+the baked per-cell coordinate table. B therefore scales with **covered cell
+count** (grid resolution × silhouette), not with frame count or loop duration.
+
+## Quality
+
+- **A:** discrete — temporal smoothness ∝ N; at N=12 the loop visibly steps.
+  Instant paint, **zero JS/CPU** after load. Fixed resolution. A non-periodic
+  effect (e.g. a `noise` voice, whose time axis isn't periodic) shows a
+  **loop seam** at the wrap; here we chose periodic voices (radial sins, integer
+  cycles over 4 s) so A loops cleanly.
+- **B:** **continuous** — perfectly smooth, any loop length, no seam constraint
+  (it just evaluates `time = now/1000 % loop`; a truly aperiodic effect can even
+  be shown non-looping). Instant first paint of the baked static grid, then the
+  JS "hydrates" and animates. Tiny JS.
+
+**Equivalence.** A and B render the **same** effect: cell-for-cell the two agree
+**95–97 %** across sampled times, with `visible` counts matching within <2 %
+(the small residual is 3-decimal coordinate quantization flipping cells that sit
+right on a ramp-index or dither-threshold boundary — visually indistinguishable).
+Screenshots confirm identical pattern, ramp, and blue→pink gradient.
+
+## CPU note for B (rough)
+
+Per frame B recomputes every covered cell: 2 active oscillators (here) → a couple
+of `sin`/`hypot` + a combine + ramp/color/lit + Bayer dither, then builds the
+colored `
` innerHTML from color runs. For 2453 cells that is a few hundred
+µs of math plus the innerHTML parse/paint of the `
` — comfortably 60 fps on
+a 100×40 grid. Cost is **O(covered cells) per frame** and independent of voice
+count beyond the active ones; it grows with grid resolution (a 200×80 grid is 4×
+the cells) and with `useColors` (colored spans cost ParseHTML/Style/Paint on top
+of the recompute). This is the same per-frame budget the live glyphcss runtime
+pays; B just inlines the field-synth slice of it with no library around it.
+
+## Recommendation
+
+**Ship Strategy B (inlined vanilla-JS) as the default for effect-only /
+static-camera exports.** For any loop worth shipping (≳4 frames) B is smaller
+gzipped, and it is *continuously* smooth with no frame-count/loop-length/seam
+trade-off — a 4 s loop and a 40 s loop are the same 19 KB. A only wins in the
+degenerate low-N corner (≤3 frames) or when a **zero-JS** artifact is a hard
+requirement (CSP forbids inline script, or a truly no-runtime SSG fragment). Keep
+A as a secondary "zero-JS, few-frames" option, but B is the right primary.
+
+Caveats to carry into a productionized B:
+- Payload is dominated by the baked per-cell coordinate table (scales with grid
+  size, not N). `cx,cy` are per-surface-group constants baked per-cell today —
+  de-duplicating them per group, and packing `x,y` as quantized deltas or a typed
+  binary blob, would shrink B further.
+- This slice bakes **one** static camera + **one** mesh + a **fixed param set**
+  (only `time` animates). Animating other params, moving the camera, or per-mesh
+  detail layers are out of scope — those defeat "bake the resolved coordinate".
+- Matches the runtime only for the layer's **actual** blend. Note field-synth's
+  `defaultBlend` is UI metadata and is **not** auto-applied to a layer — a layer
+  with no explicit `blend` uses `over` (coverage saturates to 1, no dither),
+  which looks denser than the `replace` texture used here. An exporter must read
+  the layer's real blend/opacity/order, not the definition default.
+
+## Reproduce
+
+```
+node bench/static-effect-export/build.mjs       # bakes both pens → out/, prints the size table
+node bench/static-effect-export/verify.mjs      # Playwright render+animate+offline check → shots/
+```
+
+- Prototype export code: `bench/static-effect-export/harness.ts`
+  (`buildStrategyA`, `buildStrategyB`, the Node bake path, and the build-time
+  surface-basis copy used only to resolve B's per-cell coordinates).
+- Generated pens + `sizes.json`: `bench/static-effect-export/out/`.
+
+### How B's inlined JS was generated
+
+**Hand-written** faithful vanilla-JS port of the field-synth per-cell math
+(`synthWave`/`synthNoise3`/`synthOsc`/`combineSynth`/`lerpPacked`/`scalePackedColor`
++ the compositor's Bayer coverage dither), chosen over an esbuild bundle because
+the per-cell slice is small and hand-writing gives the most honest, minimal size
+with no bundler runtime/wrapper noise. Its output is validated against the real
+compositor in Node (95–97 % cell match) and visually via screenshots.
+
+**Self-containment proof:** `grep -iE 'import |https?://|require\(|@glyphcss'
+strategyB.html` → 0 hits; and it renders + animates in headless Chromium with
+`context.setOffline(true)` + all non-`file://` requests aborted (0 external
+requests observed).
diff --git a/bench/static-effect-export/build.mjs b/bench/static-effect-export/build.mjs
new file mode 100644
index 00000000..405ef2ec
--- /dev/null
+++ b/bench/static-effect-export/build.mjs
@@ -0,0 +1,24 @@
+// Bundle generate.ts (which imports library SOURCE via aliases) into a Node ESM
+// bundle, then run it to emit both export strategies + size table.
+import { build } from "esbuild";
+import { fileURLToPath } from "node:url";
+import { dirname, resolve } from "node:path";
+
+const here = dirname(fileURLToPath(import.meta.url));
+
+await build({
+  entryPoints: [resolve(here, "generate.ts")],
+  outfile: resolve(here, "generate.bundle.mjs"),
+  bundle: true,
+  platform: "node",
+  format: "esm",
+  target: "node18",
+  logLevel: "info",
+  alias: {
+    "glyphcss": resolve(here, "../../packages/glyphcss/src/index.ts"),
+    "@glyphcss/core": resolve(here, "../../packages/core/src/index.ts"),
+    "@glyphcss/effects": resolve(here, "../../packages/effects/src/index.ts"),
+  },
+});
+
+await import(resolve(here, "generate.bundle.mjs"));
diff --git a/bench/static-effect-export/generate.ts b/bench/static-effect-export/generate.ts
new file mode 100644
index 00000000..27114d77
--- /dev/null
+++ b/bench/static-effect-export/generate.ts
@@ -0,0 +1,51 @@
+/**
+ * Generate both export strategies for the representative case, write the pens to
+ * disk, and print raw + gzipped payload sizes for the results table.
+ */
+import { gzipSync } from "node:zlib";
+import { writeFileSync, mkdirSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, resolve } from "node:path";
+import { makeBaked, buildStrategyA, buildStrategyB, COLS, ROWS, LOOP_SECONDS } from "./harness";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const outDir = resolve(here, "out");
+mkdirSync(outDir, { recursive: true });
+
+const gz = (s: string) => gzipSync(Buffer.from(s, "utf8"), { level: 9 }).length;
+const kb = (n: number) => (n / 1024).toFixed(1);
+
+const baked = makeBaked();
+
+const A_FRAMES = [12, 24, 36];
+const results: { name: string; raw: number; gz: number }[] = [];
+
+for (const n of A_FRAMES) {
+  const html = buildStrategyA(baked, n);
+  writeFileSync(resolve(outDir, `strategyA-${n}.html`), html);
+  results.push({ name: `A (N=${n})`, raw: html.length, gz: gz(html) });
+}
+
+const b = buildStrategyB(baked);
+writeFileSync(resolve(outDir, "strategyB.html"), b);
+results.push({ name: "B (inlined JS)", raw: b.length, gz: gz(b) });
+
+console.log(`\nRepresentative case: sphere, ${COLS}x${ROWS} grid, ${LOOP_SECONDS}s loop, field-synth (space:"surface")\n`);
+console.log("Strategy            | Raw (KB) | Gzip (KB)");
+console.log("--------------------|----------|----------");
+for (const r of results) {
+  console.log(`${r.name.padEnd(19)} | ${kb(r.raw).padStart(8)} | ${kb(r.gz).padStart(8)}`);
+}
+
+const bGz = results.find((r) => r.name.startsWith("B"))!.gz;
+console.log(`\nStrategy B gzip = ${bGz} bytes (${kb(bGz)} KB).`);
+// crossover: per-frame gzip cost of A
+const a12 = results.find((r) => r.name.includes("12"))!.gz;
+const a36 = results.find((r) => r.name.includes("36"))!.gz;
+const perFrame = (a36 - a12) / (36 - 12);
+const base = a12 - perFrame * 12;
+const crossover = (bGz - base) / perFrame;
+console.log(`A gzip ≈ ${base.toFixed(0)} + ${perFrame.toFixed(0)}*N bytes  →  A exceeds B at N ≈ ${crossover.toFixed(1)} frames.`);
+
+writeFileSync(resolve(outDir, "sizes.json"), JSON.stringify({ results, perFrameGz: perFrame, baseGz: base, crossover }, null, 2));
+console.log(`\nWrote pens + sizes.json to ${outDir}`);
diff --git a/bench/static-effect-export/harness.ts b/bench/static-effect-export/harness.ts
new file mode 100644
index 00000000..7d20ff9d
--- /dev/null
+++ b/bench/static-effect-export/harness.ts
@@ -0,0 +1,445 @@
+/**
+ * Static effect-export benchmark harness.
+ *
+ * Scenario: "effect only, static camera" — one mesh, a fixed camera, and the
+ * field-synth effect animating its texture over `time`. We prototype two
+ * self-contained (CodePen-style) export strategies and measure them.
+ *
+ *   A — prebaked frames (zero JS): bake N grids at time = i/N * loop, stack
+ *       them, cycle with a pure-CSS steps() animation. Mirrors
+ *       buildGlyphFramesExport but varies effect time instead of rotY.
+ *   B — minimal inlined JS (no libraries): bake the static per-cell effect-domain
+ *       coordinate (x,y,cx,cy) + shade + base color, then ship a tiny vanilla-JS
+ *       field-synth evaluator that recomputes each cell per rAF frame.
+ *
+ * The bake path runs the REAL, pure glyphcss render + effect compositor in Node
+ * (no browser globals), so A is byte-faithful to the runtime render. B's runtime
+ * evaluator is a hand-written faithful port of the field-synth per-cell math; the
+ * per-cell domain coordinates it consumes are computed here at build time with a
+ * copy of glyphcss's own surface-basis math (build-time only — never shipped).
+ */
+import type { Polygon } from "@glyphcss/core";
+import { spherePolygons } from "@glyphcss/core";
+import { createGlyphOrthographicCamera } from "glyphcss";
+import { buildRasterizeContext } from "glyphcss";
+import { rasterize } from "glyphcss";
+import { encodeCellGrid } from "glyphcss";
+import type { CellGrid } from "../../packages/glyphcss/src/render/cells";
+import {
+  createRuntimeGlyphEffectLayer,
+  prepareRuntimeGlyphEffectLayers,
+  retainGlyphEffectOutput,
+  composeRetainedGlyphEffectOutput,
+  type GlyphEffectOutputMetadata,
+  type RetainedGlyphEffectOutput,
+} from "../../packages/glyphcss/src/render/effectCompositor";
+import { GlyphFieldSynthEffect as fieldSynth } from "@glyphcss/effects";
+import { encodeStaticGlyphHtml } from "glyphcss";
+import { cropGlyphFrames } from "../../packages/glyphcss/src/api/staticEncode";
+
+// ── Scene / effect configuration (the representative case) ───────────────────
+export const COLS = 100;
+export const ROWS = 40;
+export const CELL_ASPECT = 2.0;
+export const LOOP_SECONDS = 4;
+export const FONT_PX = 12;
+export const LINE_PX = 12; // cell = fontPx wide (1ch) × linePx tall
+
+// Fixed camera (does NOT move) + one mesh.
+const ROT_X = 62;
+const ROT_Y = 38;
+const ZOOM = 110;
+const SPHERE_SIZE = 9;
+const SPHERE_SUBDIV = 3;
+
+// field-synth params — surface-mapped moiré, periodic over LOOP_SECONDS so the
+// steps() loop (Strategy A) is seamless (speed*loop must be integer; no noise
+// voice, which is not periodic in its time axis).
+export const FIELD_SYNTH_PARAMS = {
+  time: 0,
+  space: "surface",
+  scale: 2.5,
+  originU: 0.4,
+  originV: 0.6,
+  field1: "radial", wave1: "sin", freq1: 12, speed1: 0.5, amp1: 1,
+  field2: "radial", wave2: "sin", freq2: 12.6, speed2: -0.5, amp2: 1,
+  field3: "linearX", wave3: "sin", freq3: 4, speed3: 0.25, amp3: 0,
+  field4: "linearY", wave4: "sin", freq4: 4, speed4: 0.25, amp4: 0,
+  field5: "diagonal", wave5: "sin", freq5: 6, speed5: 0.25, amp5: 0,
+  field6: "noise", wave6: "sin", freq6: 5, speed6: 0.25, amp6: 0,
+  combine: "multiply",
+  gain: 1,
+  bias: 0.5,
+  glyphs: " .·:+*#%@",
+  color: "#9ddfff",
+  colorB: "#ff4fa3",
+  gradient: 0.5,
+  lit: 1,
+  voiceColors: false,
+  color1: "#7df9ff", color2: "#ff4fa3", color3: "#8affc1",
+  color4: "#ffcf5a", color5: "#c78bff", color6: "#ff7a45",
+} as const;
+
+export function sceneMesh(): Polygon[] {
+  return spherePolygons({ center: [0, 0, 0], size: SPHERE_SIZE, subdivisions: SPHERE_SUBDIV, color: "#8fb3d9" });
+}
+
+// ── Bake: run the real render + effect compositor once, retain the base grid ─
+interface Baked {
+  retained: RetainedGlyphEffectOutput;
+  layer: ReturnType;
+  worldToSceneScale: number;
+}
+
+function bake(): Baked {
+  const polygons = sceneMesh();
+  const camera = createGlyphOrthographicCamera({ rotX: ROT_X, rotY: ROT_Y, zoom: ZOOM });
+
+  // world→scene scale exactly as createGlyphScene computes it for worldPosition.
+  let minX = Infinity, minY = Infinity, minZ = Infinity, maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
+  for (const p of polygons) for (const v of p.vertices) {
+    if (v[0] < minX) minX = v[0]; if (v[1] < minY) minY = v[1]; if (v[2] < minZ) minZ = v[2];
+    if (v[0] > maxX) maxX = v[0]; if (v[1] > maxY) maxY = v[1]; if (v[2] > maxZ) maxZ = v[2];
+  }
+  const span = Math.max(maxX - minX, maxY - minY, maxZ - minZ);
+  const worldToSceneScale = Number.isFinite(span) && span > 1e-9 ? Math.min(COLS, ROWS) / span : 1;
+
+  const layer = createRuntimeGlyphEffectLayer(
+    // blend "replace" = field-synth's own defaultBlend (the definition value is
+    // UI metadata, not auto-applied to a layer, so we set it explicitly): the
+    // texture fully replaces the surface, coverage = value, dither gates low cells.
+    { effect: fieldSynth, params: { ...FIELD_SYNTH_PARAMS }, blend: "replace" },
+    0,
+    () => {},
+    () => {},
+  );
+
+  const metadata: GlyphEffectOutputMetadata = {
+    id: "base",
+    pre: null as unknown as HTMLPreElement,
+    isBase: true,
+    cellToSceneGrid: [1, 0, 0, 1, 0, 0],
+    sceneGridSize: [COLS, ROWS],
+    localCellFootprint: [1, 1],
+    worldToSceneScale,
+  };
+
+  let retained: RetainedGlyphEffectOutput | null = null;
+  const ctx = buildRasterizeContext({
+    camera,
+    grid: { cols: COLS, rows: ROWS, cellAspect: CELL_ASPECT },
+    polygons,
+    mode: "solid",
+    directionalLight: { direction: [0.5, 0.7, 0.5], intensity: 1 },
+    ambientLight: { intensity: 0.4 },
+    useColors: true,
+    retainShade: true,
+    retainWorldPosition: true,
+    retainNormal: true,
+  });
+  ctx.transformCells = (grid: CellGrid) => {
+    retained = retainGlyphEffectOutput(grid, metadata);
+    return grid;
+  };
+  rasterize(ctx);
+  if (!retained) throw new Error("bake: transformCells hook did not run");
+  return { retained, layer, worldToSceneScale };
+}
+
+// Compose the effect grid at a given time (the real compositor path).
+function composeAt(baked: Baked, time: number): CellGrid {
+  baked.layer.paramsTarget.time = time;
+  const prepared = prepareRuntimeGlyphEffectLayers([baked.layer], [COLS, ROWS]);
+  return composeRetainedGlyphEffectOutput(baked.retained, prepared);
+}
+
+// ── Strategy A — prebaked frames, zero JS (steps() CSS animation) ────────────
+export function buildStrategyA(baked: Baked, frameCount: number): string {
+  const inners: string[] = [];
+  for (let i = 0; i < frameCount; i++) {
+    const t = (i / frameCount) * LOOP_SECONDS;
+    const grid = composeAt(baked, t);
+    inners.push(encodeCellGrid(grid, true));
+  }
+  const cropped = cropGlyphFrames(inners);
+  const enc = encodeStaticGlyphHtml(cropped.frames.join("\n"), "classes");
+  const frameH = cropped.rows * LINE_PX;
+  const totalShift = frameCount * frameH;
+  const css = `html,body{margin:0;height:100%;background:#0b0d10;display:grid;place-items:center}
+.glyph-roll{height:${frameH}px;overflow:hidden}
+.glyph-roll .glyph-output{margin:0;white-space:pre;font-family:ui-monospace,Menlo,monospace;font-size:${FONT_PX}px;line-height:${LINE_PX}px;animation:glyph-roll ${LOOP_SECONDS}s steps(${frameCount}) infinite}
+@keyframes glyph-roll{from{transform:translateY(0)}to{transform:translateY(-${totalShift}px)}}
+${enc.css}`;
+  const body = `
${enc.html}
`; + return `Strategy A — prebaked frames (N=${frameCount})${body}`; +} + +// ── Build-time surface-basis math (a copy of glyphcss's own; NEVER shipped) ── +// Used only to resolve the per-cell field-synth domain coordinate at build time +// for Strategy B, so the runtime ships plain numbers, not projection/fitting. +function hash2(a: number, b: number): number { + let h = Math.imul((a | 0) + 1, -1640531527) ^ Math.imul((b | 0) + 1, -2048144789); + h ^= h >>> 16; + return h >>> 0; +} +const GENERATED_SURFACE_PITCH = 4; +interface Basis { u: number; v: number; key: string; } +function surfaceBasis(pos: Float32Array, nor: Float32Array, i: number, worldToSceneScale0: number): Basis | null { + const o = i * 3; + const px = pos[o]!, py = pos[o + 1]!, pz = pos[o + 2]!; + let nx = nor[o]!, ny = nor[o + 1]!, nz = nor[o + 2]!; + if (![px, py, pz, nx, ny, nz].every(Number.isFinite)) return null; + const nl = Math.hypot(nx, ny, nz); + if (nl < 1e-6) return null; + nx /= nl; ny /= nl; nz /= nl; + const absX = Math.abs(nx), absY = Math.abs(ny), absZ = Math.abs(nz); + const dominant = absX >= absY && absX >= absZ ? nx : absY >= absZ ? ny : nz; + if (dominant < 0) { nx = -nx; ny = -ny; nz = -nz; } + const authoredScale = worldToSceneScale0; + const worldToSceneScale = authoredScale !== undefined && Number.isFinite(authoredScale) && authoredScale > 0 + ? authoredScale / GENERATED_SURFACE_PITCH : 1 / GENERATED_SURFACE_PITCH; + let vx = nz * nx, vy = nz * ny, vz = nz * nz - 1; + const vl = Math.hypot(vx, vy, vz); + let verticalCoordinate: number; + if (vl < 1e-4) { + let tx = absX < 0.9 ? 1 : 0, ty = absX < 0.9 ? 0 : 1, tz = 0; + const td = tx * nx + ty * ny + tz * nz; + tx -= nx * td; ty -= ny * td; tz -= nz * td; + const tl = Math.hypot(tx, ty, tz); + tx /= tl; ty /= tl; tz /= tl; + const bx = ny * tz - nz * ty, by = nz * tx - nx * tz, bz = nx * ty - ny * tx; + const planeOffset = px * nx + py * ny + pz * nz; + const normalHash = hash2(hash2(Math.round(nx * 32767), Math.round(ny * 32767)), Math.round(nz * 32767)); + const orientation = hash2(normalHash, Math.round(planeOffset * 1024)); + const sign = orientation & 2 ? -1 : 1; + if (orientation & 1) { vx = bx * sign; vy = by * sign; vz = bz * sign; } + else { vx = tx * sign; vy = ty * sign; vz = tz * sign; } + verticalCoordinate = px * vx + py * vy + pz * vz; + } else { + vx /= vl; vy /= vl; vz /= vl; + verticalCoordinate = px * vx + py * vy + pz * vz; + } + const hx = vy * nz - vz * ny, hy = vz * nx - vx * nz, hz = vx * ny - vy * nx; + const planeOffset = px * nx + py * ny + pz * nz; + const u = (px * hx + py * hy + pz * hz) * worldToSceneScale; + const v = verticalCoordinate * worldToSceneScale; + const key = `${Math.round(nx * 4096)},${Math.round(ny * 4096)},${Math.round(nz * 4096)},${Math.round(planeOffset * worldToSceneScale * 1024)},${Math.round(hx * 4096)},${Math.round(hy * 4096)},${Math.round(hz * 4096)},${Math.round(vx * 4096)},${Math.round(vy * 4096)},${Math.round(vz * 4096)}`; + return { u, v, key }; +} + +interface CellData { col: number; row: number; x: number; y: number; cx: number; cy: number; shade: number; } + +function bakeCells(baked: Baked): CellData[] { + const base = baked.retained.base; + const pos = base.worldPosition!; + const nor = base.normal!; + const shade = base.shade; + const scale = FIELD_SYNTH_PARAMS.scale; + const { originU, originV } = FIELD_SYNTH_PARAMS; + // Group covered cells by surface key; track per-group u/v bounds (field-synth's + // origin maps into the group's own covered bounds). + interface Group { minU: number; maxU: number; minV: number; maxV: number; } + const groups = new Map(); + const perCell: { col: number; row: number; u: number; v: number; key: string; sh: number }[] = []; + for (let i = 0; i < base.length; i++) { + if (baked.retained.baseCoverage[i]! <= 0) continue; + const b = surfaceBasis(pos, nor, i, baked.worldToSceneScale); + if (!b) continue; + let g = groups.get(b.key); + if (!g) { g = { minU: Infinity, maxU: -Infinity, minV: Infinity, maxV: -Infinity }; groups.set(b.key, g); } + if (b.u < g.minU) g.minU = b.u; if (b.u > g.maxU) g.maxU = b.u; + if (b.v < g.minV) g.minV = b.v; if (b.v > g.maxV) g.maxV = b.v; + const sh = shade ? shade[i]! : NaN; + perCell.push({ col: i % COLS, row: (i / COLS) | 0, u: b.u, v: b.v, key: b.key, sh: Number.isFinite(sh) ? sh : 1 }); + } + const out: CellData[] = []; + for (const c of perCell) { + const g = groups.get(c.key)!; + const cx = (g.minU + originU * (g.maxU - g.minU)) * scale; + const cy = (g.minV + originV * (g.maxV - g.minV)) * scale; + out.push({ col: c.col, row: c.row, x: c.u * scale, y: c.v * scale, cx, cy, shade: c.sh }); + } + return out; +} + +// ── Strategy B — minimal inlined vanilla-JS field-synth (zero dependencies) ── +export function buildStrategyB(baked: Baked): string { + const cells = bakeCells(baked); + // Determine crop origin so the block sits like Strategy A (tight box). + let minCol = Infinity, maxCol = -Infinity, minRow = Infinity, maxRow = -Infinity; + for (const c of cells) { + if (c.col < minCol) minCol = c.col; if (c.col > maxCol) maxCol = c.col; + if (c.row < minRow) minRow = c.row; if (c.row > maxRow) maxRow = c.row; + } + const gridCols = maxCol - minCol + 1; + const gridRows = maxRow - minRow + 1; + + // Flat typed arrays keep the baked payload compact. Quantize coords to a fixed + // decimal to shrink the JSON without visible drift. + const q = (n: number) => Math.round(n * 1000) / 1000; + const col: number[] = [], row: number[] = [], X: number[] = [], Y: number[] = [], CX: number[] = [], CY: number[] = [], SH: number[] = []; + for (const c of cells) { + col.push(c.col - minCol); row.push(c.row - minRow); + X.push(q(c.x)); Y.push(q(c.y)); CX.push(q(c.cx)); CY.push(q(c.cy)); + SH.push(Math.round(c.shade * 100) / 100); + } + const data = { c: col, r: row, x: X, y: Y, cx: CX, cy: CY, sh: SH }; + + // Active voices only (amp>0) — the baked param set is fixed. + const P = FIELD_SYNTH_PARAMS; + const voices = [ + { field: P.field1, wave: P.wave1, freq: P.freq1, speed: P.speed1, amp: P.amp1 }, + { field: P.field2, wave: P.wave2, freq: P.freq2, speed: P.speed2, amp: P.amp2 }, + { field: P.field3, wave: P.wave3, freq: P.freq3, speed: P.speed3, amp: P.amp3 }, + { field: P.field4, wave: P.wave4, freq: P.freq4, speed: P.speed4, amp: P.amp4 }, + { field: P.field5, wave: P.wave5, freq: P.freq5, speed: P.speed5, amp: P.amp5 }, + { field: P.field6, wave: P.wave6, freq: P.freq6, speed: P.speed6, amp: P.amp6 }, + ].filter((v) => v.amp > 0); + + const hex = (h: string) => parseInt(h.slice(1), 16); + const cfg = { + cols: gridCols, rows: gridRows, + loop: LOOP_SECONDS, + scale: P.scale, gain: P.gain, bias: P.bias, + combine: P.combine, gradient: P.gradient, lit: P.lit, + ramp: P.glyphs, + cA: hex(P.color), cB: hex(P.colorB), + voices, + }; + + const runtime = ` +"use strict"; +const D=DATA,C=CFG,N=D.c.length,ramp=C.ramp,rmax=ramp.length-1,V=C.voices; +const BAYER=[0,8,2,10,12,4,14,6,3,11,1,9,15,7,13,5]; +function pmod(a,m){return((a%m)+m)%m} +function wave(k,t){const p=t-Math.floor(t);if(k==="triangle")return 4*Math.abs(p-0.5)-1;if(k==="saw")return 2*p-1;if(k==="square")return p<0.5?1:-1;return Math.sin(t*Math.PI*2)} +function h3(x,y,z){const h=Math.sin(x*127.1+y*311.7+z*74.7)*43758.5453;return h-Math.floor(h)} +function noise3(x,y,z){const xi=Math.floor(x),yi=Math.floor(y),zi=Math.floor(z),xf=x-xi,yf=y-yi,zf=z-zi; +const u=xf*xf*(3-2*xf),v=yf*yf*(3-2*yf),w=zf*zf*(3-2*zf); +const a000=h3(xi,yi,zi),a100=h3(xi+1,yi,zi),a010=h3(xi,yi+1,zi),a110=h3(xi+1,yi+1,zi),a001=h3(xi,yi,zi+1),a101=h3(xi+1,yi,zi+1),a011=h3(xi,yi+1,zi+1),a111=h3(xi+1,yi+1,zi+1); +const f0=(a000*(1-u)+a100*u)*(1-v)+(a010*(1-u)+a110*u)*v,f1=(a001*(1-u)+a101*u)*(1-v)+(a011*(1-u)+a111*u)*v;return f0*(1-w)+f1*w} +function osc(o,x,y,cx,cy,t){if(o.field==="noise")return 2*noise3(x*o.freq,y*o.freq,t*o.speed)-1;let raw; +switch(o.field){case"linearX":raw=x;break;case"linearY":raw=y;break;case"diagonal":raw=(x+y)*0.70710678;break; +case"angular":raw=Math.atan2(y-cy,x-cx)/(Math.PI*2);break;case"spiral":raw=Math.hypot(x-cx,y-cy)+Math.atan2(y-cy,x-cx)/(Math.PI*2);break;default:raw=Math.hypot(x-cx,y-cy)} +return wave(o.wave,raw*o.freq-t*o.speed)} +function combine(a,b){switch(C.combine){case"add":return a+b;case"max":return Math.max(a,b);case"min":return Math.min(a,b);case"difference":return Math.abs(a-b);default:return a*b}} +function clamp01(v){return v<0?0:v>1?1:v} +function lerp(a,b,t){const ar=(a>>16)&255,ag=(a>>8)&255,ab=a&255,br=(b>>16)&255,bg=(b>>8)&255,bb=b&255; +return(Math.round(ar+(br-ar)*t)<<16)|(Math.round(ag+(bg-ag)*t)<<8)|Math.round(ab+(bb-ab)*t)} +function shade(p,s){const r=Math.round(((p>>16)&255)*s),g=Math.round(((p>>8)&255)*s),b=Math.round((p&255)*s);return(r<<16)|(g<<8)|b} +function thr(col,rw){const x=col+0.5,y=rw+0.5,mx=Math.floor(4*x),my=Math.floor(4*y); +const coarse=BAYER[pmod(Math.floor(my/4),4)*4+pmod(Math.floor(mx/4),4)],fine=BAYER[pmod(my,4)*4+pmod(mx,4)];return(16*coarse+fine+0.5)/256} +const pre=document.getElementById("g"); +const rowBuf=new Array(C.rows);for(let i=0;i=1||cov>thr(col,rw))){continue} +let packed=C.gradient>0?lerp(C.cA,C.cB,clamp01(value*C.gradient)):C.cA; +if(C.lit>0){packed=shade(packed,1-C.lit*(1-clamp01(D.sh[k])))} +const g=ramp[Math.min(rmax,Math.max(0,Math.round(value*rmax)))]; +rowBuf[rw].push([col,g,packed])} +let html=""; +for(let r=0;ra[0]-b[0]); +let line="",cur=-1,prevColor=-1,open=false; +for(const cell of cells){const[cc,g,pk]=cell;while(cur";open=false;prevColor=-1}line+=" ";cur++} +if(pk!==prevColor){if(open)line+="";line+="";open=true;prevColor=pk} +line+=g;cur=cc} +if(open)line+="";html+=line+"\\n"} +pre.innerHTML=html;requestAnimationFrame(frame)} +requestAnimationFrame(frame); +`; + + const css = `html,body{margin:0;height:100%;background:#0b0d10;display:grid;place-items:center} +#g{margin:0;white-space:pre;font-family:ui-monospace,Menlo,monospace;font-size:${FONT_PX}px;line-height:${LINE_PX}px;color:#888}`; + const js = `const DATA=${JSON.stringify(data)};const CFG=${JSON.stringify(cfg)};${runtime}`; + return `Strategy B — inlined vanilla-JS field-synth
`;
+}
+
+export function makeBaked() {
+  return bake();
+}
+
+// ── Node mirror of Strategy B's shipped evaluator (for cross-check only) ─────
+// Reproduces the exact per-cell math the inlined runtime performs, over the FULL
+// grid (index = row*COLS+col), so it can be diffed against the compositor grid.
+const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];
+function pmod(a: number, m: number): number { return ((a % m) + m) % m; }
+function bwave(k: string, t: number): number {
+  const p = t - Math.floor(t);
+  if (k === "triangle") return 4 * Math.abs(p - 0.5) - 1;
+  if (k === "saw") return 2 * p - 1;
+  if (k === "square") return p < 0.5 ? 1 : -1;
+  return Math.sin(t * Math.PI * 2);
+}
+function bh3(x: number, y: number, z: number): number { const h = Math.sin(x * 127.1 + y * 311.7 + z * 74.7) * 43758.5453; return h - Math.floor(h); }
+function bnoise3(x: number, y: number, z: number): number {
+  const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z), xf = x - xi, yf = y - yi, zf = z - zi;
+  const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf), w = zf * zf * (3 - 2 * zf);
+  const a000 = bh3(xi, yi, zi), a100 = bh3(xi + 1, yi, zi), a010 = bh3(xi, yi + 1, zi), a110 = bh3(xi + 1, yi + 1, zi);
+  const a001 = bh3(xi, yi, zi + 1), a101 = bh3(xi + 1, yi, zi + 1), a011 = bh3(xi, yi + 1, zi + 1), a111 = bh3(xi + 1, yi + 1, zi + 1);
+  const f0 = (a000 * (1 - u) + a100 * u) * (1 - v) + (a010 * (1 - u) + a110 * u) * v;
+  const f1 = (a001 * (1 - u) + a101 * u) * (1 - v) + (a011 * (1 - u) + a111 * u) * v;
+  return f0 * (1 - w) + f1 * w;
+}
+function bosc(o: { field: string; wave: string; freq: number; speed: number }, x: number, y: number, cx: number, cy: number, t: number): number {
+  if (o.field === "noise") return 2 * bnoise3(x * o.freq, y * o.freq, t * o.speed) - 1;
+  let raw: number;
+  switch (o.field) {
+    case "linearX": raw = x; break;
+    case "linearY": raw = y; break;
+    case "diagonal": raw = (x + y) * 0.70710678; break;
+    case "angular": raw = Math.atan2(y - cy, x - cx) / (Math.PI * 2); break;
+    case "spiral": raw = Math.hypot(x - cx, y - cy) + Math.atan2(y - cy, x - cx) / (Math.PI * 2); break;
+    default: raw = Math.hypot(x - cx, y - cy);
+  }
+  return bwave(o.wave, raw * o.freq - t * o.speed);
+}
+function bcombine(mode: string, a: number, b: number): number {
+  switch (mode) { case "add": return a + b; case "max": return Math.max(a, b); case "min": return Math.min(a, b); case "difference": return Math.abs(a - b); default: return a * b; }
+}
+function bthr(col: number, rw: number): number {
+  const x = col + 0.5, y = rw + 0.5, mx = Math.floor(4 * x), my = Math.floor(4 * y);
+  const coarse = BAYER[pmod(Math.floor(my / 4), 4) * 4 + pmod(Math.floor(mx / 4), 4)]!;
+  const fine = BAYER[pmod(my, 4) * 4 + pmod(mx, 4)]!;
+  return (16 * coarse + fine + 0.5) / 256;
+}
+
+export function evalStrategyBGrid(baked: Baked, time: number): string[] {
+  const cells = bakeCells(baked);
+  const P = FIELD_SYNTH_PARAMS;
+  const voices = [
+    { field: P.field1, wave: P.wave1, freq: P.freq1, speed: P.speed1, amp: P.amp1 },
+    { field: P.field2, wave: P.wave2, freq: P.freq2, speed: P.speed2, amp: P.amp2 },
+    { field: P.field3, wave: P.wave3, freq: P.freq3, speed: P.speed3, amp: P.amp3 },
+    { field: P.field4, wave: P.wave4, freq: P.freq4, speed: P.speed4, amp: P.amp4 },
+    { field: P.field5, wave: P.wave5, freq: P.freq5, speed: P.speed5, amp: P.amp5 },
+    { field: P.field6, wave: P.wave6, freq: P.freq6, speed: P.speed6, amp: P.amp6 },
+  ].filter((v) => v.amp > 0);
+  const ramp = Array.from(P.glyphs);
+  const rmax = ramp.length - 1;
+  const out = new Array(COLS * ROWS).fill(" ");
+  const q = (n: number) => Math.round(n * 1000) / 1000; // match baked quantization
+  for (const c of cells) {
+    const x = q(c.x), y = q(c.y), cx = q(c.cx), cy = q(c.cy);
+    let combined = 0, active = 0;
+    for (const v of voices) {
+      const o = bosc(v, x, y, cx, cy, time);
+      if (active === 0) combined = v.amp * o; else combined += v.amp * (bcombine(P.combine, combined, o) - combined);
+      active++;
+    }
+    const value = Math.min(1, Math.max(0, P.bias + P.gain * combined * 0.5));
+    if (value <= 0) continue;
+    const cov = value;
+    if (!(cov >= 1 || cov > bthr(c.col, c.row))) continue;
+    out[c.row * COLS + c.col] = ramp[Math.min(rmax, Math.max(0, Math.round(value * rmax)))]!;
+  }
+  return out;
+}
diff --git a/bench/static-effect-export/verify.mjs b/bench/static-effect-export/verify.mjs
new file mode 100644
index 00000000..dca6b917
--- /dev/null
+++ b/bench/static-effect-export/verify.mjs
@@ -0,0 +1,77 @@
+// Verify both export strategies render + animate in headless Chromium.
+// Screenshots two frames ~1.5s apart, reports a frame-diff, and for Strategy B
+// proves it renders+animates with ALL network blocked (offline).
+//
+// Screenshots are written next to this file under ./shots/.
+import { chromium } from "playwright";
+import { writeFileSync, mkdirSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const DIR = resolve(here, "out");
+const OUT = resolve(here, "shots");
+mkdirSync(OUT, { recursive: true });
+
+// crude byte-level diff of two PNG buffers — enough to prove "the frame changed".
+function pngDiff(a, b) {
+  const n = Math.min(a.length, b.length);
+  let diff = 0;
+  for (let i = 0; i < n; i++) if (a[i] !== b[i]) diff++;
+  return diff / n + Math.abs(a.length - b.length) / Math.max(a.length, b.length);
+}
+
+async function shoot(page, file, tag, { offline = false, blockNet = false } = {}) {
+  if (blockNet) {
+    await page.route("**/*", (route) => {
+      const u = route.request().url();
+      if (u.startsWith("file://") || u === "about:blank") return route.continue();
+      return route.abort();
+    });
+  }
+  if (offline) await page.context().setOffline(true);
+  const requests = [];
+  page.on("request", (r) => { if (!r.url().startsWith("file://")) requests.push(r.url()); });
+  await page.goto("file://" + resolve(DIR, file));
+  await page.waitForTimeout(400);
+  const s1 = await page.screenshot();
+  writeFileSync(resolve(OUT, `${tag}-1.png`), s1);
+  await page.waitForTimeout(1500);
+  const s2 = await page.screenshot();
+  writeFileSync(resolve(OUT, `${tag}-2.png`), s2);
+  const glyphs = await page.evaluate(() => {
+    const el = document.querySelector("#g, .glyph-output");
+    const txt = (el?.textContent || "");
+    return [...txt].filter((c) => c !== " " && c !== "\n").length;
+  });
+  const d = pngDiff(s1, s2);
+  console.log(`[${tag}] glyphs=${glyphs}  frameDiff=${(d * 100).toFixed(2)}%  offRequests=${requests.length}`);
+  return { glyphs, diff: d, requests };
+}
+
+const browser = await chromium.launch();
+const view = { viewport: { width: 900, height: 500 } };
+
+const pA = await browser.newPage(view);
+const rA = await shoot(pA, "strategyA-24.html", "A");
+await pA.close();
+
+const pB = await browser.newPage(view);
+const rB = await shoot(pB, "strategyB.html", "B");
+await pB.close();
+
+const pBoff = await browser.newPage(view);
+const rBoff = await shoot(pBoff, "strategyB.html", "B-offline", { offline: true, blockNet: true });
+await pBoff.close();
+
+await browser.close();
+
+console.log("\nVERDICT:", JSON.stringify({
+  A_renders: rA.glyphs > 500,
+  A_animates: rA.diff > 0.002,
+  B_renders: rB.glyphs > 500,
+  B_animates: rB.diff > 0.002,
+  B_offline_renders: rBoff.glyphs > 500,
+  B_offline_animates: rBoff.diff > 0.002,
+  B_offline_zero_requests: rBoff.requests.length === 0,
+}, null, 2));
diff --git a/packages/effects/src/index.ts b/packages/effects/src/index.ts
index b625faa7..7f3897ed 100644
--- a/packages/effects/src/index.ts
+++ b/packages/effects/src/index.ts
@@ -3,9 +3,11 @@ export {
   GlyphEffectNoColor,
   GlyphEffects,
   GlyphRamps,
+  combineSynth,
   defaultGlyphEffectParams,
   getGlyphEffect,
   glyphEffectHasColor,
+  synthWave,
 } from "./stock";
 
 export {
@@ -26,3 +28,13 @@ export type {
   GlyphStockEffect,
   GlyphStockEffectDefinition,
 } from "./stock";
+
+// Static effect export — bake an effect-only, static-camera scene into a
+// self-contained pen (inlined vanilla-JS evaluator, zero glyphcss at runtime).
+// Field-synth only for now; see the module doc for what generalizing needs.
+export { buildGlyphFieldSynthStaticExport } from "./staticExport";
+export type {
+  GlyphFieldSynthStaticExportEffect,
+  GlyphFieldSynthStaticExportOptions,
+  GlyphFieldSynthStaticExportResult,
+} from "./staticExport";
diff --git a/packages/effects/src/staticExport.test.ts b/packages/effects/src/staticExport.test.ts
new file mode 100644
index 00000000..865d2cc0
--- /dev/null
+++ b/packages/effects/src/staticExport.test.ts
@@ -0,0 +1,246 @@
+import { describe, expect, it } from "vitest";
+import { spherePolygons, type Polygon } from "glyphcss";
+import { buildGlyphFieldSynthStaticExport, type GlyphFieldSynthStaticExportOptions } from "./staticExport";
+
+function mesh(): Polygon[] {
+  return spherePolygons({ center: [0, 0, 0], size: 4, subdivisions: 1, color: "#8fb3d9" });
+}
+
+// Mirrors website/src/components/SynthWorkbench/SynthWorkbench.tsx `flatQuad`
+// (the /synth page's default "plane" shape): a flat square in the world XY
+// plane, z=0, linear 0..1 UVs across the whole quad — the case whose
+// per-cell field-synth domain coordinate is an exact affine function of
+// (col,row).
+function planeMesh(size = 3): Polygon[] {
+  return [{
+    vertices: [[-size, -size, 0], [size, -size, 0], [size, size, 0], [-size, size, 0]],
+    uvs: [[0, 0], [1, 0], [1, 1], [0, 1]],
+    color: "#8fb3d9",
+  }];
+}
+
+function baseOptions(overrides: Partial = {}): GlyphFieldSynthStaticExportOptions {
+  return {
+    params: {
+      space: "surface",
+      scale: 2.5,
+      field1: "radial", wave1: "sin", freq1: 6, speed1: 0.5, amp1: 1,
+      field2: "angular", wave2: "saw", freq2: 4, speed2: 0.3, amp2: 0.6,
+      combine: "multiply",
+      glyphs: " .:-=+*#%@",
+      color: "#7df9ff",
+      colorB: "#ff4fa3",
+      gradient: 0.5,
+    },
+    blend: "replace",
+    loopSeconds: 4,
+    cols: 24,
+    rows: 12,
+    rotX: 62,
+    rotY: 38,
+    zoom: 3,
+    ...overrides,
+  };
+}
+
+describe("buildGlyphFieldSynthStaticExport", () => {
+  it("rejects an unsupported effect id", () => {
+    expect(() => buildGlyphFieldSynthStaticExport(mesh(), { ...baseOptions(), effect: "matrix-rain" as never }))
+      .toThrow(/field-synth/);
+  });
+
+  it("rejects a non-positive grid or loop", () => {
+    expect(() => buildGlyphFieldSynthStaticExport(mesh(), baseOptions({ cols: 0 }))).toThrow();
+    expect(() => buildGlyphFieldSynthStaticExport(mesh(), baseOptions({ rows: 0 }))).toThrow();
+    expect(() => buildGlyphFieldSynthStaticExport(mesh(), baseOptions({ loopSeconds: 0 }))).toThrow();
+  });
+
+  it("produces a non-empty base frame", () => {
+    const result = buildGlyphFieldSynthStaticExport(mesh(), baseOptions());
+    const dataMatch = result.js.match(/var DATA=(\{.*?\});var CFG=/);
+    expect(dataMatch).not.toBeNull();
+    const data = JSON.parse(dataMatch![1]!) as { c: number[]; r: number[]; x: number[]; bg: string[] };
+    expect(data.c.length).toBeGreaterThan(0);
+    expect(data.c.length).toBe(data.r.length);
+    expect(data.c.length).toBe(data.x.length);
+    expect(data.bg.some((g) => g !== " ")).toBe(true);
+  });
+
+  it("is fully self-contained: no imports, no network URLs, no @glyphcss package reference", () => {
+    const result = buildGlyphFieldSynthStaticExport(mesh(), baseOptions());
+    for (const source of [result.html, result.css, result.js, result.pen.html, result.pen.css, result.pen.js]) {
+      expect(source).not.toMatch(/\bimport\s/);
+      expect(source).not.toMatch(/\brequire\(/);
+      expect(source).not.toMatch(/https?:\/\//);
+      expect(source).not.toMatch(/@glyphcss/);
+    }
+    // The JS payload specifically must never mention the `glyphcss` package
+    // itself (a title like "glyphcss field synth" in the HTML doc is fine —
+    // it's copy, not a dependency).
+    expect(result.js).not.toMatch(/\bglyphcss\b/);
+  });
+
+  it("inlines the field-synth math and an animation loop, with no glyphcss import", () => {
+    const result = buildGlyphFieldSynthStaticExport(mesh(), baseOptions());
+    expect(result.js).toContain("requestAnimationFrame");
+    // Oscillator / combine / dither primitives — the hand-written vanilla-JS port.
+    expect(result.js).toContain("function osc(");
+    expect(result.js).toContain("function combine(");
+    expect(result.js).toContain("function noise3(");
+    expect(result.js).toContain("function thr(");
+    expect(result.html).toContain("
");
+  });
+
+  it("bakes the supplied params (loop seconds, ramp, combine mode) into CFG", () => {
+    const result = buildGlyphFieldSynthStaticExport(mesh(), baseOptions({ loopSeconds: 7.5 }));
+    const cfgMatch = result.js.match(/var CFG=(\{.*\});\s*"use strict"/s);
+    expect(cfgMatch).not.toBeNull();
+    const cfg = JSON.parse(cfgMatch![1]!) as {
+      loop: number;
+      combine: string;
+      ramp: string[];
+      blend: string;
+      voices: { amp: number }[];
+    };
+    expect(cfg.loop).toBe(7.5);
+    expect(cfg.combine).toBe("multiply");
+    expect(cfg.ramp.join("")).toBe(" .:-=+*#%@");
+    // Only voices with amp > 0 are shipped (field1 + field2 above; the other four defaulted to amp 0).
+    expect(cfg.voices.length).toBe(2);
+  });
+
+  it("reads the REAL mounted blend verbatim instead of the effect definition's own defaultBlend", () => {
+    const replaceResult = buildGlyphFieldSynthStaticExport(mesh(), baseOptions({ blend: "replace" }));
+    const overResult = buildGlyphFieldSynthStaticExport(mesh(), baseOptions({ blend: "over" }));
+    expect(replaceResult.js).toMatch(/"blend":"replace"/);
+    expect(overResult.js).toMatch(/"blend":"over"/);
+  });
+
+  it("respects useColors: false by emitting plain-text output with no color spans", () => {
+    const result = buildGlyphFieldSynthStaticExport(mesh(), baseOptions({ useColors: false }));
+    expect(result.js).toMatch(/"useColors":false/);
+    expect(result.js).toContain("pre.textContent=text");
+  });
+
+  it("throws when field-synth's own param validation rejects the patch", () => {
+    expect(() => buildGlyphFieldSynthStaticExport(mesh(), baseOptions({ params: { glyphs: "" } }))).toThrow();
+  });
+
+  it("a flat, head-on, fully-covered plane drops the per-cell table entirely: no DATA at all", () => {
+    const result = buildGlyphFieldSynthStaticExport(planeMesh(), baseOptions({
+      rotX: 0,
+      rotY: 0,
+      zoom: 150,
+      cols: 24,
+      rows: 12,
+    }));
+    // A flat, evenly-lit, fully-covered, head-on plane hoists every per-cell
+    // field it bakes (coords → affine scalars, shade → a constant, cx/cy →
+    // constants, col/row → derivable from the loop index, base → unread) —
+    // nothing is left for `DATA` to carry, so `var DATA=` is omitted
+    // entirely rather than emitted as `{}`.
+    expect(result.js).not.toMatch(/var DATA=/);
+    expect(result.js).toMatch(/^var CFG=/);
+
+    const cfgMatch = result.js.match(/var CFG=(\{.*\});\s*"use strict"/s);
+    expect(cfgMatch).not.toBeNull();
+    const cfg = JSON.parse(cfgMatch![1]!) as {
+      aff: number[] | null; skipBase: boolean; full: boolean; cols: number;
+      shFixed: boolean; sh: number; cxFixed: boolean; cyFixed: boolean;
+    };
+
+    // 6 fitted scalars driving `x = aff[0]*col + aff[1]*row + aff[2]`
+    // (and the `y` analogue) at runtime instead of a per-cell table.
+    expect(cfg.aff).not.toBeNull();
+    expect(cfg.aff).toHaveLength(6);
+    expect(cfg.aff!.every((n) => Number.isFinite(n))).toBe(true);
+    expect(cfg.skipBase).toBe(true);
+    expect(cfg.full).toBe(true);
+    expect(cfg.cols).toBe(24);
+    expect(cfg.shFixed).toBe(true);
+    expect(cfg.cxFixed).toBe(true);
+    expect(cfg.cyFixed).toBe(true);
+    expect(result.js).toContain("var col0=C.full?k%C.cols:D.c[k],row0=C.full?(k/C.cols)|0:D.r[k];");
+    expect(result.js).toContain("C.aff?C.aff[0]*col0+C.aff[1]*row0+C.aff[2]:D.x[k]");
+    expect(result.js).toContain("typeof DATA<\"u\"?DATA:0");
+  });
+
+  it("a curved surface (sphere) keeps the baked per-cell coordinate AND index table — never mis-detected as affine or full", () => {
+    const result = buildGlyphFieldSynthStaticExport(mesh(), baseOptions());
+    const dataMatch = result.js.match(/var DATA=(\{.*?\});var CFG=/);
+    expect(dataMatch).not.toBeNull();
+    const data = JSON.parse(dataMatch![1]!) as { c: number[]; r: number[]; x: number[]; y: number[]; bg: string[]; bc: number[] };
+    const cfgMatch = result.js.match(/var CFG=(\{.*\});\s*"use strict"/s);
+    const cfg = JSON.parse(cfgMatch![1]!) as { aff: number[] | null; skipBase: boolean; full: boolean };
+
+    expect(cfg.aff).toBeNull();
+    expect(cfg.skipBase).toBe(false);
+    expect(cfg.full).toBe(false);
+    // Sparse (silhouette-gapped) bakes genuinely need the per-cell index
+    // table — there's no formula for "which cells are covered".
+    expect(Array.isArray(data.c)).toBe(true);
+    expect(Array.isArray(data.r)).toBe(true);
+    expect(data.c.length).toBeGreaterThan(0);
+    expect(data.c.length).toBe(data.r.length);
+    expect(Array.isArray(data.x)).toBe(true);
+    expect(Array.isArray(data.y)).toBe(true);
+    expect(data.x.length).toBeGreaterThan(0);
+    expect(Array.isArray(data.bg)).toBe(true);
+    expect(Array.isArray(data.bc)).toBe(true);
+  });
+
+  it("a partially-covered plane (not filling the grid) keeps the index table and base grid even though coordinates are still affine", () => {
+    const result = buildGlyphFieldSynthStaticExport(planeMesh(), baseOptions({
+      rotX: 0,
+      rotY: 0,
+      zoom: 40, // small enough that the quad doesn't fill the 24x12 grid
+      cols: 24,
+      rows: 12,
+    }));
+    const dataMatch = result.js.match(/var DATA=(\{.*?\});var CFG=/);
+    expect(dataMatch).not.toBeNull();
+    const data = JSON.parse(dataMatch![1]!) as Record;
+    const cfgMatch = result.js.match(/var CFG=(\{.*\});\s*"use strict"/s);
+    const cfg = JSON.parse(cfgMatch![1]!) as { aff: number[] | null; skipBase: boolean; full: boolean };
+
+    // Coordinates are still an exact affine function of (col,row) on a flat
+    // plane regardless of how much of the grid it covers.
+    expect(cfg.aff).not.toBeNull();
+    expect(data.x).toBeUndefined();
+    // But it's not fully covered, so the index table stays (col/row can't be
+    // derived from the loop index without a formula for which cells exist).
+    expect(cfg.full).toBe(false);
+    expect(Array.isArray(data.c)).toBe(true);
+    expect((data.c as unknown[]).length).toBeGreaterThan(0);
+    // The base is genuinely needed here too (uncovered cells fall back to
+    // it), so it must NOT be skipped.
+    expect(cfg.skipBase).toBe(false);
+    expect(data.bg).toBeDefined();
+    expect(data.bc).toBeDefined();
+  });
+
+  it("opacity < 1 with a fully-covered `replace` plane drops the index table (still fully covered) but keeps the base grid (input genuinely still shows through)", () => {
+    const result = buildGlyphFieldSynthStaticExport(planeMesh(), baseOptions({
+      rotX: 0,
+      rotY: 0,
+      zoom: 150,
+      cols: 24,
+      rows: 12,
+      opacity: 0.5,
+    }));
+    const dataMatch = result.js.match(/var DATA=(\{.*?\});var CFG=/);
+    expect(dataMatch).not.toBeNull();
+    const data = JSON.parse(dataMatch![1]!) as Record;
+    const cfgMatch = result.js.match(/var CFG=(\{.*\});\s*"use strict"/s);
+    const cfg = JSON.parse(cfgMatch![1]!) as { skipBase: boolean; full: boolean };
+
+    expect(cfg.full).toBe(true);
+    expect(cfg.skipBase).toBe(false);
+    // Fully covered, so the index table is still droppable independent of
+    // whether the base is skipped.
+    expect(data.c).toBeUndefined();
+    expect(data.r).toBeUndefined();
+    expect(data.bg).toBeDefined();
+    expect(data.bc).toBeDefined();
+  });
+});
diff --git a/packages/effects/src/staticExport.ts b/packages/effects/src/staticExport.ts
new file mode 100644
index 00000000..644aa6a4
--- /dev/null
+++ b/packages/effects/src/staticExport.ts
@@ -0,0 +1,642 @@
+/**
+ * Static effect export — bakes an "effect only, static camera" scene into a
+ * self-contained pen (one HTML document, no imports, no CDN, no
+ * `glyphcss`/`@glyphcss/*` at runtime). Productionizes the strategy proven in
+ * `bench/static-effect-export.md` (Strategy B: minimal inlined vanilla-JS):
+ * bake the static base grid + each covered cell's resolved effect-domain
+ * coordinate ONCE, then ship a tiny hand-written evaluator that recomputes the
+ * pattern every `requestAnimationFrame` — smaller and smoother than a
+ * prebaked-frame `steps()` export for anything past a handful of frames, and
+ * unlike the live runtime, ships zero library code.
+ *
+ * The bake step reuses glyphcss's real, pure render + effect-input machinery
+ * (`buildRasterizeContext`, `rasterize`, `retainGlyphEffectOutput`) and this
+ * package's own field-synth coordinate resolution (`fieldSynthCoordinate` +
+ * friends, exported from `./stock` for exactly this reuse) — so the baked
+ * per-cell coordinates are byte-identical to what a mounted `` would read, not a hand-copied
+ * approximation. Only the PER-FRAME oscillator math is duplicated as plain JS
+ * text, because that is the entire point of the deliverable: a standalone
+ * evaluator with no glyphcss import.
+ *
+ * Scope: field-synth only. Generalizing to another stock effect needs two more
+ * things per effect id: (1) that effect's own coordinate resolver exported the
+ * same way fieldSynthCoordinate is, and (2) a hand-written inlined JS port of
+ * its per-cell math (there is no way to ship an arbitrary GlyphEffectProgram's
+ * `evaluate()` without shipping a JS engine's worth of glyphcss around it).
+ *
+ * Two payload cuts beyond the bench's baseline (see `detectAffineCoords` /
+ * `skipBase` in `buildRuntime`): a flat, head-on surface with a linear UV map
+ * (e.g. the `/synth` page's default fullscreen plane) makes every cell's
+ * domain coordinate an exact affine function of (col,row), so the per-cell
+ * coordinate table — the bulk of the payload — is replaced with 6 fitted
+ * scalars and a one-line formula; and when the effect covers every cell of
+ * the grid with `blend:"replace"` at full opacity, the baked base glyph/color
+ * grid is provably never read by the compositor and is skipped too. Curved or
+ * projected surfaces (cube, sphere, a tilted plane, …) fail the affine
+ * residual check and keep the table, unchanged from before.
+ */
+import {
+  buildRasterizeContext,
+  rasterize,
+  retainGlyphEffectOutput,
+  parseGlyphEffectColor,
+  type CellGrid,
+  type GlyphAmbientLight,
+  type GlyphCamera,
+  type GlyphDirectionalLight,
+  type GlyphEffectBlend,
+  type GlyphEffectCoordinates,
+  type GlyphEffectOutputMetadata,
+  type GlyphEffectParamsOf,
+  type GlyphEffectScratchView,
+  type Polygon,
+  type RenderMode,
+  type RetainedGlyphEffectOutput,
+  createGlyphOrthographicCamera,
+  createGlyphPerspectiveCamera,
+  DEFAULT_PERSPECTIVE,
+} from "glyphcss";
+import {
+  fieldSynth,
+  findUvBounds,
+  generatedSurfaceField,
+  fieldSynthCoordinate,
+  defaultGlyphEffectParams,
+  SYNTH_VOICES,
+  type AnyContext,
+  type AnyParams,
+  type EffectSpace,
+} from "./stock";
+
+export type GlyphFieldSynthStaticExportEffect = "field-synth";
+
+export interface GlyphFieldSynthStaticExportOptions {
+  /** Which stock effect to export. Only `"field-synth"` is supported today (see module doc). */
+  effect?: GlyphFieldSynthStaticExportEffect;
+  /** field-synth params (a partial patch over its own defaults — same shape the live layer takes). `time` is ignored (the export always starts its own clock at 0). */
+  params: Partial>;
+  /** The blend the layer is ACTUALLY mounted with. Read verbatim — never defaulted from the effect definition's `defaultBlend` metadata, since `over` vs `replace` changes the composited look. */
+  blend: GlyphEffectBlend;
+  /** Effect layer opacity, matching `GlyphEffectLayerCommonOptions.opacity`. Default 1. */
+  opacity?: number;
+  /** Seconds before the client-side clock wraps (`time = (now/1000) % loopSeconds`). Longer = smoother-feeling for a slow patch, no payload cost either way (the baked table is time-independent). */
+  loopSeconds: number;
+  cols: number;
+  rows: number;
+  mode?: RenderMode;
+  cellAspect?: number;
+  useColors?: boolean;
+  rotX?: number;
+  rotY?: number;
+  zoom?: number;
+  projection?: "orthographic" | "perspective";
+  perspectivePx?: number;
+  fontSizePx?: number;
+  lineHeightPx?: number;
+  directionalLight?: GlyphDirectionalLight;
+  ambientLight?: GlyphAmbientLight;
+  title?: string;
+}
+
+export interface GlyphFieldSynthStaticExportResult {
+  /** Self-contained `…` document. */
+  html: string;
+  css: string;
+  js: string;
+  /** Split for CodePen prefill (mirrors `GlyphInteractiveExportResult.pen`). */
+  pen: { html: string; css: string; js: string };
+}
+
+const EMPTY_SCRATCH: GlyphEffectScratchView = {
+  images: [],
+  floatFields: [],
+  uintFields: [],
+  glyphFields: [],
+  samples: [],
+};
+
+function clamp01(value: number): number {
+  return value < 0 ? 0 : value > 1 ? 1 : value;
+}
+
+// Mirrors createGlyphScene's identical bbox→worldToSceneScale computation
+// (packages/glyphcss/src/api/createGlyphScene.ts) — plain bounding-box
+// arithmetic, not effect math, so a local copy carries none of the
+// silent-divergence risk the surface-basis functions would.
+function computeWorldToSceneScale(polygons: readonly Polygon[], cols: number, rows: number): number {
+  let minX = Infinity, minY = Infinity, minZ = Infinity;
+  let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
+  for (const polygon of polygons) {
+    for (const vertex of polygon.vertices) {
+      if (vertex[0] < minX) minX = vertex[0];
+      if (vertex[1] < minY) minY = vertex[1];
+      if (vertex[2] < minZ) minZ = vertex[2];
+      if (vertex[0] > maxX) maxX = vertex[0];
+      if (vertex[1] > maxY) maxY = vertex[1];
+      if (vertex[2] > maxZ) maxZ = vertex[2];
+    }
+  }
+  const span = Math.max(maxX - minX, maxY - minY, maxZ - minZ);
+  return Number.isFinite(span) && span > 1e-9 ? Math.min(cols, rows) / span : 1;
+}
+
+function buildCamera(options: GlyphFieldSynthStaticExportOptions): GlyphCamera {
+  if (options.projection === "perspective") {
+    return createGlyphPerspectiveCamera({
+      rotX: options.rotX,
+      rotY: options.rotY,
+      zoom: options.zoom,
+      perspective: options.perspectivePx ?? DEFAULT_PERSPECTIVE,
+    });
+  }
+  return createGlyphOrthographicCamera({ rotX: options.rotX, rotY: options.rotY, zoom: options.zoom });
+}
+
+interface BakedCell {
+  col: number;
+  row: number;
+  x: number;
+  y: number;
+  cx: number;
+  cy: number;
+  shade: number;
+  glyph: string;
+  /** Packed 24-bit RGB, or `-1` for "no color" (matches the runtime's sentinel). */  color: number;
+}
+
+interface Baked {
+  cells: BakedCell[];
+  cols: number;
+  rows: number;
+}
+
+// Runs the REAL rasterizer + retains the base frame in the exact shape a
+// mounted effect layer's evaluate() reads (GlyphEffectFrameView + coverage),
+// then resolves each covered cell's field-synth domain coordinate with the
+// package's own (real, tested) fieldSynthCoordinate — not a re-derivation.
+function bake(
+  polygons: Polygon[],
+  options: GlyphFieldSynthStaticExportOptions,
+  params: GlyphEffectParamsOf,
+): Baked {
+  const mode: RenderMode = options.mode ?? "solid";
+  // AGENTS.md: worldPosition/normal/shade retention is solid-mode-only.
+  const retainOptional = mode === "solid";
+  const camera = buildCamera(options);
+  const worldToSceneScale = retainOptional
+    ? computeWorldToSceneScale(polygons, options.cols, options.rows)
+    : undefined;
+
+  const metadata: GlyphEffectOutputMetadata = {
+    id: "static-export",
+    // Never dereferenced: retainGlyphEffectOutput / fieldSynthCoordinate only
+    // read the geometry fields below; `.pre` only matters to a real DOM write,
+    // which this pure builder never performs.
+    pre: null as unknown as HTMLPreElement,
+    isBase: true,
+    cellToSceneGrid: [1, 0, 0, 1, 0, 0],
+    sceneGridSize: [options.cols, options.rows],
+    localCellFootprint: [1, 1],
+    ...(worldToSceneScale !== undefined ? { worldToSceneScale } : {}),
+  };
+
+  let retained: RetainedGlyphEffectOutput | null = null;
+  const ctx = buildRasterizeContext({
+    camera,
+    grid: { cols: options.cols, rows: options.rows, cellAspect: options.cellAspect ?? 2 },
+    polygons,
+    mode,
+    directionalLight: options.directionalLight ?? { direction: [0.5, 0.7, 0.5], intensity: 1 },
+    ambientLight: options.ambientLight ?? { intensity: 0.4 },
+    useColors: options.useColors ?? true,
+    retainShade: retainOptional,
+    retainWorldPosition: retainOptional,
+    retainNormal: retainOptional,
+  });
+  ctx.transformCells = (grid: CellGrid) => {
+    retained = retainGlyphEffectOutput(grid, metadata);
+    return grid;
+  };
+  rasterize(ctx);
+  if (retained === null) throw new Error("glyphcss: static field-synth export — rasterize did not produce a base frame.");
+  const baked: RetainedGlyphEffectOutput = retained;
+
+  const coordinates: GlyphEffectCoordinates = {
+    cellToSceneGrid: metadata.cellToSceneGrid,
+    sceneGridSize: metadata.sceneGridSize,
+    localCellFootprint: metadata.localCellFootprint,
+    ...(metadata.worldToSceneScale !== undefined ? { worldToSceneScale: metadata.worldToSceneScale } : {}),
+  };
+  const context: AnyContext = {
+    params: params as unknown as AnyParams,
+    state: undefined,
+    base: baked.base,
+    input: baked.base,
+    target: { coverage: baked.baseCoverage },
+    coordinates,
+    scratch: EMPTY_SCRATCH,
+    output: baked.emission,
+  };
+
+  const uvBounds = findUvBounds(context);
+  const [sceneCols, sceneRows] = coordinates.sceneGridSize;
+  const generatedSurface = params.space !== "scene" && !(params.space === "auto" && uvBounds)
+    ? generatedSurfaceField(context)
+    : undefined;
+
+  const cells: BakedCell[] = [];
+  const n = baked.base.length;
+  for (let i = 0; i < n; i++) {
+    if (baked.baseCoverage[i]! <= 0) continue;
+    const coord = fieldSynthCoordinate(
+      context,
+      i,
+      params.space as EffectSpace,
+      uvBounds,
+      params.scale,
+      params.originU,
+      params.originV,
+      sceneCols,
+      sceneRows,
+      generatedSurface,
+    );
+    if (!coord) continue;
+    const [x, y, cx, cy] = coord;
+    const shadeArr = baked.base.shade;
+    const sh = shadeArr ? shadeArr[i]! : Number.NaN;
+    const glyph = baked.baseGrid.char[i] ?? " ";
+    const colorHex = baked.baseGrid.color[i] ?? null;
+    cells.push({
+      col: i % sceneCols,
+      row: (i / sceneCols) | 0,
+      x,
+      y,
+      cx,
+      cy,
+      shade: Number.isFinite(sh) ? sh : 1,
+      glyph,
+      color: colorHex ? parseGlyphEffectColor(colorHex).packed : -1,
+    });
+  }
+  return { cells, cols: options.cols, rows: options.rows };
+}
+
+interface AffineCoordFit {
+  ax: number; bx: number; ecx: number;
+  ay: number; by: number; ecy: number;
+}
+
+// A flat, head-on surface with a linear UV map (the /synth page's default
+// fullscreen plane, and any other surface whose per-cell domain coordinate
+// happens to come out affine) needs no per-cell coordinate table at all: the
+// per-cell (x,y) is an exact affine function of grid (col,row), so the
+// runtime can recompute it from 6 fitted scalars instead of shipping one
+// float pair per cell — the ~86% of the payload the bench in
+// bench/static-effect-export.md attributes to that table. Curved or
+// perspective-projected surfaces (cube, sphere, a tilted/foreshortened
+// plane, …) are NOT globally affine in cell space and must keep the baked
+// table; the fit below detects that mechanically rather than assuming it
+// from the shape name, so any surface that happens to be affine benefits and
+// nothing else is misdetected.
+//
+// Least-squares fit of (col,row) → x and (col,row) → y shares one 3×3
+// normal-equations solve (both regressions have the same design matrix).
+// "Affine within epsilon" requires the max PER-CELL residual — not an
+// average — to be under AFFINE_EPSILON: an average-error check would accept
+// a surface that's affine almost everywhere but curves at the edges, which
+// would silently corrupt exactly those cells once the table is dropped.
+const AFFINE_EPSILON = 1e-3;
+
+function detectAffineCoords(points: { col: number; row: number; x: number; y: number }[]): AffineCoordFit | null {
+  const n = points.length;
+  if (n < 3) return null;
+  let Scc = 0, Scr = 0, Sc = 0, Srr = 0, Sr = 0;
+  let Sxc = 0, Sxr = 0, Sx = 0, Syc = 0, Syr = 0, Sy = 0;
+  for (const p of points) {
+    Scc += p.col * p.col; Scr += p.col * p.row; Sc += p.col;
+    Srr += p.row * p.row; Sr += p.row;
+    Sxc += p.col * p.x; Sxr += p.row * p.x; Sx += p.x;
+    Syc += p.col * p.y; Syr += p.row * p.y; Sy += p.y;
+  }
+  const m00 = Scc, m01 = Scr, m02 = Sc;
+  const m10 = Scr, m11 = Srr, m12 = Sr;
+  const m20 = Sc, m21 = Sr, m22 = n;
+  const det =
+    m00 * (m11 * m22 - m12 * m21)
+    - m01 * (m10 * m22 - m12 * m20)
+    + m02 * (m10 * m21 - m11 * m20);
+  // Singular normal matrix — e.g. a 1-row/1-col grid, or too few points to
+  // pin down 3 unknowns per axis. Keep the table; there's nothing to fit.
+  if (Math.abs(det) < 1e-9) return null;
+  const solve = (r0: number, r1: number, r2: number): [number, number, number] => {
+    const dA =
+      r0 * (m11 * m22 - m12 * m21)
+      - m01 * (r1 * m22 - m12 * r2)
+      + m02 * (r1 * m21 - m11 * r2);
+    const dB =
+      m00 * (r1 * m22 - m12 * r2)
+      - r0 * (m10 * m22 - m12 * m20)
+      + m02 * (m10 * r2 - r1 * m20);
+    const dC =
+      m00 * (m11 * r2 - r1 * m21)
+      - m01 * (m10 * r2 - r1 * m20)
+      + r0 * (m10 * m21 - m11 * m20);
+    return [dA / det, dB / det, dC / det];
+  };
+  const [ax, bx, ecx] = solve(Sxc, Sxr, Sx);
+  const [ay, by, ecy] = solve(Syc, Syr, Sy);
+  for (const p of points) {
+    const px = ax * p.col + bx * p.row + ecx;
+    const py = ay * p.col + by * p.row + ecy;
+    if (Math.abs(px - p.x) >= AFFINE_EPSILON || Math.abs(py - p.y) >= AFFINE_EPSILON) return null;
+  }
+  return { ax, bx, ecx, ay, by, ecy };
+}
+
+function jsonForScript(value: unknown): string {
+  return JSON.stringify(value)
+    .replace(//g, "\\u003e")
+    .replace(/&/g, "\\u0026")
+    .replace(/\u2028/g, "\\u2028")
+    .replace(/\u2029/g, "\\u2029");
+}
+
+// Shared vanilla-JS runtime, generalized from the bench's validated
+// Strategy B evaluator: N active oscillators (not a fixed set), the general
+// blend formula (`over` OR `replace`, with opacity) instead of a
+// replace-only shortcut, and optional per-voice color mixing. Reproduces
+// `combineSynth`/`synthOsc`/`synthWave`/`synthNoise3`/`lerpPacked`/
+// `scalePackedColor` (packages/effects/src/stock.ts) and the compositor's
+// blend + Bayer coverage dither (packages/glyphcss/src/render/
+// effectCompositor.ts `blendPackedColor`/`coverageThreshold`) as plain JS —
+// this text is the one piece that can't be "reused" instead of hand-written,
+// since it has to run with zero glyphcss/effects code alongside it.
+const RUNTIME_JS = `
+"use strict";
+var D=typeof DATA<"u"?DATA:0,C=CFG,N=C.full?C.cols*C.rows:D.c.length,ramp=C.ramp,rmax=ramp.length-1,V=C.voices;
+var BAYER=[0,8,2,10,12,4,14,6,3,11,1,9,15,7,13,5];
+function pmod(a,m){return((a%m)+m)%m}
+function clamp01(v){return v<0?0:v>1?1:v}
+function wave(k,t){var p=t-Math.floor(t);if(k==="triangle")return 4*Math.abs(p-0.5)-1;if(k==="saw")return 2*p-1;if(k==="square")return p<0.5?1:-1;return Math.sin(t*Math.PI*2)}
+function h3(x,y,z){var h=Math.sin(x*127.1+y*311.7+z*74.7)*43758.5453;return h-Math.floor(h)}
+function noise3(x,y,z){var xi=Math.floor(x),yi=Math.floor(y),zi=Math.floor(z),xf=x-xi,yf=y-yi,zf=z-zi;
+var u=xf*xf*(3-2*xf),v=yf*yf*(3-2*yf),w=zf*zf*(3-2*zf);
+var a000=h3(xi,yi,zi),a100=h3(xi+1,yi,zi),a010=h3(xi,yi+1,zi),a110=h3(xi+1,yi+1,zi),a001=h3(xi,yi,zi+1),a101=h3(xi+1,yi,zi+1),a011=h3(xi,yi+1,zi+1),a111=h3(xi+1,yi+1,zi+1);
+var f0=(a000*(1-u)+a100*u)*(1-v)+(a010*(1-u)+a110*u)*v,f1=(a001*(1-u)+a101*u)*(1-v)+(a011*(1-u)+a111*u)*v;return f0*(1-w)+f1*w}
+function osc(o,x,y,cx,cy,t){if(o.field==="noise")return 2*noise3(x*o.freq,y*o.freq,t*o.speed)-1;var raw;
+switch(o.field){case"linearX":raw=x;break;case"linearY":raw=y;break;case"diagonal":raw=(x+y)*0.70710678;break;
+case"angular":raw=Math.atan2(y-cy,x-cx)/(Math.PI*2);break;case"spiral":raw=Math.hypot(x-cx,y-cy)+Math.atan2(y-cy,x-cx)/(Math.PI*2);break;default:raw=Math.hypot(x-cx,y-cy)}
+return wave(o.wave,raw*o.freq-t*o.speed)}
+function combine(a,b){switch(C.combine){case"add":return a+b;case"max":return Math.max(a,b);case"min":return Math.min(a,b);case"difference":return Math.abs(a-b);default:return a*b}}
+function lerp(a,b,t){var ar=(a>>16)&255,ag=(a>>8)&255,ab=a&255,br=(b>>16)&255,bg=(b>>8)&255,bb=b&255;
+return(Math.round(ar+(br-ar)*t)<<16)|(Math.round(ag+(bg-ag)*t)<<8)|Math.round(ab+(bb-ab)*t)}
+function shadeColor(p,s){var r=Math.round(((p>>16)&255)*s),g=Math.round(((p>>8)&255)*s),b=Math.round((p&255)*s);return(r<<16)|(g<<8)|b}
+function blendColor(input,emitted,iw,ew){var ip=input!==-1,ep=emitted!==-1;
+if(ip&&ep){var t=iw+ew;if(t<=0)return emitted;
+var r=Math.floor((((input>>16)&255)*iw+((emitted>>16)&255)*ew)/t+0.5),g=Math.floor((((input>>8)&255)*iw+((emitted>>8)&255)*ew)/t+0.5),b=Math.floor(((input&255)*iw+(emitted&255)*ew)/t+0.5);
+return(r<<16)|(g<<8)|b}
+if(ip!==ep)return ew>=iw?emitted:input;return-1}
+function thr(col,rw){var x=col+0.5,y=rw+0.5,mx=Math.floor(4*x),my=Math.floor(4*y);
+var coarse=BAYER[pmod(Math.floor(my/4),4)*4+pmod(Math.floor(mx/4),4)],fine=BAYER[pmod(my,4)*4+pmod(mx,4)];return(16*coarse+fine+0.5)/256}
+var pre=document.getElementById("g");
+var rowBuf=new Array(C.rows);
+function frame(now){var t=(now/1000)%C.loop;
+for(var i=0;i>16)&255,g=(c.p>>8)&255,b=c.p&255;
+cr+=r*w;cg+=g*w;cbv+=b*w;co+=c.o*w;cw+=w;car+=r*V[j].amp;cag+=g*V[j].amp;cabv+=b*V[j].amp;cao+=c.o*V[j].amp;caw+=V[j].amp}}
+var value=clamp01(C.bias+C.gain*combined*0.5);
+var packed=-1,resolvedOpacity=0;
+if(value>0){
+if(C.voiceColors&&cw>0){packed=(Math.round(cr/cw)<<16)|(Math.round(cg/cw)<<8)|Math.round(cbv/cw);resolvedOpacity=co/cw}
+else if(C.voiceColors&&caw>0){packed=(Math.round(car/caw)<<16)|(Math.round(cag/caw)<<8)|Math.round(cabv/caw);resolvedOpacity=cao/caw}
+else{packed=C.gradient>0?lerp(C.cA.p,C.cB.p,clamp01(value*C.gradient)):C.cA.p;resolvedOpacity=C.cA.o}
+if(C.lit>0)packed=shadeColor(packed,1-C.lit*(1-clamp01(sh)))}
+var emittedCoverage=value>0?clamp01(value*resolvedOpacity):0;
+var inputCoverage=C.skipBase?1:D.bg[k]!==" "?1:0;
+var emittedWeight=emittedCoverage*C.opacity;
+var inputWeight=C.blend==="over"?inputCoverage*(1-emittedWeight):inputCoverage*(1-C.opacity);
+var nextCoverage=clamp01(emittedWeight+inputWeight);
+var col=col0,rw=row0;
+var visible=nextCoverage>=1||(nextCoverage>0&&nextCoverage>thr(col,rw));
+if(!visible)continue;
+var chooseEmitted=emittedWeight>=inputWeight;
+var g=chooseEmitted?ramp[Math.min(rmax,Math.max(0,Math.round(value*rmax)))]:(C.skipBase?" ":D.bg[k]);
+var pk=blendColor(C.skipBase?-1:D.bc[k],packed,inputWeight,emittedWeight);
+rowBuf[rw].push([col,g,pk])}
+if(C.useColors){var html="";
+for(var r2=0;r2";line+=pk2===-1?"":"";open=true;prevColor=pk2}
+line+=gg;cur=cc}
+if(open)line+="";html+=line+"\\n"}
+pre.innerHTML=html}else{var text="";
+for(var r3=0;r3, options: GlyphFieldSynthStaticExportOptions): { js: string; gridCols: number; gridRows: number } {
+  let minCol = Infinity, maxCol = -Infinity, minRow = Infinity, maxRow = -Infinity;
+  for (const c of baked.cells) {
+    if (c.col < minCol) minCol = c.col;
+    if (c.col > maxCol) maxCol = c.col;
+    if (c.row < minRow) minRow = c.row;
+    if (c.row > maxRow) maxRow = c.row;
+  }
+  if (!Number.isFinite(minCol)) { minCol = 0; maxCol = -1; minRow = 0; maxRow = -1; }
+  const gridCols = Math.max(0, maxCol - minCol + 1);
+  const gridRows = Math.max(0, maxRow - minRow + 1);
+
+  const q = (n: number) => Math.round(n * 1000) / 1000;
+  const affine = detectAffineCoords(
+    baked.cells.map((c) => ({ col: c.col - minCol, row: c.row - minRow, x: c.x, y: c.y })),
+  );
+  // Plane-fill: every raster cell in `cols×rows` got baked — no silhouette
+  // gaps, the surface fills the whole viewport. `bake()` walks the grid in
+  // increasing row-major index order (`i = 0..cols*rows-1`, `col=i%cols,
+  // row=(i/cols)|0`) and only skips uncovered cells, so a full bake is
+  // guaranteed to still be in that exact row-major order — every (col,row)
+  // pair appears exactly once, in scan order. That means the per-cell `c`/`r`
+  // index arrays carry zero information beyond the loop counter: the runtime
+  // can derive them from `k` and `CFG.cols` instead of reading a table.
+  // Sparse bakes (silhouette gaps — e.g. a cube or sphere) keep the table;
+  // there's no formula for "which cells are covered".
+  const full = baked.cells.length === options.cols * options.rows;
+  // Plane-fill case, further restricted: the effect also fully overwrites
+  // the pixel (`replace` at opacity 1 means the base contributes EXACTLY
+  // zero weight to every composited cell: see `inputWeight` in RUNTIME_JS's
+  // `frame()` — `inputCoverage*(1-C.opacity)` is 0 whenever opacity is 1).
+  // The baked base glyph/color grid is then provably never read, so it's
+  // dropped entirely and the runtime hardcodes "no base" instead. Partial
+  // coverage, `over`, or opacity < 1 all mean the base genuinely still shows
+  // through — keep baking it.
+  const skipBase = full && options.blend === "replace" && clamp01(options.opacity ?? 1) === 1;
+
+  const col: number[] = [], row: number[] = [], X: number[] = [], Y: number[] = [];
+  const CXa: number[] = [], CYa: number[] = [], SH: number[] = [], BG: string[] = [], BC: number[] = [];
+  // `cx,cy` are per-coplanar-group constants; in the common single-facet /
+  // uv-space / scene-space cases they're a SINGLE constant across the whole
+  // bake — detected and hoisted to a scalar so the (usually large) per-cell
+  // arrays don't repeat it. Multi-facet meshes (several differently-oriented
+  // coplanar groups) keep the per-cell arrays; deduping those too by group id
+  // is a further, not-yet-implemented win (see module doc / AGENTS.md).
+  //
+  // Shade gets the same treatment: a single flat, evenly-lit facet (a
+  // directional light has no position, so Lambert shade only depends on the
+  // facet's normal) produces the exact same shade for every cell — hoist it
+  // to a scalar too instead of repeating it once per cell.
+  let cxFixed = true, cyFixed = true, shFixed = true;
+  const cx0 = baked.cells[0]?.cx ?? 0, cy0 = baked.cells[0]?.cy ?? 0;
+  const sh0 = Math.round((baked.cells[0]?.shade ?? 1) * 100) / 100;
+  for (const c of baked.cells) {
+    if (Math.abs(c.cx - cx0) > 1e-6) cxFixed = false;
+    if (Math.abs(c.cy - cy0) > 1e-6) cyFixed = false;
+    if (Math.round(c.shade * 100) / 100 !== sh0) shFixed = false;
+  }
+  for (const c of baked.cells) {
+    if (!full) { col.push(c.col - minCol); row.push(c.row - minRow); }
+    if (!affine) { X.push(q(c.x)); Y.push(q(c.y)); }
+    if (!cxFixed) CXa.push(q(c.cx));
+    if (!cyFixed) CYa.push(q(c.cy));
+    if (!shFixed) SH.push(Math.round(c.shade * 100) / 100);
+    if (!skipBase) { BG.push(c.glyph); BC.push(c.color); }
+  }
+  const data: Record = {};
+  if (!full) { data.c = col; data.r = row; }
+  if (!affine) { data.x = X; data.y = Y; }
+  if (!skipBase) { data.bg = BG; data.bc = BC; }
+  if (!cxFixed) data.cx = CXa;
+  if (!cyFixed) data.cy = CYa;
+  if (!shFixed) data.sh = SH;
+  const hasData = Object.keys(data).length > 0;
+
+  const voices: { field: string; wave: string; freq: number; speed: number; amp: number; color: { p: number; o: number } }[] = [];
+  for (let k = 1; k <= SYNTH_VOICES; k++) {
+    const amp = (params as unknown as AnyParams)[`amp${k}`] as number;
+    if (!(amp > 0)) continue;
+    const parsedColor = parseGlyphEffectColor((params as unknown as AnyParams)[`color${k}`] as string);
+    voices.push({
+      field: (params as unknown as AnyParams)[`field${k}`] as string,
+      wave: (params as unknown as AnyParams)[`wave${k}`] as string,
+      freq: (params as unknown as AnyParams)[`freq${k}`] as number,
+      speed: (params as unknown as AnyParams)[`speed${k}`] as number,
+      amp,
+      color: { p: parsedColor.packed, o: parsedColor.opacity },
+    });
+  }
+  const cA = parseGlyphEffectColor(params.color);
+  const cB = parseGlyphEffectColor(params.colorB);
+
+  const cfg = {
+    rows: gridRows,
+    loop: options.loopSeconds,
+    combine: params.combine,
+    gradient: params.gradient,
+    lit: params.lit,
+    gain: params.gain,
+    bias: params.bias,
+    ramp: Array.from(params.glyphs),
+    cA: { p: cA.packed, o: cA.opacity },
+    cB: { p: cB.packed, o: cB.opacity },
+    voiceColors: params.voiceColors,
+    voices,
+    blend: options.blend,
+    opacity: clamp01(options.opacity ?? 1),
+    useColors: options.useColors ?? true,
+    cxFixed,
+    cyFixed,
+    cx: cxFixed ? q(cx0) : 0,
+    cy: cyFixed ? q(cy0) : 0,
+    shFixed,
+    sh: shFixed ? sh0 : 0,
+    // Full precision here, NOT `q()`: these 6 scalars are multiplied by
+    // (col,row) — up to a few hundred — at runtime, so 3-decimal rounding
+    // (fine for a per-cell coordinate, where the error stays put) would get
+    // amplified by that multiplication into an error many times bigger than
+    // AFFINE_EPSILON. Six extra full-precision numbers cost a few dozen
+    // bytes; that's irrelevant next to the table this replaces.
+    aff: affine ? [affine.ax, affine.bx, affine.ecx, affine.ay, affine.by, affine.ecy] : null,
+    skipBase,
+    full,
+    // Only needed to derive (col,row) from the loop index `k` when `full`
+    // drops the `c`/`r` tables — omitted otherwise so a sparse bake doesn't
+    // pay for a field it never reads.
+    ...(full ? { cols: gridCols } : {}),
+  };
+
+  // `hasData`: the affine + skipBase + fixed-cx/cy + fixed-shade + full-grid
+  // combo (a flat, head-on, fully-covered plane) leaves nothing for `DATA` to
+  // carry — every per-cell field has been hoisted to a `CFG` scalar or is
+  // derivable from the loop index. `var DATA=...;` is then omitted entirely
+  // rather than emitted as an empty object; RUNTIME_JS's `typeof DATA<"u"`
+  // guard handles the identifier not existing (every subsequent `D.foo[k]`
+  // read is itself gated by the same `CFG` flag that made `DATA` droppable,
+  // so `D` is never dereferenced when it's `0`).
+  const js = `${hasData ? `var DATA=${jsonForScript(data)};` : ""}var CFG=${jsonForScript(cfg)};${RUNTIME_JS}`;
+  return { js, gridCols, gridRows };
+}
+
+/**
+ * Bake the current field-synth patch over a static-camera mesh into a
+ * self-contained pen: inlined per-cell coordinates + a tiny hand-written
+ * vanilla-JS evaluator, animated with `requestAnimationFrame`. Zero imports,
+ * zero CDN, zero `glyphcss`/`@glyphcss/*` at runtime.
+ */
+export function buildGlyphFieldSynthStaticExport(
+  polygons: Polygon[],
+  options: GlyphFieldSynthStaticExportOptions,
+): GlyphFieldSynthStaticExportResult {
+  if (options.effect !== undefined && options.effect !== "field-synth") {
+    throw new Error(
+      `glyphcss: buildGlyphFieldSynthStaticExport only supports the "field-synth" effect (got "${options.effect}"). `
+      + "Generalizing to another stock effect needs that effect's own coordinate resolver exported from " + "@glyphcss/effects and a hand-written inlined JS port of its per-cell math — see the module doc.",
+    );
+  }
+  if (!(options.cols > 0) || !(options.rows > 0)) {
+    throw new RangeError("glyphcss: buildGlyphFieldSynthStaticExport requires cols/rows > 0.");
+  }
+  if (!(options.loopSeconds > 0)) {
+    throw new RangeError("glyphcss: buildGlyphFieldSynthStaticExport requires loopSeconds > 0.");
+  }
+
+  const params = { ...defaultGlyphEffectParams(fieldSynth), ...options.params, time: 0 } as GlyphEffectParamsOf;
+  fieldSynth.program.validateParams?.(params);
+
+  const baked = bake(polygons, options, params);
+  const { js, gridCols, gridRows } = buildRuntime(baked, params, options);
+
+  const fontSizePx = options.fontSizePx ?? 12;
+  const lineHeightPx = options.lineHeightPx ?? fontSizePx;
+  const css = `html,body{margin:0;height:100%;background:#0b0d10;display:grid;place-items:center}
+#g{margin:0;white-space:pre;font-family:ui-monospace,Menlo,monospace;font-size:${fontSizePx}px;line-height:${lineHeightPx}px;color:#ccc;width:${gridCols}ch;height:${gridRows * lineHeightPx}px}`;
+  const title = options.title ?? "glyphcss field synth";
+  const bodyHtml = `
`;
+  const html = `${title}${bodyHtml}`;
+
+  return {
+    html,
+    css,
+    js,
+    pen: { html: bodyHtml, css, js },
+  };
+}
diff --git a/packages/effects/src/stock.ts b/packages/effects/src/stock.ts
index f9acfbe7..f03bff21 100644
--- a/packages/effects/src/stock.ts
+++ b/packages/effects/src/stock.ts
@@ -24,13 +24,16 @@ export interface GlyphStockEffectDefinition<
   readonly presets?: readonly GlyphEffectPreset[];
 }
 
-type AnyParams = Record;
-type AnyContext

= GlyphEffectEvaluateContext; +// Exported (alongside a handful of coordinate-resolution internals below) so +// `staticExport.ts`'s build-time baker can construct the same evaluate() +// context shape and reuse the real surface-basis math instead of copying it. +export type AnyParams = Record; +export type AnyContext

= GlyphEffectEvaluateContext; const GLYPH = GlyphEffectOutputChannel.Glyph; const COLOR = GlyphEffectOutputChannel.Color; -interface SurfaceMetricAccumulator { +export interface SurfaceMetricAccumulator { count: number; sumX: number; sumY: number; @@ -58,7 +61,7 @@ interface SurfaceMetricAccumulator { maxV: number; } -interface GeneratedSurfaceField { +export interface GeneratedSurfaceField { readonly normal: object; readonly cols: number; readonly rows: number; @@ -68,7 +71,10 @@ interface GeneratedSurfaceField { readonly groups: readonly SurfaceMetricAccumulator[]; } -type EffectSpace = "auto" | "surface" | "scene"; +// Exported so a build-time exporter (see `staticExport.ts`) can resolve the +// SAME domain coordinate a mounted effect layer's `evaluate()` reads, instead +// of re-deriving the surface-basis / coplanar-group math by hand. +export type EffectSpace = "auto" | "surface" | "scene"; type EffectDirection = "down" | "up" | "right" | "left"; const GENERATED_SURFACE_PITCH = 4; const generatedSurfaceFieldCache = new WeakMap(); @@ -155,7 +161,7 @@ function glyphRamp(value: string): string[] { // Callers only truth-test the result as an "authored UVs are usable" gate — // no caller reads bound values, so this returns the gate directly instead of // a bounds struct. -function findUvBounds

(context: AnyContext

): boolean { +export function findUvBounds

(context: AnyContext

): boolean { const uv = context.base.uv0; if (!uv) return false; let minU = Infinity; @@ -385,7 +391,7 @@ function solveSurfaceMetric(group: SurfaceMetricAccumulator): void { group.dyDv = dyDv * vScale; } -function generatedSurfaceField

(context: AnyContext

): GeneratedSurfaceField | null { +export function generatedSurfaceField

(context: AnyContext

): GeneratedSurfaceField | null { const position = context.base.worldPosition; const normal = context.base.normal; if (!position || !normal || typeof position !== "object" || typeof normal !== "object") return null; @@ -981,12 +987,18 @@ export const ripple: GlyphStockEffectDefinition = { // patterns (moiré, plaid, sonar, lattices) come from. Runs over surfaces via // `space`. -const SYNTH_VOICES = 6; -const SYNTH_FIELDS = ["radial", "linearX", "linearY", "diagonal", "angular", "spiral", "noise"] as const; -const SYNTH_WAVES = ["sin", "triangle", "saw", "square"] as const; -const SYNTH_COMBINES = ["add", "multiply", "max", "min", "difference"] as const; - -function synthWave(kind: string, t: number): number { +// Exported so `staticExport.ts` can enumerate voices / validate field-wave- +// combine names against the same lists the schema uses, instead of a second +// hardcoded copy that could drift. +export const SYNTH_VOICES = 6; +export const SYNTH_FIELDS = ["radial", "linearX", "linearY", "diagonal", "angular", "spiral", "noise"] as const; +export const SYNTH_WAVES = ["sin", "triangle", "saw", "square"] as const; +export const SYNTH_COMBINES = ["add", "multiply", "max", "min", "difference"] as const; + +// Exported so consumers (e.g. the website's `/synth` waveform trendlines) can +// plot the exact same shape+phase math the engine evaluates, instead of a +// second copy that could drift. +export function synthWave(kind: string, t: number): number { const p = t - Math.floor(t); // 0..1 switch (kind) { case "triangle": return 4 * Math.abs(p - 0.5) - 1; @@ -1138,7 +1150,7 @@ interface SynthVoice { // matrixRain reads, just with min/max also tracked), returning a per-cell // (cx, cy) alongside (x, y). UV and scene branches keep the original // origin*scale center untouched. -function fieldSynthCoordinate

( +export function fieldSynthCoordinate

( context: AnyContext

, index: number, space: EffectSpace, @@ -1176,7 +1188,9 @@ function fieldSynthCoordinate

( return [(x / sceneCols) * scale, (y / sceneRows) * scale, originU * scale, originV * scale]; } -function combineSynth(mode: string, a: number, b: number): number { +// Exported for the same reason as `synthWave` above — reuse the exact +// per-voice mix-weight fold instead of re-deriving it. +export function combineSynth(mode: string, a: number, b: number): number { switch (mode) { case "add": return a + b; case "max": return Math.max(a, b); diff --git a/packages/glyphcss/src/api/interactiveExport.test.ts b/packages/glyphcss/src/api/interactiveExport.test.ts new file mode 100644 index 00000000..4504acaf --- /dev/null +++ b/packages/glyphcss/src/api/interactiveExport.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from "vitest"; +import { buildGlyphInteractiveExport, glyphCodepenPrefill } from "./interactiveExport"; +import type { Polygon } from "@glyphcss/core"; + +const TRIANGLE: Polygon[] = [ + { vertices: [[0, 0, 0], [1, 0, 0], [0, 1, 0]], color: "#ff0000" }, + { vertices: [[0, 0, 1], [1, 0, 1], [0, 1, 1]], color: "#00ff00" }, +]; + +describe("buildGlyphInteractiveExport — effect option", () => { + it("no effect → unchanged output (regression guard)", () => { + const r = buildGlyphInteractiveExport(TRIANGLE, { interactions: ["orbit"] }); + expect(r.html).not.toContain("@glyphcss/effects"); + expect(r.html).not.toContain("getGlyphEffect"); + expect(r.html).not.toContain("addEffectLayer"); + expect(r.html).not.toContain("requestAnimationFrame"); + }); + + it("animated effect → effects CDN import, getGlyphEffect lookup, addEffectLayer, inlined params, rAF clock", () => { + const r = buildGlyphInteractiveExport(TRIANGLE, { + interactions: ["orbit"], + effect: { + id: "field-synth", + params: { space: "surface", field1: "radial", amp1: 0.5, time: 999 }, + blend: "replace", + timeScale: 2, + }, + }); + expect(r.html).toContain('import { getGlyphEffect } from "https://esm.sh/@glyphcss/effects?deps=glyphcss";'); + expect(r.html).toContain('getGlyphEffect("field-synth")'); + expect(r.html).toContain("scene.addEffectLayer({ effect: getGlyphEffect(\"field-synth\"), blend: \"replace\", params:"); + // Params are inlined without the authored `time` key — the clock owns it. + const paramsMatch = r.html.match(/params: (\{[^}]*\})/); + expect(paramsMatch).not.toBeNull(); + const params = JSON.parse(paramsMatch![1]); + expect(params).toEqual({ space: "surface", field1: "radial", amp1: 0.5 }); + expect(params.time).toBeUndefined(); + // rAF clock drives params.time at the declared speed. + expect(r.html).toContain("requestAnimationFrame(_tick)"); + expect(r.html).toContain("glyphEffect.params.time = ((now - _t0) / 1000) * 2;"); + }); + + it("static effect (no timeScale) → mounts the layer but emits no rAF clock", () => { + const r = buildGlyphInteractiveExport(TRIANGLE, { + interactions: [], + effect: { id: "matrix-rain", params: { density: 0.5 } }, + }); + expect(r.html).toContain('getGlyphEffect("matrix-rain")'); + expect(r.html).toContain("scene.addEffectLayer("); + // Default blend is "over" (glyphcss's own addEffectLayer default) when unset. + expect(r.html).toContain('blend: "over"'); + expect(r.html).not.toContain("requestAnimationFrame"); + expect(r.html).not.toContain("_tick"); + }); + + it("timeScale: 0 is treated as static — no clock emitted", () => { + const r = buildGlyphInteractiveExport(TRIANGLE, { + interactions: ["orbit"], + effect: { id: "scan", params: {}, timeScale: 0 }, + }); + expect(r.html).toContain("scene.addEffectLayer("); + expect(r.html).not.toContain("requestAnimationFrame"); + }); + + it("cdnVersion pins both the glyphcss and @glyphcss/effects CDN imports, plus the deps dedupe query", () => { + const r = buildGlyphInteractiveExport(TRIANGLE, { + interactions: ["orbit"], + cdnVersion: "0.1.1", + effect: { id: "glitch", params: {}, timeScale: 1 }, + }); + expect(r.html).toContain('from "https://esm.sh/glyphcss@0.1.1";'); + expect(r.html).toContain( + 'import { getGlyphEffect } from "https://esm.sh/@glyphcss/effects@0.1.1?deps=glyphcss@0.1.1";', + ); + }); + + it("mounts the effect after controls in both the normal and FPV branches", () => { + const normal = buildGlyphInteractiveExport(TRIANGLE, { + interactions: ["orbit"], + effect: { id: "wipe", params: {}, timeScale: 1 }, + }); + const orbitIdx = normal.html.indexOf("createGlyphOrbitControls("); + const effectIdx = normal.html.indexOf("scene.addEffectLayer("); + expect(orbitIdx).toBeGreaterThan(-1); + expect(effectIdx).toBeGreaterThan(orbitIdx); + + const fpv = buildGlyphInteractiveExport(TRIANGLE, { + interactions: ["fpv"], + autoCenter: true, + effect: { id: "wipe", params: {}, timeScale: 1 }, + }); + const fpvSetupIdx = fpv.html.indexOf("fpv.setOrigin("); + const fpvEffectIdx = fpv.html.indexOf("scene.addEffectLayer("); + expect(fpvSetupIdx).toBeGreaterThan(-1); + expect(fpvEffectIdx).toBeGreaterThan(fpvSetupIdx); + }); + + it("glyphCodepenPrefill carries the effect-mounting script through the pen payload", () => { + const r = buildGlyphInteractiveExport(TRIANGLE, { + interactions: ["orbit"], + effect: { id: "ripple", params: {}, timeScale: 1 }, + }); + const pre = glyphCodepenPrefill(r, "effect-demo"); + const data = JSON.parse(pre.data); + expect(data.html).toContain('getGlyphEffect("ripple")'); + expect(data.html).toContain("requestAnimationFrame(_tick)"); + }); +}); diff --git a/packages/glyphcss/src/api/interactiveExport.ts b/packages/glyphcss/src/api/interactiveExport.ts index cde27e97..81f6e40b 100644 --- a/packages/glyphcss/src/api/interactiveExport.ts +++ b/packages/glyphcss/src/api/interactiveExport.ts @@ -37,6 +37,22 @@ export interface GlyphInteractiveExportOptions { cellAspect?: number; mode?: RenderMode; useColors?: boolean; + /** + * Mount a live stock `@glyphcss/effects` layer, resolved by id from the CDN + * at runtime. The exporter never imports `@glyphcss/effects` itself (that + * package depends on glyphcss, never the reverse) — `id` stays an opaque + * string until the snippet runs in the browser. + */ + effect?: { + /** Stock effect id, e.g. "field-synth" / "matrix-rain". */ + id: string; + /** Flat ABI params. Any `time` key is stripped — the emitted clock owns it. */ + params: Record; + /** Default: "over" (glyphcss's own `addEffectLayer` default). */ + blend?: "over" | "replace"; + /** Clock speed. >0 emits an rAF loop driving `params.time`; 0/undefined = static. */ + timeScale?: number; + }; } export interface GlyphInteractiveExportResult { @@ -74,8 +90,31 @@ const BASE_CSS = `html,body{margin:0;height:100%;background:#0b0d10;color:#e2e8f #MOUNT{width:100vw;height:100vh} #MOUNT pre.glyph-output{margin:0;font:13px/1 ui-monospace,Menlo,monospace;white-space:pre}`; +// The clock owns `time` — strip any authored value so the rAF loop (or the +// static single assignment) is the sole writer. +function serializeEffectParams(params: Record): string { + const { time: _time, ...rest } = params; + return JSON.stringify(rest); +} + +// Shared by the FPV and normal branches of buildScript so the effect-mount + +// clock snippet isn't duplicated. `effect` here is the resolved, non-optional +// export option — callers gate on `opts.effect` before calling this. +function buildEffectBlock(effect: NonNullable): string { + const blend = effect.blend ?? "over"; + const layerLine = `const glyphEffect = scene.addEffectLayer({ effect: getGlyphEffect(${JSON.stringify(effect.id)}), blend: ${JSON.stringify(blend)}, params: ${serializeEffectParams(effect.params)} });`; + if (!effect.timeScale) return layerLine; + return `${layerLine} +const _t0 = performance.now(); +(function _tick(now) { + glyphEffect.params.time = ((now - _t0) / 1000) * ${round(effect.timeScale)}; + requestAnimationFrame(_tick); +})(performance.now());`; +} + function buildScript(opts: { cdn: string; + effectsCdn?: string; mountId: string; polysJson: string; rotX?: number; rotY?: number; zoom?: number; @@ -83,6 +122,7 @@ function buildScript(opts: { projection?: "perspective" | "orthographic"; perspectivePx?: number; interactions: GlyphInteraction[]; scale: number; + effect?: GlyphInteractiveExportOptions["effect"]; }): string { const hasFpv = opts.interactions.includes("fpv"); const hasPan = opts.interactions.includes("pan"); @@ -105,9 +145,12 @@ function buildScript(opts: { if (opts.useColors === false) sceneOpts.push("useColors: false"); if (opts.cellAspect !== undefined) sceneOpts.push(`cellAspect: ${opts.cellAspect}`); - const head = `import { ${imports.join(", ")} } from "${opts.cdn}"; + const importLines = [`import { ${imports.join(", ")} } from "${opts.cdn}";`]; + if (opts.effect) importLines.push(`import { getGlyphEffect } from "${opts.effectsCdn}";`); + const head = `${importLines.join("\n")} const polygons = ${opts.polysJson}.map((p) => ({ vertices: p[0], color: p[1] }));`; const mount = `document.getElementById(${JSON.stringify(opts.mountId)})`; + const effectBlock = opts.effect ? buildEffectBlock(opts.effect) : undefined; // FPV mirrors the gallery: perspective at rotX=90, zoom × model-scale, // and move/jump/gravity/eyeHeight scaled by the model so it works at any size. @@ -123,13 +166,16 @@ const polygons = ${opts.polysJson}.map((p) => ({ vertices: p[0], color: p[1] })) const fpvZoom = round((opts.zoom ?? 0.5) * s); const fpvPerspective = round(200 * s); const fpvOpts = `{ moveSpeed: ${round(1 * s)}, jumpVelocity: ${round(0.7 * s)}, gravity: ${round(1.8 * s)}, eyeHeight: ${eyeZ}, crouchHeight: ${round(0.1 * s)}, groundZ: 0, lookSensitivity: 0.15, minPitch: 5, maxPitch: 175 }`; - return `${head} -const camera = createGlyphPerspectiveCamera({ rotX: 90, rotY: ${round(rotY)}, zoom: ${fpvZoom}, perspective: ${fpvPerspective} }); + return [ + head, + `const camera = createGlyphPerspectiveCamera({ rotX: 90, rotY: ${round(rotY)}, zoom: ${fpvZoom}, perspective: ${fpvPerspective} }); const scene = createGlyphScene(${mount}, { camera, ${sceneOpts.join(", ")} }); scene.add(polygons); const fpv = createGlyphFirstPersonControls(scene, ${fpvOpts}); fpv.setOrigin([${spawnX}, ${spawnY}, ${eyeZ}]); -// Click the scene to capture the mouse, then WASD to move, mouse to look.`; +// Click the scene to capture the mouse, then WASD to move, mouse to look.`, + effectBlock, + ].filter(Boolean).join("\n"); } const camParts: string[] = []; @@ -145,11 +191,14 @@ fpv.setOrigin([${spawnX}, ${spawnY}, ${eyeZ}]); controlLine = `createGlyphOrbitControls(scene, { drag: ${hasOrbit ? "true" : "false"}, wheel: ${hasZoom ? "true" : "false"} });`; } - return `${head} -const camera = ${cameraCtor}({ ${camParts.join(", ")} }); + return [ + head, + `const camera = ${cameraCtor}({ ${camParts.join(", ")} }); const scene = createGlyphScene(${mount}, { camera, ${sceneOpts.join(", ")} }); scene.add(polygons); -${controlLine}`; +${controlLine}`, + effectBlock, + ].filter(Boolean).join("\n"); } function modelScale(polygons: Polygon[]): number { @@ -173,15 +222,23 @@ export function buildGlyphInteractiveExport( const decimated = decimatePolygons(centered, { grid }); const mountId = options.mountId ?? "glyph"; - const cdn = `https://esm.sh/glyphcss${options.cdnVersion ? `@${options.cdnVersion}` : ""}`; + const versionSuffix = options.cdnVersion ? `@${options.cdnVersion}` : ""; + const cdn = `https://esm.sh/glyphcss${versionSuffix}`; + // `?deps=glyphcss` pins esm.sh to the same glyphcss instance the main + // import resolves, so the effect layer's runtime type checks (which compare + // against glyphcss's exported classes/symbols) see one module graph. + const effectsCdn = options.effect + ? `https://esm.sh/@glyphcss/effects${versionSuffix}?deps=glyphcss${versionSuffix}` + : undefined; const script = buildScript({ - cdn, mountId, + cdn, effectsCdn, mountId, polysJson: serializePolygons(decimated), rotX: options.rotX, rotY: options.rotY, zoom: options.zoom, projection: options.projection, perspectivePx: options.perspectivePx, cellAspect: options.cellAspect, mode: options.mode, useColors: options.useColors, interactions, scale: modelScale(decimated), + effect: options.effect, }); const css = BASE_CSS.replace(/MOUNT/g, mountId); diff --git a/packages/glyphcss/src/index.ts b/packages/glyphcss/src/index.ts index fe08307e..2fdc5f64 100644 --- a/packages/glyphcss/src/index.ts +++ b/packages/glyphcss/src/index.ts @@ -28,6 +28,18 @@ export type { // Effect-program protocol + scene-root compositor layers. export * from "./api/effects"; +// Retained-effect base frame — turns a rasterized `CellGrid` (with optional +// shade/worldPosition/normal buffers) into the `GlyphEffectFrameView` + +// coverage shape an effect program's `evaluate()` reads. Exposed so a +// build-time exporter (e.g. `@glyphcss/effects`'s static field-synth export) +// can bake the SAME per-cell inputs a mounted effect layer sees, instead of +// re-deriving coverage/color-packing from a `CellGrid` by hand. +export { retainGlyphEffectOutput } from "./render/effectCompositor"; +export type { + GlyphEffectOutputMetadata, + RetainedGlyphEffectOutput, +} from "./render/effectCompositor"; + // Static compile — render a scene to its `

` without a DOM (build-time / SSR).
 export { compileScene } from "./api/compileScene";
 export type { CompileSceneOptions, CompileSceneResult } from "./api/compileScene";
diff --git a/packages/glyphcss/src/render/effectCompositor.ts b/packages/glyphcss/src/render/effectCompositor.ts
index a18eac36..03e36bb9 100644
--- a/packages/glyphcss/src/render/effectCompositor.ts
+++ b/packages/glyphcss/src/render/effectCompositor.ts
@@ -25,7 +25,15 @@ import {
 } from "../api/effects";
 
 type AnyParams = Record;
-type AnyProgram = GlyphEffectProgram;
+// `unknown` (not `any`) for the state param: `GlyphEffectProgram`'s
+// `createState` field type is a conditional on `[S] extends [undefined]`, and
+// a bare `any` there triggers TS's "any collapses a conditional type to the
+// union of both branches" rule, which made `program.createState` resolve to
+// an uncallable `never` at the one call site below. `unknown` picks the
+// `{ createState(): unknown }` branch cleanly — correct anyway, since a
+// runtime-dispatched program's state is always handled opaquely (cast at the
+// point of use), never actually relied on to BE `any`.
+type AnyProgram = GlyphEffectProgram;
 
 const SUPPORTED_REQUIREMENTS = new Set([
   "baseColor",
diff --git a/website/src/components/Dock/primitives.tsx b/website/src/components/Dock/primitives.tsx
index 4b9e829e..90646a72 100644
--- a/website/src/components/Dock/primitives.tsx
+++ b/website/src/components/Dock/primitives.tsx
@@ -300,6 +300,44 @@ export function useReadonlyNumber(
   return ctrl;
 }
 
+/**
+ * Insert a plain DOM node into a folder's children container for mounting
+ * arbitrary content (e.g. a React portal) alongside its lil-gui controllers.
+ * `parent.$children` is the documented DOM element lil-gui appends controller
+ * rows into (see the `lil-gui` type declarations), so this stays inside the
+ * public contract rather than reaching into private internals.
+ *
+ * Controllers only ever `appendChild` themselves, so a `"top"` slot must be
+ * requested before any sibling `use*` calls in the same folder to land above
+ * them; a `"bottom"` slot can be requested at any point.
+ */
+export function useDockSlot(
+  parent: GUI | null,
+  options?: { position?: "top" | "bottom"; className?: string },
+): HTMLDivElement | null {
+  const [host, setHost] = useState(null);
+  const optsRef = useRef(options);
+  optsRef.current = options;
+
+  useEffect(() => {
+    if (!parent) return;
+    const el = document.createElement("div");
+    el.className = "dock-slot";
+    if (optsRef.current?.className) el.classList.add(optsRef.current.className);
+    const container = parent.$children;
+    if (optsRef.current?.position === "bottom") container.appendChild(el);
+    else container.insertBefore(el, container.firstChild);
+    setHost(el);
+    return () => {
+      container.removeChild(el);
+      setHost(null);
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [parent]);
+
+  return host;
+}
+
 /** Always-disabled text display with the same visual treatment as readonly numbers. */
 export function useReadonlyText(parent: GUI | null, label: string, value: string): DockController | null {
   const [ctrl, setCtrl] = useState | null>(null);
diff --git a/website/src/components/GalleryWorkbench/CodePanel.tsx b/website/src/components/GalleryWorkbench/CodePanel.tsx
index c951d48b..f2db639b 100644
--- a/website/src/components/GalleryWorkbench/CodePanel.tsx
+++ b/website/src/components/GalleryWorkbench/CodePanel.tsx
@@ -589,6 +589,14 @@ export function CodePanel({ meshUrl, options, selectedPreset, effectState, effec
         mode: options.renderMode === "wireframe" ? "wireframe" : "solid",
         useColors: options.useColors,
         decimateGrid,
+        effect: effectState.effectId
+          ? {
+              id: effectState.effectId,
+              params: effectState.params,
+              blend: effectState.blend,
+              timeScale: effectState.paused ? 0 : effectState.timeScale,
+            }
+          : undefined,
       });
       postToCodepen(glyphCodepenPrefill(result, title));
     } catch (err) {
@@ -596,7 +604,7 @@ export function CodePanel({ meshUrl, options, selectedPreset, effectState, effec
     } finally {
       setExporting(false);
     }
-  }, [meshUrl, selectedPreset, options, interactions, staticMode, staticEncoding, rotate]);
+  }, [meshUrl, selectedPreset, options, interactions, staticMode, staticEncoding, rotate, effectState]);
 
   return (