Skip to content
Merged
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ There are no per-polygon DOM elements. There is no CSS `matrix3d`. The base `<pr

**Retained Glyph Effects.** `scene.addEffectLayer()` mounts ordered, scene-root appearance programs without adding output elements. The first geometry render retains an immutable `CellGrid` for the base `<pre>` and every detail `<pre>`; changing `layer.params`, opacity, blend, order, or enabled state then recomposes and safely encodes those retained grids without transforming or projecting the mesh again. The handle's flat `params` object has stable identity, validates known string keys, accepts Anime.js/private symbol metadata, and automatically schedules a coalesced effect transaction. Generic effects run before the legacy `transformCells` escape hatch. An effect-only transaction stages every affected string before assigning each `<pre>` at most once. With no mounted effect and no hook, the original direct renderer remains byte-identical and does not allocate retained effect frames.

The current generic compositor supports `over`/`replace`, `"surfaces"`/`"viewport"` targets, canonical base-grid affines across detail densities, and the `baseColor`, `baseShade`, `depth`, `normal`, `worldPosition`, and `uv0` requested inputs. Hard surface requirements are solid-mode-only; `optionalRequirements` retain those inputs in solid mode but allow a program-defined fallback in wireframe/voxel modes. Mesh-handle targets, scene-image sampling, program scratch buffers, surface-key/UV-footprint inputs, and static compile/export evaluation are not implemented yet and reject explicitly. `@glyphcss/effects` supplies Matrix rain, flow text, scan, wipe, scramble, glitch, noise dissolve, ripple, and **field synth** as reusable definitions. `field-synth` is a small composable synth — up to six oscillators (`fieldN` × `waveN`: radial/linearX/linearY/diagonal/angular/spiral/noise × sin/triangle/saw/square, each with `freqN`/`speedN`/`ampN`), combined (`add`/`multiply`/`max`/`min`/`difference`) into one scalar field mapped to a glyph ramp + color, over `space` — for emergent moiré/interference patterns. `ampN` is a MIX WEIGHT (blends the result toward `combine(result, voiceN)` per voice, not a signal gain), so low amp gently mixes instead of `multiply` collapsing to flat; `amp 0` skips the voice. `lit` (0..1) modulates output color by the surface Lambert shade so lighting reads through the texture. `voiceColors` blends each active voice's `colorN` by its contribution (per-voice color composition); off keeps the `color`/`colorB`/`gradient` value-gradient. `@glyphcss/effects` also exports `GlyphRamps` (named character-sets: Fade/Blocks/Shades/Dots/Binary/ASCII/Hatch/Stars/Digital). The **`/synth` website page** is the front-end: dual-sidebar modular synth (add/remove voice cards with live per-voice previews + a color swatch, icon multi-toggles, per-voice mix; Stage/Mix/Output/Lighting Dock; live preset gallery), full patch persisted to the URL. For surfaces it gives each face a local 0..1 UV so patterns map per-face. For Matrix/flow/scan, `space: "auto"` resolves authored UVs first and then generated world-surface coordinates; `space: "surface"` forces the generated orientation-aware mapping, while `space: "scene"` uses projected scene coordinates. On generated surface coordinates, `down` is world `-Z` projected into each geometric face's tangent plane; coplanar polygons agree, differently oriented polygons derive different downhill directions, and a plane perpendicular to that vector gets a deterministic pseudo-random in-plane fallback. Generated Matrix rain fits each quantized coplanar surface basis to projected glyph-cell space, orthogonalizes its visible flow and lane vectors, and evaluates both sparse trail membership and glyph phase in that same face-local field. The word therefore shares the strand's direction and projected-cell velocity on sheared or foreshortened faces, remains ordered when a periodic strand wraps, and does not mutate in place while its mask moves separately. Active rain cells have full logical coverage; sparsity comes from lane selection and trail length, not fractional tail coverage that the compositor would dither into broken strands. Matrix also supports original surface colors or a configurable shaded monochrome color. The package owns no clock. Applications animate explicit parameters such as `time` directly or through Anime.js. React and Vue mirror the scene-root `<GlyphEffectLayer>` wrapper, and custom HTML configures `<glyph-effect-layer>` through JavaScript properties/`configure()` because executable definitions are values, not JSON attributes. `ANIMATIONS.md` records both this implemented slice and the richer approved graph/sampling architecture that remains.
The current generic compositor supports `over`/`replace`, `"surfaces"`/`"viewport"` targets, canonical base-grid affines across detail densities, and the `baseColor`, `baseShade`, `depth`, `normal`, `worldPosition`, and `uv0` requested inputs. Hard surface requirements are solid-mode-only; `optionalRequirements` retain those inputs in solid mode but allow a program-defined fallback in wireframe/voxel modes. Mesh-handle targets, scene-image sampling, program scratch buffers, surface-key/UV-footprint inputs, and static compile/export evaluation are not implemented yet and reject explicitly. `@glyphcss/effects` supplies Matrix rain, flow text, scan, wipe, scramble, glitch, noise dissolve, ripple, and **field synth** as reusable definitions. `field-synth` is a small composable synth — up to six oscillators (`fieldN` × `waveN`: radial/linearX/linearY/diagonal/angular/spiral/noise × sin/triangle/saw/square, each with `freqN`/`speedN`/`ampN`), combined (`add`/`multiply`/`max`/`min`/`difference`) into one scalar field mapped to a glyph ramp + color, over `space` — for emergent moiré/interference patterns. `ampN` is a MIX WEIGHT (blends the result toward `combine(result, voiceN)` per voice, not a signal gain), so low amp gently mixes instead of `multiply` collapsing to flat; `amp 0` skips the voice. `lit` (0..1) modulates output color by the surface Lambert shade so lighting reads through the texture. `voiceColors` blends each active voice's `colorN` by its contribution (per-voice color composition); off keeps the `color`/`colorB`/`gradient` value-gradient. `@glyphcss/effects` also exports `GlyphRamps` (named character-sets: Fade/Blocks/Shades/Dots/Binary/ASCII/Hatch/Stars/Digital). The **`/synth` website page** is the front-end: dual-sidebar modular synth (add/remove voice cards with live per-voice previews + a color swatch, icon multi-toggles, per-voice mix; Stage/Mix/Output/Lighting Dock; live preset gallery), full patch persisted to the URL. For surfaces it gives each face a local 0..1 UV so patterns map per-face. For Matrix/flow/scan, `space: "auto"` resolves authored UVs first and then generated world-surface coordinates; `space: "surface"` forces the generated orientation-aware mapping, while `space: "scene"` uses projected scene coordinates. On generated surface coordinates, `down` is world `-Z` projected into each geometric face's tangent plane; coplanar polygons agree, differently oriented polygons derive different downhill directions, and a plane perpendicular to that vector gets a deterministic pseudo-random in-plane fallback. Generated Matrix rain fits each quantized coplanar surface basis to projected glyph-cell space, orthogonalizes its visible flow and lane vectors, and evaluates both sparse trail membership and glyph phase in that same face-local field. The word therefore shares the strand's direction and projected-cell velocity on sheared or foreshortened faces, remains ordered when a periodic strand wraps, and does not mutate in place while its mask moves separately. Active rain cells have full logical coverage; sparsity comes from lane selection and trail length, not fractional tail coverage that the compositor would dither into broken strands. Matrix also supports original surface colors or a configurable monochrome color; in monochrome each strand lights a bright `headColor` head and fades its tail toward black (still modulated by surface shade) for the classic head-to-tail falling-code gradient. The package owns no clock. Applications animate explicit parameters such as `time` directly or through Anime.js. React and Vue mirror the scene-root `<GlyphEffectLayer>` wrapper, and custom HTML configures `<glyph-effect-layer>` through JavaScript properties/`configure()` because executable definitions are values, not JSON attributes. `ANIMATIONS.md` records both this implemented slice and the richer approved graph/sampling architecture that remains.

### Per-mesh detail layers

Expand Down
77 changes: 60 additions & 17 deletions packages/effects/src/stock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,47 +408,90 @@ describe("stock effects", () => {
expect(activePairs).toBeGreaterThan(0);
});

it("uses surface shade for every monochrome rain glyph", () => {
const shade = new Float32Array(12 * 6).fill(0.25);
it("lights a bright head and fades the tail on every monochrome strand", () => {
// Body cells use `color` (green, red channel 0); head cells use `headColor`
// (mint, red channel > 0). The red channel therefore separates head from
// body, and the green channel measures the head→tail brightness gradient.
const output = evaluate(matrixRain, {
colorMode: "monochrome",
color: "#804020",
color: "#00ff66",
headColor: "#e8ffe8",
space: "scene",
time: 1.25,
speedMin: 5,
speedMax: 5,
density: 1,
trail: 4,
}, { shade });
const shaded = parseGlyphEffectColor("#201008").packed;
const rainColors: number[] = [];

trail: 8,
}, { shade: new Float32Array(12 * 6).fill(1) });
const rgb = (packed: number) => ({ r: (packed >>> 16) & 0xff, g: (packed >>> 8) & 0xff, b: packed & 0xff });
const active: { r: number; g: number; b: number }[] = [];
for (let i = 0; i < output.coverage.length; i++) {
if (output.coverage[i]! <= 0) continue;
expect(output.channels[i]! & GlyphEffectOutputChannel.Color).toBeTruthy();
rainColors.push(output.color[i]!);
active.push(rgb(output.color[i]!));
}
const heads = active.filter((c) => c.r > 0);
const body = active.filter((c) => c.r === 0);

expect(heads.length).toBeGreaterThan(0);
expect(body.length).toBeGreaterThan(1);
// The tail is a gradient, not one flat green.
expect(new Set(body.map((c) => c.g)).size).toBeGreaterThan(1);
// The bright head outshines the darkest tail cell.
expect(Math.max(...heads.map((c) => c.g))).toBeGreaterThan(Math.min(...body.map((c) => c.g)));
});

it("modulates monochrome brightness by surface shade", () => {
const params = {
colorMode: "monochrome",
color: "#00ff66",
space: "scene" as const,
time: 1.25,
speedMin: 5,
speedMax: 5,
density: 1,
trail: 8,
};
const green = (output: ReturnType<typeof evaluate>) => {
let sum = 0;
for (let i = 0; i < output.coverage.length; i++) {
if (output.coverage[i]! > 0) sum += (output.color[i]! >>> 8) & 0xff;
}
return sum;
};
const dim = evaluate(matrixRain, params, { shade: new Float32Array(12 * 6).fill(0.2) });
const bright = evaluate(matrixRain, params, { shade: new Float32Array(12 * 6).fill(1) });

expect(rainColors.length).toBeGreaterThan(0);
expect(new Set(rainColors)).toEqual(new Set([shaded]));
expect(green(dim)).toBeGreaterThan(0);
expect(green(bright)).toBeGreaterThan(green(dim));
});

it("falls back to the selected monochrome color when surface shade is unavailable", () => {
it("renders monochrome rain from the configured color when surface shade is unavailable", () => {
const output = evaluate(matrixRain, {
colorMode: "monochrome",
color: "#37c96f",
headColor: "#d8ffe4",
space: "scene",
time: 1.25,
speedMin: 5,
speedMax: 5,
density: 1,
trail: 4,
trail: 8,
});
const selected = parseGlyphEffectColor("#37c96f").packed;
const activeColors = Array.from(output.color).filter((_, index) => output.coverage[index]! > 0);
const rgb = (packed: number) => ({ r: (packed >>> 16) & 0xff, g: (packed >>> 8) & 0xff, b: packed & 0xff });
const active: { r: number; g: number; b: number }[] = [];
for (let i = 0; i < output.coverage.length; i++) {
if (output.coverage[i]! > 0) active.push(rgb(output.color[i]!));
}

expect(activeColors.length).toBeGreaterThan(0);
expect(new Set(activeColors)).toEqual(new Set([selected]));
expect(active.length).toBeGreaterThan(0);
// Every cell is a real, green-dominant color (no fall-through to black).
for (const c of active) {
expect(c.g).toBeGreaterThan(0);
expect(c.g).toBeGreaterThanOrEqual(c.r);
}
// Head + fading tail give more than one brightness level.
expect(new Set(active.map((c) => c.g)).size).toBeGreaterThan(1);
});

it("scales monochrome coverage by the color's alpha channel", () => {
Expand Down
17 changes: 15 additions & 2 deletions packages/effects/src/stock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -700,9 +700,22 @@ export const matrixRain: GlyphStockEffectDefinition<typeof matrixRainSchema> = {
);
setGlyph(context, i, glyphs[glyphIndex]!);
if (monochrome) {
// Matrix signature: a bright leading head that fades to a dark tail.
// `behind` is 0 at the head and grows toward the tail — light the
// head cell toward `headColor` and fall the body off toward black so
// each strand glows at its tip and trails away, instead of every cell
// sharing one flat green. Surface shade still modulates so lit faces
// read brighter than faces turned away from the light.
const shade = context.base.shade?.[i];
const intensity = shade !== undefined && Number.isFinite(shade) ? shade : 1;
setColor(context, i, scalePackedColor(parsedColor.packed, intensity));
const surfaceLit = shade !== undefined && Number.isFinite(shade) ? shade : 1;
const headSpan = 1 / glyphsPerPatternCell;
if (behind < headSpan && parsedHead.opacity > 0) {
setColor(context, i, scalePackedColor(parsedHead.packed, 0.6 + 0.4 * surfaceLit));
} else {
const fade = Math.pow(1 - behind / trail, 1.6);
const intensity = (0.12 + 0.88 * fade) * (0.5 + 0.5 * surfaceLit);
setColor(context, i, scalePackedColor(parsedColor.packed, intensity));
}
context.output.coverage[i] = parsedColor.opacity;
} else {
context.output.coverage[i] = 1;
Expand Down
19 changes: 15 additions & 4 deletions packages/glyphcss/src/render/rasterize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1215,7 +1215,7 @@ function scanFillTriangle(
const ccw = area2 > 0;
const perspectiveAttributes = aq !== 1 || bq !== 1 || cq !== 1;
const interpolatePerspective = perspectiveAttributes
&& (worldPosBuf !== null || surfaceUvBuf !== null || tex !== null);
&& (worldPosBuf !== null || surfaceUvBuf !== null || tex !== null || sh !== null);

// Bounding box clamped to grid.
let minX = ax < bx ? ax : bx; if (cx < minX) minX = cx;
Expand Down Expand Up @@ -1342,9 +1342,20 @@ function scanFillTriangle(
// Shadow occlusion: barycentric-interpolate light-space (u,v,depth),
// sample the shadow map, darken if occluded.
if (sh !== null) {
const lu = (wA * sh.luA + wB * sh.luB + wC * sh.luC) * invArea2;
const lv = (wA * sh.lvA + wB * sh.lvB + wC * sh.lvC) * invArea2;
const ld = (wA * sh.ldA + wB * sh.ldB + wC * sh.ldC) * invArea2;
// Light-space (u,v,depth) are affine in world position, so they need
// the SAME perspective-correct interpolation as world position/UV —
// interpolating them affinely in screen space warps the shadow under a
// perspective camera (and the warp shifts with the camera, worst up
// close). Ortho supplies q=1, reducing exactly to affine barycentrics.
const lu = perspectiveAttributes
? (wA * aq * sh.luA + wB * bq * sh.luB + wC * cq * sh.luC) * invQ
: (wA * sh.luA + wB * sh.luB + wC * sh.luC) * invArea2;
const lv = perspectiveAttributes
? (wA * aq * sh.lvA + wB * bq * sh.lvB + wC * cq * sh.lvC) * invQ
: (wA * sh.lvA + wB * sh.lvB + wC * sh.lvC) * invArea2;
const ld = perspectiveAttributes
? (wA * aq * sh.ldA + wB * bq * sh.ldB + wC * cq * sh.ldC) * invQ
: (wA * sh.ldA + wB * sh.ldB + wC * sh.ldC) * invArea2;
// Nearest-neighbor sample (integer texel coords).
const tu = lu | 0;
const tv = lv | 0;
Expand Down
49 changes: 49 additions & 0 deletions packages/glyphcss/src/render/shadow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,4 +227,53 @@ describe("shadow map", () => {

expect(rasterize(ctxSelf)).not.toBe(rasterize(ctxBaseline));
});

it("perspective-correct: a big receiver quad shadows the same cells as a tessellated one", () => {
// The light-space (u,v,depth) sampled per cell must be interpolated
// perspective-correctly. On a small triangle affine ≈ perspective-correct,
// but a big receiver quad under a perspective camera warps the shadow if the
// UVs are interpolated affinely (screen-space). A finely tessellated ground
// is affine-safe, so it's the ground truth: the big single quad must land
// the shadow in the same cells. (Before the fix these diverged badly.)
const cube = makeCubePolygons(3); // hovering caster so the shadow falls on open ground
const nc = cube.length;
const light = { direction: [0.5, -0.3, 0.8] as Vec3, intensity: 1 };
const cam = createGlyphPerspectiveCamera({ rotX: 66, rotY: 20, zoom: 90, distance: 14 });
const grid = { cols: 90, rows: 46, cellAspect: 2 };

const bigGround: Polygon[] = [
{ vertices: [[-24, -24, 0], [24, -24, 0], [24, 24, 0]], color: "#888888" },
{ vertices: [[-24, -24, 0], [24, 24, 0], [-24, 24, 0]], color: "#888888" },
];
const tessGround: Polygon[] = [];
for (let x = -24; x < 24; x += 4) for (let y = -24; y < 24; y += 4) {
tessGround.push({ vertices: [[x, y, 0], [x + 4, y, 0], [x + 4, y + 4, 0], [x, y + 4, 0]], color: "#888888" });
}

const shadowedCells = (ground: Polygon[]): Set<string> => {
const all = [...cube, ...ground];
const n = all.length;
const cast = new Array(n).fill(false); for (let i = 0; i < nc; i++) cast[i] = true;
const receive = new Array(n).fill(false); for (let i = nc; i < n; i++) receive[i] = true;
const common = { camera: cam, grid, polygons: all, mode: "solid" as const, directionalLight: light, ambientLight: AMB_LIGHT, useColors: false };
const base = rasterize(buildRasterizeContext(common)).split("\n");
const shad = rasterize(buildRasterizeContext({ ...common, shadow: { opacity: 0.85, lift: 0.06 }, castShadowFlags: cast, receiveShadowFlags: receive })).split("\n");
const set = new Set<string>();
for (let r = 0; r < grid.rows; r++) for (let c = 0; c < grid.cols; c++) {
if (base[r]?.[c] !== undefined && base[r]![c] !== shad[r]?.[c]) set.add(`${r},${c}`);
}
return set;
};

const big = shadowedCells(bigGround);
const tess = shadowedCells(tessGround);

expect(tess.size).toBeGreaterThan(5); // there is a real shadow to compare
let intersection = 0;
for (const key of big) if (tess.has(key)) intersection++;
const union = big.size + tess.size - intersection;
// Perspective-correct → the big quad lands the shadow where the tessellated
// reference does. Affine interpolation would drop this Jaccard far below 0.7.
expect(intersection / union).toBeGreaterThan(0.7);
});
});
Binary file added website/public/og/city-lab.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added website/public/og/flatmap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added website/public/og/gallery.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added website/public/og/image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added website/public/og/parthenon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added website/public/og/synth.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added website/public/og/wordart.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added website/public/og/world.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading