Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
cebc1b5
feat(core): add baked shadow projection helpers
apresmoi May 23, 2026
a5953b4
feat(polycss): emit baked-mode shadow leaves with CPU-baked matrix3d
apresmoi May 23, 2026
36bc46f
feat(react): emit baked-mode shadow leaves with CPU-baked matrix3d
apresmoi May 23, 2026
a61b868
feat(vue): emit baked-mode shadow leaves with CPU-baked matrix3d
apresmoi May 23, 2026
7478695
test(polycss): cover baked-shadow light-change re-emit + ground-var s…
apresmoi May 23, 2026
1dc2bf3
docs: update lighting + shadow descriptions for baked-mode shadows
apresmoi May 23, 2026
f3f8735
Merge remote-tracking branch 'origin/main' into feat/baked-shadows
apresmoi May 23, 2026
74e9b7b
chore(bench): add baked-shadow + gallery shadow diagnostic scripts
apresmoi May 23, 2026
179207f
feat(polycss): render baked-mode shadows as per-mesh SVG
apresmoi May 23, 2026
7731075
feat(react,vue): mirror SVG baked shadow rendering
apresmoi May 23, 2026
f9ac11b
feat(website): clamp Lighting Elev. slider minimum to 0
apresmoi May 23, 2026
f410147
fix(polycss): emit shadows for all rendered polys, not just camera-vi…
apresmoi May 23, 2026
0fb2166
feat: merge baked shadows into single convex-hull silhouette per mesh
apresmoi May 23, 2026
70c7712
Revert "feat: merge baked shadows into single convex-hull silhouette …
apresmoi May 23, 2026
80e4ab3
chore(bench): dump shadow SVG outerHTML for diagnostics
apresmoi May 23, 2026
25934f2
feat: combine shadow polygons into single compound SVG path per mesh
apresmoi May 23, 2026
69c5c01
feat: unify dynamic + baked shadows on per-mesh SVG path
apresmoi May 23, 2026
393dce5
fix: cover sub-pixel shadow seams with same-color hairline stroke
apresmoi May 23, 2026
ccc9777
fix(shadow): project all polygons (no Lambert cull) so thin meshes ge…
apresmoi May 23, 2026
8d6dbf4
perf(shadow): cap SVG dimensions + GPU-promote to stop low-elevation …
apresmoi May 23, 2026
24cb021
perf(shadow): anchor SVG cap to mesh footprint so shadow under mesh i…
apresmoi May 23, 2026
cc17261
perf(shadow): tighten max-extend to 2000 px beyond mesh footprint
apresmoi May 23, 2026
2486c8d
feat(shadow): expose maxExtend as scene option (default 2000 px)
apresmoi May 23, 2026
a9256f0
feat(website): add Shadow reach slider in Lighting dock
apresmoi May 23, 2026
0919b50
feat(polycss): experimental per-receiver-face shadow projection
apresmoi May 23, 2026
afd77b1
feat(shadow): per-tri 3D-clip receiver shadow + raytrace bench validator
apresmoi May 23, 2026
415e423
Merge remote-tracking branch 'origin/main' into feat/baked-shadows
apresmoi May 23, 2026
85b2e4a
perf(shadow): cache per-receiver face planes across light updates
apresmoi May 23, 2026
333b641
perf(shadow): mount-once SVGs, AABB cull, lower precision, drop stroke
apresmoi May 23, 2026
3dd725d
perf(shadow): cache caster world-vertices + AABB across light updates
apresmoi May 23, 2026
d631a0a
Merge remote-tracking branch 'origin/main' into feat/baked-shadows
apresmoi May 24, 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
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Voxel-shaped meshes are the exception to "all polygons stay mounted": meshes wit
| `<i>` | **Border-shape clipped solid** | Untextured non-rect not caught by the exact corner-shape solid path, on browsers with CSS `border-shape` (Chromium + `pointer:fine` + `hover:hover`) | `border-color: currentColor` on a fixed 16px border-shape primitive, clipped by `border-shape: polygon(...)`; polygon bbox scale and tiny solid bleed are folded into `matrix3d` | None |
| `<s>` | **Atlas slice** | Textured polygons, or untextured non-rect on browsers without `border-shape` | `background-image` slice of packed bitmap on an auto-budgeted fixed primitive (128px for desktop-class `textureQuality="auto"`, 64px for mobile-class `auto` and explicit numeric quality); atlas position/size and `matrix3d` scale are normalized to the slice, shared textured edges get low-alpha atlas pixels repaired during atlas generation, and solid fallbacks get same-color edge bleed to avoid dark alpha fringes | Bounding-rect area |
| `<u>` | **Stable solid triangle / corner-shape solid** | Triangles on non-WebKit engines; or untextured non-triangle polygons whose normalized outline is exactly a rectangle with one or more beveled corners on browsers with CSS `corner-shape` | Triangles use a 32px box with two beveled top corners and `background: currentColor` when CSS `corner-shape` support is present, progressively falling back to the CSS border-color triangle trick; exact corner-shape solids use a bare fixed 16px box with inline per-corner radii + `corner-*-shape: bevel` and `background: currentColor`. Tiny solid bleed is folded into `matrix3d`. WebKit/Safari falls through to `<s>` for border triangles because transformed CSS border triangles composite incorrectly there. | None |
| `<q>` | **Cast shadow leaf** | Per casting polygon when `castShadow: true` and dynamic lighting mode. Applies regardless of caster strategy — `<b>`/`<i>`/`<s>`/`<u>` all produce a `<q>` shadow because only the polygon's outline matters, not its surface. | Same `border-color: currentColor` + `border-shape: polygon(...)` as `<i>`, but transform composes `var(--shadow-proj)` to project the polygon onto the ground plane along the CSS-space light direction | None |
| `<q>` | **Cast shadow leaf** | Per casting polygon when `castShadow: true`, in either lighting mode. Applies regardless of caster strategy — `<b>`/`<i>`/`<s>`/`<u>` all produce a `<q>` shadow because only the polygon's outline matters, not its surface. | Same `border-color: currentColor` + `border-shape: polygon(...)` as `<i>`. Dynamic mode chains `var(--shadow-proj)` (driven by `--clx/y/z` + `--shadow-ground-cssz`) so the projection follows the live light vars. Baked mode CPU-bakes the projection into the leaf's inline `matrix3d(...)` and drops back-facing polys from the DOM entirely instead of opacity-gating them. | None |

Strategies are ordered cheapest → most expensive. The mesher's job is to maximise `<b>` / `<u>` / `<i>` and minimise `<s>` (see "Meshing implications" below).

Expand All @@ -45,8 +45,8 @@ The `.vox` fast path emits plain `<b>` elements inside `.polycss-voxel-face` wra

### Lighting modes (`PolyTextureLightingMode = "baked" | "dynamic"`)

- **Baked.** Lambert is computed once on the CPU per polygon, multiplied into the inline `color` (for `<b>`/`<i>`/`<u>`) or into the rasterised atlas pixels (for `<s>`). Moving a light requires re-rasterising affected polys.
- **Dynamic.** Scene root carries the light setup as custom properties (`--plx/y/z`, `--plr/g/b`, `--pli`, `--par/g/b`, `--pai`). Each leaf embeds its surface normal (`--pnx/y/z`) and base color (`--psr/g/b`) inline. CSS `calc()` resolves the Lambert dot product and per-channel tint at paint time. Moving a light mutates one var on the scene root — zero JS, no atlas redraw.
- **Baked.** Lambert is computed once on the CPU per polygon, multiplied into the inline `color` (for `<b>`/`<i>`/`<u>`) or into the rasterised atlas pixels (for `<s>`). Moving a light requires explicit re-rasterising of affected polys via `mesh.rebakeAtlas()`; cast-shadow `<q>` leaves auto re-emit with a fresh CPU-baked `matrix3d` (DOM-only, no atlas redraw) so shadows can still follow the light interactively even when the lit-side shading stays frozen.
- **Dynamic.** Scene root carries the light setup as custom properties (`--plx/y/z`, `--plr/g/b`, `--pli`, `--par/g/b`, `--pai`). Each leaf embeds its surface normal (`--pnx/y/z`) and base color (`--psr/g/b`) inline. CSS `calc()` resolves the Lambert dot product and per-channel tint at paint time. Moving a light mutates one var on the scene root — zero JS, no atlas redraw. Cast shadows project via `--shadow-proj` and gate back-facing polys with a CSS opacity calc.

All solid/atlas tags work in both modes. The `.vox` direct-matrix fast path is baked-only for now; dynamic mode uses the polygon path so lighting semantics stay correct. The full coverage matrix is in `packages/polycss/src/styles/styles.ts`.

Expand Down
139 changes: 139 additions & 0 deletions bench/baked-shadow-diagnose.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
#!/usr/bin/env node
/**
* Visual + structural diagnostic for the baked-mode cast-shadow path.
*
* Renders the same minimal cube-on-a-ground scene in four configurations
* (baked/dynamic × castShadow on/off) and reports:
* - element counts (leaves, shadow leaves, mesh wrappers)
* - scene-root state (`--shadow-ground-cssz`, `--clx`, data-polycss-lighting)
* - inline transform on the first few shadow leaves
* - a screenshot of each variant
*
* Usage:
* node bench/baked-shadow-diagnose.mjs # headless, all variants
* node bench/baked-shadow-diagnose.mjs --headed # open browser
* node bench/baked-shadow-diagnose.mjs --port=4400
*
* Requires the bench bundle to be built first (`node bench/build.mjs` or
* `pnpm bench:build`).
*/
import { chromium } from "playwright";
import { spawn } from "node:child_process";
import { mkdir, writeFile } from "node:fs/promises";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { chromiumArgsWithGpuDefault } from "./chromium-defaults.mjs";

const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, "..");

const argv = process.argv.slice(2);
const optStr = (name, dflt = "") => {
const i = argv.indexOf(`--${name}`);
if (i >= 0) return argv[i + 1] ?? dflt;
const eq = argv.find((a) => a.startsWith(`--${name}=`));
return eq ? eq.slice(name.length + 3) : dflt;
};
const hasFlag = (name) => argv.includes(`--${name}`) || argv.includes(`--${name}=true`);

const PORT = Number(optStr("port", "4400"));
const HEADED = hasFlag("headed");

// Start the perf-serve static server so the .generated/polycss.js bundle
// resolves under the same origin as the HTML page.
const serverProc = spawn(
"node",
["bench/perf-serve.mjs", "--port", String(PORT)],
{ cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"] },
);
await new Promise((resolveReady) => {
const onLine = (data) => {
if (String(data).includes("[perf-serve] index")) {
serverProc.stdout.off("data", onLine);
resolveReady();
}
};
serverProc.stdout.on("data", onLine);
});

const outDir = resolve(repoRoot, "bench/results/baked-shadow");
await mkdir(outDir, { recursive: true });

const browser = await chromium.launch({
headless: !HEADED,
args: chromiumArgsWithGpuDefault([], { softwareBackend: false }),
});

const variants = [
{ name: "baked-cast", query: "?mode=baked&cast=1" },
{ name: "baked-nocast", query: "?mode=baked&cast=0" },
{ name: "dynamic-cast", query: "?mode=dynamic&cast=1" },
{ name: "dynamic-nocast",query: "?mode=dynamic&cast=0" },
];

const report = {};

try {
const ctx = await browser.newContext({ viewport: { width: 800, height: 600 } });
for (const v of variants) {
const page = await ctx.newPage();
const url = `http://localhost:${PORT}/baked-shadow.html${v.query}`;
const consoleMsgs = [];
page.on("console", (msg) => {
if (msg.type() === "error" || msg.type() === "warning") {
consoleMsgs.push(`[${msg.type()}] ${msg.text()}`);
}
});
page.on("pageerror", (err) => {
consoleMsgs.push(`[pageerror] ${err.message}`);
});

await page.goto(url, { waitUntil: "networkidle", timeout: 10000 });
// Give the scene a tick to render.
await page.waitForTimeout(200);

const snapshot = await page.evaluate(() => window.__polySnapshot());

const shotPath = resolve(outDir, `${v.name}.png`);
await page.screenshot({ path: shotPath, fullPage: false });

report[v.name] = {
url,
snapshot,
consoleMsgs,
screenshot: shotPath.slice(repoRoot.length + 1),
};

await page.close();
}
} finally {
await browser.close();
serverProc.kill();
}

const summaryPath = resolve(outDir, "report.json");
await writeFile(summaryPath, JSON.stringify(report, null, 2));

console.log("\n──── baked-shadow diagnose report ────\n");
for (const [name, r] of Object.entries(report)) {
console.log(`▷ ${name} (${r.url})`);
const s = r.snapshot;
console.log(` mode=${s.mode} cast=${s.castShadow} data-polycss-lighting=${s.lightingAttr}`);
console.log(` meshes=${s.meshCount} leaves=${s.leafCount} shadows=${s.shadowCount}`);
console.log(` --shadow-ground-cssz=${s.groundCssZ_var} --clx=${s.clx_var}`);
if (s.sample.length > 0) {
console.log(` shadow leaf samples:`);
for (const sa of s.sample) {
const t = sa.transform.length > 100 ? sa.transform.slice(0, 100) + "…" : sa.transform;
console.log(` transform: ${t}`);
console.log(` width=${sa.width} height=${sa.height} color=${sa.color}`);
}
}
if (r.consoleMsgs.length > 0) {
console.log(` !! console:`);
for (const m of r.consoleMsgs) console.log(` ${m}`);
}
console.log(` screenshot: ${r.screenshot}\n`);
}

console.log(`Full report: ${summaryPath.slice(repoRoot.length + 1)}`);
127 changes: 127 additions & 0 deletions bench/baked-shadow.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>polycss baked-shadow diagnose</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #ddd; }
#host { position: fixed; inset: 0; }
#status {
position: fixed; bottom: 8px; left: 8px;
font: 12px ui-monospace, monospace;
background: rgba(0,0,0,0.6); color: #fff;
padding: 6px 10px; border-radius: 4px;
white-space: pre; pointer-events: none;
}
</style>
</head>
<body>
<div id="host"></div>
<pre id="status"></pre>

<script type="module">
import {
createPolyCamera,
createPolyScene,
} from "./.generated/polycss.js";

const query = new URLSearchParams(location.search);
// Default: baked + castShadow — the failing case.
const mode = query.get("mode") || "baked"; // baked | dynamic
const cast = query.get("cast") !== "0"; // 1 (default) | 0
const includeGround = query.get("ground") !== "0";

const host = document.getElementById("host");
const camera = createPolyCamera({ rotX: 35, rotY: -45, zoom: 1.5 });
const scene = createPolyScene(host, {
camera,
directionalLight: { direction: [0.4, -0.7, 0.59], color: "#ffffff", intensity: 1 },
ambientLight: { color: "#ffffff", intensity: 0.4 },
textureLighting: mode,
autoCenter: false,
});

// ── Cube: 6 axis-aligned face polygons of a unit cube. Bottom sits
// ON the ground (Z=0) so the shadow plane (derived from caster min Z
// + lift) aligns with the ground mesh.
const C = 1.5; // half-size in world units (CSS pixels at BASE_TILE=50)
const ZBOTTOM = 0;
const ZTOP = ZBOTTOM + C * 2;
function quad(p0, p1, p2, p3, color) {
return { vertices: [p0, p1, p2, p3], color };
}
const cubePolys = [
// Top (normal +Z)
quad([-C, -C, ZTOP], [ C, -C, ZTOP], [ C, C, ZTOP], [-C, C, ZTOP], "#ef4444"),
// Bottom (normal -Z)
quad([-C, C, ZBOTTOM], [ C, C, ZBOTTOM], [ C, -C, ZBOTTOM], [-C, -C, ZBOTTOM], "#7f1d1d"),
// +X
quad([ C, -C, ZBOTTOM], [ C, C, ZBOTTOM], [ C, C, ZTOP], [ C, -C, ZTOP], "#3b82f6"),
// -X
quad([-C, C, ZBOTTOM], [-C, -C, ZBOTTOM], [-C, -C, ZTOP], [-C, C, ZTOP], "#1e3a8a"),
// +Y
quad([ C, C, ZBOTTOM], [-C, C, ZBOTTOM], [-C, C, ZTOP], [ C, C, ZTOP], "#22c55e"),
// -Y
quad([-C, -C, ZBOTTOM], [ C, -C, ZBOTTOM], [ C, -C, ZTOP], [-C, -C, ZTOP], "#14532d"),
];

// Ground plane (gray, larger than the cube footprint).
const G = 6;
const groundPolys = [
quad([-G, -G, 0], [ G, -G, 0], [ G, G, 0], [-G, G, 0], "#cbd5e1"),
];

function makeParseResult(polygons) {
return { polygons, dispose() {} };
}

if (includeGround) {
scene.add(makeParseResult(groundPolys), { castShadow: false });
}
const cubeHandle = scene.add(makeParseResult(cubePolys), {
castShadow: cast,
});

// Surface state for the diagnose script to read.
function snapshot() {
const sceneEl = host.querySelector(".polycss-scene");
const meshes = host.querySelectorAll(".polycss-mesh");
const shadows = host.querySelectorAll(".polycss-shadow");
const leaves = host.querySelectorAll(".polycss-scene > .polycss-mesh > :is(b, i, s, u)");
const sample = [];
const sampleSize = Math.min(3, shadows.length);
for (let i = 0; i < sampleSize; i++) {
const el = shadows[i];
sample.push({
transform: el.style.transform,
width: el.style.width,
height: el.style.height,
color: el.style.color,
});
}
// Dump SVG inner structure for baked-mode debugging.
const svgs = Array.from(host.querySelectorAll("svg.polycss-shadow"));
const svgInfo = svgs.map((svg) => ({
pathCount: svg.querySelectorAll("path").length,
outerHTML: svg.outerHTML.slice(0, 800),
}));
return {
mode,
castShadow: cast,
groundCssZ_var: sceneEl?.style.getPropertyValue("--shadow-ground-cssz") || "(unset)",
clx_var: sceneEl?.style.getPropertyValue("--clx") || "(unset)",
lightingAttr: sceneEl?.dataset.polycssLighting || "(unset)",
meshCount: meshes.length,
leafCount: leaves.length,
shadowCount: shadows.length,
sample,
svgInfo,
};
}

window.__polySnapshot = snapshot;
document.getElementById("status").textContent =
JSON.stringify(snapshot(), null, 2);
</script>
</body>
</html>
Loading
Loading