Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
c7ec4b1
feat(shadow): vanilla parametric coverage-contour shadows with rubric…
apresmoi Jun 21, 2026
b501565
perf(shadow): halve self-shadow bands and decouple depth-bias from ba…
apresmoi Jun 21, 2026
1fef694
chore: gitignore chrome-trace raw output
apresmoi Jun 21, 2026
3937a1b
perf(shadow): progressive definition for laggless light drag + trace …
apresmoi Jun 21, 2026
8f98f3f
fix(bench): scope progressive shadow def to light/camera motion, not …
apresmoi Jun 21, 2026
f05176c
feat(bench): configurable in-motion shadow definition (dragDef slider)
apresmoi Jun 21, 2026
85b2c40
feat(shadow): per-mesh shadowDefinition + library progressive dragDef…
apresmoi Jun 21, 2026
2b15548
feat(shadow): point-light parametric parity (per-light radial silhoue…
apresmoi Jun 21, 2026
514a1df
feat(shadow): pixel/voxel shadow style (shadow.style: vector | pixel)
apresmoi Jun 21, 2026
3ede8ea
feat(shadow): mirror parametric shadows to React + Vue via shared cor…
apresmoi Jun 21, 2026
6fe4e95
feat(website): parametric shadow controls (parametric, definition, pi…
apresmoi Jun 21, 2026
a52df8e
fix(website): apply parametric shadow option changes in gallery (miss…
apresmoi Jun 21, 2026
77a96fd
feat(shadow): animated shadows via shadow.followAnimation + gallery t…
apresmoi Jun 21, 2026
43833c5
chore(website): gallery defaults to parametric shadows at definition …
apresmoi Jun 21, 2026
5fcab69
chore(website): gallery animates shadows by default
apresmoi Jun 21, 2026
1ae6354
feat(shadow): mirror animated shadows (followAnimation) to React + Vue
apresmoi Jun 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,7 @@ packages/vue/tsconfig.tsbuildinfo
log.md
log-texture.md
website/.baseline-shots/

# chrome-trace skill raw output
chrome-trace.json
/tmp/*.trace.json
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ Cast shadows are not a render-strategy leaf tag. Meshes with `castShadow: true`

Receiver-shadow geometry has two caster paths. The default per-mesh **silhouette fast path** (caster ≠ receiver, ≥40 polys) projects one outline per caster instead of every front-facing triangle — but only when the caster's silhouette under the current light is a clean union of simple closed loops (every silhouette vertex shared by exactly two silhouette edges). Meshes whose silhouette has non-manifold / T-junction / open-boundary vertices (imported architecture like the castle) fall back to the **per-polygon union**, which is gap-free for any topology. Light-back-facing caster polygons are normally culled (single-sided casting, correct for clean closed meshes); the per-poly path casts **double-sided** (skips that cull) for two cases — cross-mesh casters whose silhouette is unreliable, and ALL self-shadow casters (caster = receiver) — so badly-wound / single-sided interior walls don't leave holes. Closed meshes are unaffected by double-siding: their far back-faces sit below each lit receiver plane and get above-plane-culled, adding no spurious shadow.

**Parametric shadows (all three renderers, opt-in via `shadow.parametric` + `shadow.definition`).** A lightweight approximation that replaces a caster's per-poly/silhouette projection with low-resolution coverage-contour loops, so a complex caster emits far fewer shadow-path vertices. Per caster the light-perpendicular coverage is rasterised into a mask (resolution scales with `definition`), traced with marching squares, simplified, and lifted back to 3D for the normal receiver projector. Cross-mesh casting uses ONE flat proxy outline slid toward the light so it clears receivers below the caster (footprint is depth-invariant under directional projection); self-shadow uses depth-stratified bands, each biased to its far-from-light edge so a face is only shadowed by geometry genuinely in front of it. The approximation is governed by named **rubrics** measured parametric-vs-exact in `bench/shadow-rubric.mjs`; the current correction terms are: **flat casters route to the exact path** (their tilted proxy would project garbage onto a coplanar receiver), **convex casters skip self-shadow** (a convex surface self-shadows nothing), **self-shadow depth-bias** (flat band proxies otherwise over-darken), and **hole subtraction** (coverage holes — courtyards, the coliseum arena — are emitted with opposite winding by nesting depth and the override path preserves that relative winding so they subtract under the receiver's `fill-rule: nonzero`; the exact path keeps per-loop CCW). Higher `definition` trades DOM weight for fidelity and is the knob for resolving fine concave holes. **Point lights** are supported: each shadow-casting point light gets its own RADIAL override silhouette built from the caster-centroid → light direction (the small-object approximation point shading already uses), stored per-light in `overridePointSilhouettes` and selected per pass; the directional override is the wrong outline for a finite-distance light, so point passes never reuse it. Parametric does not change the exact path, which remains the default. **Render style** is selectable via `shadow.style`: `"vector"` (default) traces the smooth concave contour; `"pixel"` greedy-meshes the coverage mask into axis-aligned rectangles for a blocky/voxel shadow — holes (courtyards, the coliseum arena) fall out for free as absent cells (no winding/`fill-rule` work), and `definition` becomes the pixel-grid resolution (lower → chunkier; the block size is the aesthetic). Both styles share the per-mesh def, point-light, and progressive machinery. The override builder (`buildParametricCasterOverride` + `isFlatCaster`/`isConvexCaster`) lives in **core** so all three renderers produce byte-identical loops — vanilla `receiverShadow.ts`, React `PolyMesh.tsx`, and Vue `PolyMesh.ts` each just call it in their caster loop and drop the result on the `ReceiverCasterInput`. Per-mesh `shadowDefinition` is a `PolyMeshTransform` field (vanilla), a `<PolyMesh shadowDefinition>` prop (React), and a `shadow-definition` prop (Vue). The ONE intentional asymmetry: **progressive `dragDefinition` is vanilla-only**, because it's a `createPolyScene` orchestration concern (auto-detect a light-direction change → emit at the drag def → debounced full-def refine). React/Vue are declarative and re-render on prop change, so an app gets the same effect idiomatically by lowering `shadow.definition` in its own state during a drag and restoring it at rest. **Animated shadows** (`shadow.followAnimation`, all three renderers): a caster's shadow normally FREEZES during a same-topology deform — re-projecting every frame is expensive — so the default is frozen and `followAnimation` opts into tracking the pose (pair with a low parametric `definition`). Vanilla re-emits (throttled ~12fps) from the freshly-deformed polygons in `setPolygons`; React/Vue gate the caster re-registration (a same-topology polygon change is skipped unless `followAnimation`, so the receiver re-emits only when following). Topology changes (different polygon count) always re-emit regardless.

**Cost + the laggless levers.** Cross-mesh/floor cast shadows are cheap (one outline, ~1 receiver face) and follow a moving light at 60fps+; camera orbit is free (shadows ride the scene transform — only light/geometry changes re-emit). SELF-shadow is the expensive case: it projects every depth band onto every one of the mesh's own coplanar faces, so the recompute is O(faces × bands × points) and that work is irreducible by spatial culling (the depth-band proxies span the whole mesh, so their AABBs overlap nearly every face — a broad-phase cull was measured to reject nothing). The lever for smooth interaction is therefore reducing *quality during motion*, not a faster projector. Two knobs make this configurable:
- **Per-mesh `PolyMeshTransform.shadowDefinition`** overrides the scene `shadow.definition` for one mesh's cast/self shadow — a detailed caster stays sharp while a simple prop runs cheap in the same scene.
- **`shadow.dragDefinition`** is the library's built-in progressive refinement: when set (and `parametric`), a light-DIRECTION change emits at `min(definition, dragDefinition)` for a laggless drag (castle ~100fps at 16 vs 36fps at 96), then a debounced pass re-emits at full `definition` once the light settles — the same atlas-rebake-at-rest escape hatch. It's auto-detected in `setOptions` (direction change = motion; an appearance edit like the `definition` slider renders full immediately). Per-mesh def composes: motion caps each mesh at `min(its def, dragDefinition)`.

The fps↔definition curve and per-phase breakdown are measured with `bench/shadow-trace.html` + the chrome-trace skill; `bench/shadow-parametric.html` exposes both knobs (`definition`, `dragDef` sliders; `?def=`, `?dragdef=`).

The `.vox` fast path emits plain `<b>` elements inside `.polycss-voxel-face` wrappers. They intentionally reuse the cheap quad tag; each visible quad has one `matrix3d(...)`, with same-color shared-edge overscan folded into the local left/top/width/height before matrix generation. The face wrappers are grouping nodes for cheap add/remove and are not render-strategy leaves. Desktop-class documents use a canonical 1px primitive for the cheapest transform shape; mobile-class documents (`pointer: coarse` or `hover: none`) use an 8px primitive and divide the in-plane matrix scale by 8 to preserve identical CSS-space geometry while avoiding large GPU filtering gaps.

### Lights
Expand Down
25 changes: 25 additions & 0 deletions bench/anim-shadow-test.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<!doctype html>
<html><head><meta charset="utf-8"><style>html,body{margin:0;height:100%;background:#eef}#h{position:absolute;inset:0}</style></head>
<body><div id="h"></div>
<script type="importmap">{ "imports": { "@layoutit/polycss": "./.generated/polycss.js" } }</script>
<script type="module">
import { createPolyCamera, createPolyScene, boxPolygons } from "@layoutit/polycss";
const q = new URLSearchParams(location.search);
const follow = q.get("follow") === "1";
const camera = createPolyCamera({ rotX: 55, rotY: 18, zoom: 26 });
const scene = createPolyScene(document.getElementById("h"), {
camera,
directionalLight: { direction: [1, 0.5, 1], intensity: 1, color: "#fff", castShadow: true },
ambientLight: { color: "#fff", intensity: 0.3 },
shadow: { color: "#000", opacity: 1, lift: 0.05, parametric: true, definition: 24, followAnimation: follow },
});
const floor = { polygons: [{ vertices: [[-12,-12,0],[12,-12,0],[12,12,0],[-12,12,0]], color: "#cbd5e1" }] };
// caster: a box hovering above the floor; we "animate" it by translating in X.
function poseBox(dx) { return boxPolygons({ size: 4 }).map((p) => ({ ...p, color: "#d22", vertices: p.vertices.map((v) => [v[0] + dx, v[1], v[2] + 5]) })); }
const caster = scene.add({ polygons: poseBox(0) }, { id: "box", castShadow: true, receiveShadow: false, merge: false, stableDom: true });
scene.add(floor, { id: "floor", receiveShadow: true, merge: false });
scene.applyCamera();
window.__step = (dx) => caster.setPolygons(poseBox(dx), { stableDom: true, recomputeAutoCenter: false });
window.__shadowHash = () => { const d=[...document.querySelectorAll("[class*=shadow] path")].map(p=>p.getAttribute("d")||"").join(""); let h=0; for(let i=0;i<d.length;i++) h=(h*31+d.charCodeAt(i))>>>0; return h; };
window.__ready = true;
</script></body></html>
8 changes: 7 additions & 1 deletion bench/entries/parityMeshes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,15 @@
* (vanilla / React / Vue) renders byte-identical polygons. Bundled into
* bench/.generated/parity-meshes.js.
*/
import { boxPolygons } from "@layoutit/polycss-core";
import { boxPolygons, spherePolygons } from "@layoutit/polycss-core";
import type { Polygon } from "@layoutit/polycss-core";

/** Round caster — its many-vertex silhouette makes the parametric `definition`
* knob visually obvious. */
export function spherePolys(radius = 8, subdivisions = 2, color = "#dc2626"): Polygon[] {
return spherePolygons({ radius, subdivisions }).map((p) => ({ ...p, color }));
}

/** Unit-2 cube centered at the origin (sit it on the floor with position z=1). */
export function cubePolygons(color = "#dc2626"): Polygon[] {
return boxPolygons({ size: 2 }).map((p) => ({ ...p, color }));
Expand Down
91 changes: 91 additions & 0 deletions bench/shadow-diff.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Parametric-vs-exact shadow harness.
//
// Compares PolyCSS parametric shadows (pm=1) against PolyCSS exact shadows
// (pm=0) at IDENTICAL camera + light. Exact is ground truth, so the pixel diff
// IS the parametric approximation error. Object pixels are identical in both
// renders and cancel, so the diff isolates the shadow (no need to tell object
// from shadow visually). The browser decodes the PNGs and returns only scalar
// metrics, so no Node image library is needed.
//
// Usage: node bench/shadow-diff.mjs [base] (default base http://localhost:4322)
import { chromium } from "playwright";
import { chromiumArgsWithGpuDefault } from "./chromium-defaults.mjs";

const BASE = process.argv[2] ?? "http://localhost:4322";
const PAGE = "/shadow-parametric.html";

// scenario knobs shared by both renders; `pm` flips parametric on/off.
const CONFIGS = [
{ name: "castle floor (steep)", mesh: "castle", sx: 1, ss: 0, def: 48, q: "rx=55&ry=15&z=18&dx=1&dy=0.6&dz=1" },
{ name: "castle floor (graze)", mesh: "castle", sx: 1, ss: 0, def: 48, q: "rx=55&ry=15&z=18&dx=1.4&dy=0.2&dz=0.5" },
{ name: "castle self (steep)", mesh: "castle", sx: 0, ss: 1, def: 48, q: "rx=62&ry=20&z=40&dx=1&dy=0.6&dz=1" },
{ name: "castle self (side) ", mesh: "castle", sx: 0, ss: 1, def: 48, q: "rx=62&ry=20&z=40&dx=1.4&dy=0.2&dz=0.5" },
{ name: "apple floor (steep)", mesh: "apple", sx: 1, ss: 0, def: 48, q: "rx=45&ry=10&z=30&dx=1&dy=0.6&dz=1" },
{ name: "apple self (steep)", mesh: "apple", sx: 0, ss: 1, def: 48, q: "rx=45&ry=10&z=40&dx=1&dy=0.6&dz=1" },
{ name: "cottage floor (steep)", mesh: "cottage", sx: 1, ss: 0, def: 48, q: "rx=50&ry=12&z=22&dx=1&dy=0.6&dz=1" },
{ name: "coliseum floor (steep)", mesh: "coliseum", sx: 1, ss: 0, def: 48, q: "rx=50&ry=12&z=22&dx=1&dy=0.6&dz=1" },
];

const url = (c, pm) => `${BASE}${PAGE}?mesh=${c.mesh}&pm=${pm}&sx=${c.sx}&ss=${c.ss}&def=${c.def}&fv=1&${c.q}`;

async function shotHost(page, u) {
await page.goto(u, { waitUntil: "networkidle" });
await page.waitForSelector("#poly-host .polycss-scene", { state: "attached", timeout: 30000 }).catch(() => {});
await page.waitForTimeout(1800);
const el = await page.$("#poly-host");
return await el.screenshot();
}

async function diff(scratch, bufA, bufB) {
return await scratch.evaluate(async ([a, b]) => {
const load = (src) => new Promise((res) => { const i = new Image(); i.onload = () => res(i); i.src = "data:image/png;base64," + src; });
const [iA, iB] = await Promise.all([load(a), load(b)]);
const W = Math.min(iA.width, iB.width), H = Math.min(iA.height, iB.height);
const cv = document.createElement("canvas"); cv.width = W; cv.height = H;
const cx = cv.getContext("2d", { willReadFrequently: true });
cx.drawImage(iA, 0, 0); const A = cx.getImageData(0, 0, W, H).data;
cx.clearRect(0, 0, W, H); cx.drawImage(iB, 0, 0); const B = cx.getImageData(0, 0, W, H).data;
const THR = 18;
// background luminance = top-left corner (always empty in both panels).
const bg = A[0] * 0.299 + A[1] * 0.587 + A[2] * 0.114;
let diff = 0, over = 0, under = 0, sum = 0;
let pShadow = 0, eShadow = 0, inter = 0, uni = 0;
for (let i = 0; i < A.length; i += 4) {
const la = A[i] * 0.299 + A[i + 1] * 0.587 + A[i + 2] * 0.114;
const lb = B[i] * 0.299 + B[i + 1] * 0.587 + B[i + 2] * 0.114;
const d = la - lb, ad = Math.abs(d);
if (ad > THR) { diff++; sum += ad; if (d < 0) over++; else under++; }
const sa = la < bg - THR, sb = lb < bg - THR;
if (sa) pShadow++; if (sb) eShadow++;
if (sa && sb) inter++; if (sa || sb) uni++;
}
const N = A.length / 4;
return {
pAreaPct: (100 * pShadow / N).toFixed(2), // parametric shadow coverage area
eAreaPct: (100 * eShadow / N).toFixed(2), // exact shadow coverage area
overPct: (100 * over / N).toFixed(2), // param darker than exact (over-shadow)
underPct: (100 * under / N).toFixed(2), // param lighter (missing shadow)
mad: (sum / N).toFixed(2),
iou: uni ? (inter / uni).toFixed(3) : "1.000", // only meaningful for shadows-only (sx=1)
};
}, [bufA.toString("base64"), bufB.toString("base64")]);
}

const browser = await chromium.launch({ args: chromiumArgsWithGpuDefault() });
const page = await browser.newPage({ viewport: { width: 1600, height: 900 } });
const scratch = await browser.newPage();
await scratch.setContent("<html><body></body></html>");
page.on("console", (m) => { if (m.type() === "error") console.error("[page]", m.text().slice(0, 120)); });

console.log("\nparametric (pm=1) vs exact (pm=0) — exact is ground truth\n");
console.log("scenario pArea% eArea% over% under% MAD IoU(sx=1)");
console.log("-".repeat(72));
for (const c of CONFIGS) {
const a = await shotHost(page, url(c, 1));
const b = await shotHost(page, url(c, 0));
const r = await diff(scratch, a, b);
const iou = c.sx === 1 ? r.iou : " - ";
console.log(`${c.name} ${r.pAreaPct.padStart(6)} ${r.eAreaPct.padStart(6)} ${r.overPct.padStart(6)} ${r.underPct.padStart(5)} ${r.mad.padStart(5)} ${iou}`);
}
console.log("");
await browser.close();
Loading
Loading