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;r a[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 `…