From cebc1b5c454a3937d710ed651edee492add531b8 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 23 May 2026 02:03:28 +0200 Subject: [PATCH 01/28] feat(core): add baked shadow projection helpers --- packages/core/src/index.ts | 8 ++ packages/core/src/shadow/projection.test.ts | 86 +++++++++++++++++++++ packages/core/src/shadow/projection.ts | 83 ++++++++++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 packages/core/src/shadow/projection.test.ts create mode 100644 packages/core/src/shadow/projection.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 085d0ef3..6761b82a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -141,6 +141,14 @@ export type { export { axesHelperPolygons, boxPolygons, arrowPolygons, ringPolygons, ringQuadPolygons, planePolygons, octahedronPolygons, spherePolygons, tetrahedronPolygons, icosahedronPolygons, dodecahedronPolygons, cylinderPolygons, conePolygons, torusPolygons } from "./helpers"; export type { AxesHelperOptions, BoxFace, BoxFaceOptions, BoxPolygonsOptions, ArrowPolygonsOptions, RingPolygonsOptions, RingQuadPolygonsOptions, PlanePolygonsOptions, OctahedronPolygonsOptions, SpherePolygonsOptions, TetrahedronPolygonsOptions, IcosahedronPolygonsOptions, DodecahedronPolygonsOptions, CylinderPolygonsOptions, ConePolygonsOptions, TorusPolygonsOptions } from "./helpers"; +// ── Shadow ──────────────────────────────────────────────────────── +export { + BAKED_SHADOW_MIN_UP, + BAKED_SHADOW_Z_SQUASH, + buildBakedShadowProjectionMatrix, + isBakedShadowCaster, +} from "./shadow/projection"; + // ── Animation ───────────────────────────────────────────────────── export { createPolyAnimationMixer, diff --git a/packages/core/src/shadow/projection.test.ts b/packages/core/src/shadow/projection.test.ts new file mode 100644 index 00000000..beb18be7 --- /dev/null +++ b/packages/core/src/shadow/projection.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from "vitest"; +import { + BAKED_SHADOW_MIN_UP, + BAKED_SHADOW_Z_SQUASH, + buildBakedShadowProjectionMatrix, + isBakedShadowCaster, +} from "./projection"; + +describe("buildBakedShadowProjectionMatrix", () => { + it("produces the identity transform for axis-aligned top-down light at ground=0", () => { + const m = buildBakedShadowProjectionMatrix([0, 0, -1], 0); + // col1 = [1,0,0,0], col2 = [0,1,0,0] + expect(m.slice(0, 4)).toEqual([1, 0, 0, 0]); + expect(m.slice(4, 8)).toEqual([0, 1, 0, 0]); + // -lx/lz = 0/-1 = 0, -ly/lz = 0/-1 = 0 + expect(m[8]).toBeCloseTo(0, 6); + expect(m[9]).toBeCloseTo(0, 6); + expect(m[10]).toBeCloseTo(BAKED_SHADOW_Z_SQUASH, 6); + expect(m[11]).toBe(0); + // Ground = 0: col4 = [0, 0, 0, 1] + expect(m[12]).toBeCloseTo(0, 6); + expect(m[13]).toBeCloseTo(0, 6); + expect(m[14]).toBeCloseTo(0, 6); + expect(m[15]).toBe(1); + }); + + it("offsets translation by groundCssZ", () => { + const m = buildBakedShadowProjectionMatrix([0, 0, -1], 100); + // With lx=ly=0, the only G-dependent entry left is m[14] = G*(1-Z) + expect(m[14]).toBeCloseTo(100 * (1 - BAKED_SHADOW_Z_SQUASH), 6); + }); + + it("encodes the shear from an oblique light direction", () => { + const m = buildBakedShadowProjectionMatrix([1, 0, -1], 0); + // After normalize: lx ≈ 0.7071, ly = 0, lz ≈ -0.7071 + // -lx/lz = -(0.7071)/(-0.7071) = +1 + expect(m[8]).toBeCloseTo(1, 5); + expect(m[9]).toBeCloseTo(0, 5); + }); + + it("clamps near-horizontal light up-axis to BAKED_SHADOW_MIN_UP", () => { + // lz = -0.0001 should be clamped to -BAKED_SHADOW_MIN_UP + const m = buildBakedShadowProjectionMatrix([0, 0, -0.0001], 0); + // The unnormalized direction [0,0,-0.0001] normalizes to [0,0,-1] (len 0.0001 > 0) + // So lz_normalized = -1, no clamp needed in this case. + // Use a tilted near-horizontal vector instead to actually exercise the clamp: + const m2 = buildBakedShadowProjectionMatrix([1, 0, -0.001], 0); + // After normalize lz ≈ -0.001 / 1.0000005 ≈ -0.001 → clamped to -0.01 + // -lx/lz with lx≈1, lz=-0.01 → +100 + expect(Math.abs(m2[8])).toBeLessThanOrEqual(100 + 1e-3); + expect(Math.abs(m2[8])).toBeGreaterThan(10); + // Use the public min directly to make the expectation obvious. + expect(BAKED_SHADOW_MIN_UP).toBe(0.01); + // Also exercise the variable so the unused-import linter is happy in + // a separate assertion path. + expect(m[10]).toBeCloseTo(BAKED_SHADOW_Z_SQUASH, 6); + }); +}); + +describe("isBakedShadowCaster", () => { + it("returns true for polygons whose normal points along the light direction (far side)", () => { + // Top-down light, bottom face of a cube (normal pointing down) → silhouette + expect(isBakedShadowCaster([0, 0, -1], [0, 0, -1])).toBe(true); + }); + + it("returns false for polygons whose normal opposes the light direction (lit side)", () => { + // Top-down light, top face of a cube (normal pointing up) → lit, not a caster + expect(isBakedShadowCaster([0, 0, 1], [0, 0, -1])).toBe(false); + }); + + it("returns false for polygons whose normal is perpendicular to the light", () => { + // Vertical wall under a top-down light: doesn't add to the silhouette + expect(isBakedShadowCaster([1, 0, 0], [0, 0, -1])).toBe(false); + }); + + it("handles oblique lights", () => { + // Light going down-and-right; a face whose normal also points down-and-right is a caster + expect(isBakedShadowCaster([1, 0, -1], [1, 0, -1])).toBe(true); + // Opposite normal → not a caster + expect(isBakedShadowCaster([-1, 0, 1], [1, 0, -1])).toBe(false); + }); + + it("is robust to an un-normalized light direction", () => { + expect(isBakedShadowCaster([0, 0, -1], [0, 0, -10])).toBe(true); + }); +}); diff --git a/packages/core/src/shadow/projection.ts b/packages/core/src/shadow/projection.ts new file mode 100644 index 00000000..fd7a5aff --- /dev/null +++ b/packages/core/src/shadow/projection.ts @@ -0,0 +1,83 @@ +// Pure-math helpers for baked-mode shadow projection. +// +// Dynamic mode keeps the shadow projection in CSS (--shadow-proj on the +// scene root, driven by --clx/--cly/--clz + --shadow-ground-cssz) so the +// browser recomputes it whenever the light moves. Baked mode skips that +// machinery entirely: the light is fixed, so the projection matrix can +// be CPU-computed once at scene build time and reused inline on every +// shadow leaf. No CSS vars, no @property dependency, no per-paint calc +// chain — and back-facing polygons are dropped from the DOM instead of +// being emitted with opacity 0. +import type { Vec3 } from "../types"; + +/** Tiny non-zero scale collapsed into the projection's Z column to keep + * the matrix invertible. Chromium skips elements whose composed + * transform is singular (m22 = 0 would make this a true projection + * matrix, but Chromium would refuse to paint it), so we crush Z to 1% + * of its input instead of exactly zero. The result still looks flat + * to the eye — sub-pixel drift on any realistic scene size. */ +export const BAKED_SHADOW_Z_SQUASH = 0.01; + +/** Minimum absolute value of the up-axis light component before the + * projection blows up (we divide by it). Matches the --clz clamp in + * the dynamic-mode applyDynamicLightVars helper so baked + dynamic + * behave identically when the light is near-horizontal. */ +export const BAKED_SHADOW_MIN_UP = 0.01; + +/** + * Build the CSS-space shadow projection matrix for a fixed light + ground + * plane. The 16-element output mirrors the matrix3d expression in the + * dynamic-mode `--shadow-proj` CSS custom property, but with literal + * numbers — ready to be formatted into a single `matrix3d(...)` per + * shadow leaf. + * + * `lightDir` is the direction the light TRAVELS (e.g. `[0, 0, -1]` is + * straight down). Polycss world Z is up, and the world→CSS axis swap + * leaves Z alone — see styles.ts for the full convention. + * + * `groundCssZ` is the receiver plane in CSS-Z (= world-Z) coordinates, + * already in unit-less form (matrix3d entries must be dimensionless). + */ +export function buildBakedShadowProjectionMatrix( + lightDir: Vec3, + groundCssZ: number, +): number[] { + const len = Math.hypot(lightDir[0], lightDir[1], lightDir[2]) || 1; + const lx = lightDir[0] / len; + const ly = lightDir[1] / len; + const lzRaw = lightDir[2] / len; + const lz = + Math.sign(lzRaw || 1) * Math.max(Math.abs(lzRaw), BAKED_SHADOW_MIN_UP); + const G = groundCssZ; + const Z = BAKED_SHADOW_Z_SQUASH; + // Column-major 4×4, identical layout to CSS matrix3d. + return [ + 1, 0, 0, 0, + 0, 1, 0, 0, + -lx / lz, -ly / lz, Z, 0, + (G * lx) / lz, (G * ly) / lz, G * (1 - Z), 1, + ]; +} + +/** + * Decides whether a polygon should cast a shadow given its outward + * normal and the light's travel direction. + * + * True for polygons whose normals point in the same direction as the + * light travels — i.e., on the far/dark side of the mesh from the + * light's POV. Those define the silhouette of the cast shadow. + * + * False for front-facing polygons whose projection would land inside + * the silhouette and only add overdraw. Dynamic mode hides these with + * a Lambert opacity gate; baked mode skips the DOM emission entirely. + */ +export function isBakedShadowCaster( + normal: Vec3, + lightDir: Vec3, +): boolean { + const len = Math.hypot(lightDir[0], lightDir[1], lightDir[2]) || 1; + const lx = lightDir[0] / len; + const ly = lightDir[1] / len; + const lz = lightDir[2] / len; + return normal[0] * lx + normal[1] * ly + normal[2] * lz > 0; +} From a5953b470cbf043481a0957ec305ff86e3342e3d Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 23 May 2026 02:13:35 +0200 Subject: [PATCH 02/28] feat(polycss): emit baked-mode shadow leaves with CPU-baked matrix3d --- .../polycss/src/api/createPolyScene.test.ts | 43 ++++- packages/polycss/src/api/createPolyScene.ts | 158 +++++++++++++++--- packages/polycss/src/styles/styles.ts | 11 +- packages/react/src/styles/styles.ts | 9 +- packages/vue/src/styles/styles.ts | 6 +- 5 files changed, 187 insertions(+), 40 deletions(-) diff --git a/packages/polycss/src/api/createPolyScene.test.ts b/packages/polycss/src/api/createPolyScene.test.ts index c8c43b5b..32a5c5a1 100644 --- a/packages/polycss/src/api/createPolyScene.test.ts +++ b/packages/polycss/src/api/createPolyScene.test.ts @@ -1890,9 +1890,31 @@ describe("createPolyScene", () => { expect(host.querySelectorAll(".polycss-shadow").length).toBe(2); }); - it("castShadow:true in baked mode emits NO shadow leaves", () => { + it("castShadow:true in baked mode emits shadow leaves with a CPU-baked matrix3d transform", () => { + // Baked mode bakes the shadow projection into each leaf's inline + // transform on the CPU — no var(--shadow-proj), no --pnx/--pny/--pnz + // opacity gate. The default light has +Z so the +Z-facing triangle + // is a caster and emits one shadow. scene = makeScene(host, { textureLighting: "baked" }); scene.add(makeParseResult([triangle()]), { castShadow: true }); + const shadow = host.querySelector(".polycss-shadow") as HTMLElement; + expect(shadow).not.toBeNull(); + // Inline transform is a literal matrix3d() chain — no CSS var. + expect(shadow.style.transform).toMatch(/^matrix3d\(.+\)\s+matrix3d\(/); + expect(shadow.style.transform).not.toContain("var(--shadow-proj)"); + // Back-facing polys are skipped, not opacity-gated — no normal vars. + expect(shadow.style.getPropertyValue("--pnx")).toBe(""); + expect(shadow.style.getPropertyValue("--pny")).toBe(""); + expect(shadow.style.getPropertyValue("--pnz")).toBe(""); + }); + + it("baked mode skips shadow leaves for polygons facing away from the light", () => { + // backTriangle wound CW from +Z → surface normal is -Z. Default light + // has +Z component, so the triangle is on the lit-AWAY side and + // should NOT emit a shadow leaf (its projection would land inside + // any other caster's silhouette anyway). + scene = makeScene(host, { textureLighting: "baked" }); + scene.add(makeParseResult([backTriangle()]), { castShadow: true }); expect(host.querySelectorAll(".polycss-shadow").length).toBe(0); }); @@ -1964,20 +1986,27 @@ describe("createPolyScene", () => { expect(host.querySelectorAll(".polycss-shadow").length).toBe(0); }); - it("switching from dynamic to baked removes shadow leaves", () => { + it("switching from dynamic to baked rebuilds shadow leaves with inline matrix3d", () => { scene = makeScene(host, dynOpts); scene.add(makeParseResult([triangle()]), { castShadow: true }); - expect(host.querySelectorAll(".polycss-shadow").length).toBeGreaterThan(0); + const dynamicShadow = host.querySelector(".polycss-shadow") as HTMLElement; + expect(dynamicShadow.style.transform).toContain("var(--shadow-proj)"); scene.setOptions({ textureLighting: "baked" }); - expect(host.querySelectorAll(".polycss-shadow").length).toBe(0); + const bakedShadow = host.querySelector(".polycss-shadow") as HTMLElement; + expect(bakedShadow).not.toBeNull(); + expect(bakedShadow.style.transform).not.toContain("var(--shadow-proj)"); + expect(bakedShadow.style.transform).toMatch(/^matrix3d\(.+\)\s+matrix3d\(/); }); - it("switching from baked back to dynamic re-emits shadow leaves", () => { + it("switching from baked back to dynamic re-emits shadow leaves using var(--shadow-proj)", () => { scene = makeScene(host, { textureLighting: "baked" }); scene.add(makeParseResult([triangle()]), { castShadow: true }); - expect(host.querySelectorAll(".polycss-shadow").length).toBe(0); + const bakedShadow = host.querySelector(".polycss-shadow") as HTMLElement; + expect(bakedShadow.style.transform).not.toContain("var(--shadow-proj)"); scene.setOptions({ ...dynOpts }); - expect(host.querySelectorAll(".polycss-shadow").length).toBeGreaterThan(0); + const dynamicShadow = host.querySelector(".polycss-shadow") as HTMLElement; + expect(dynamicShadow).not.toBeNull(); + expect(dynamicShadow.style.transform).toContain("var(--shadow-proj)"); }); it("textured polygons (s) ALSO emit shadow leaves", () => { diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index 8e1bf72f..6ea1af2f 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -39,12 +39,15 @@ import { CAMERA_BACKFACE_CULL_EPS, DEFAULT_SEAM_BLEED, VOXEL_CAMERA_CULL_NORMAL_LIMIT, + buildBakedShadowProjectionMatrix, cameraCullNormalKey, cameraCullVisibleSignature, computeSceneBbox, findOverlappingPolygonDuplicates, + formatMatrix3dValues, inverseRotateVec3, isAxisAlignedSurfaceNormal, + isBakedShadowCaster, isVoxelCameraCullableNormalGroups, normalFacesCamera, optimizeMeshPolygons, @@ -353,6 +356,22 @@ function strategiesEqual( return true; } +function vec3Equal(a: Vec3 | undefined, b: Vec3 | undefined): boolean { + if (a === b) return true; + if (!a || !b) return false; + return a[0] === b[0] && a[1] === b[1] && a[2] === b[2]; +} + +function shadowOptsEqual( + a: PolySceneOptions["shadow"] | undefined, + b: PolySceneOptions["shadow"] | undefined, +): boolean { + if (a === b) return true; + return (a?.color ?? "#000000") === (b?.color ?? "#000000") + && (a?.opacity ?? 0.25) === (b?.opacity ?? 0.25) + && (a?.lift ?? 0.05) === (b?.lift ?? 0.05); +} + function buildMeshTransform(t: PolyMeshTransform): string | undefined { const parts: string[] = []; if (t.position) { @@ -593,6 +612,13 @@ export function createPolyScene( } const meshes = new Set(); + // Cached CSS-Z of the shadow ground plane. Set by `recomputeShadowGround`. + // In dynamic mode this also flows into the `--shadow-ground-cssz` CSS var + // that drives `--shadow-proj`. In baked mode it's read by `emitShadowLeaves` + // to bake the per-leaf inline projection matrix on the CPU. `null` means + // no casting mesh exists yet, so no shadow leaves should be emitted. + let currentGroundCssZ: number | null = null; + // Apply CSS perspective on the camera wrapper, not the scene element. // CSS `perspective` only foreshortens direct children's 3D transforms, so // the wrapper must be the perspective context for .polycss-scene to work @@ -1106,17 +1132,28 @@ export function createPolyScene( } } - // Emits shadow leaves for all non-textured rendered polys in the entry. - // Each shadow leaf uses the same tag and shape as the original but with a - // flat shadow color and a transform prepended by var(--shadow-proj) so it - // projects onto the ground plane driven entirely by CSS vars. + // Emits shadow leaves for all rendered polys in the entry. Each shadow + // leaf uses the same border-shape as the original but with a flat shadow + // color and a transform that projects the polygon onto the ground plane + // along the directional light. + // + // Dynamic mode prepends `var(--shadow-proj)` so the projection follows + // the live light vars on the scene root (zero JS at light-change time). + // Baked mode pre-composes the projection matrix on the CPU and inlines + // it as a literal `matrix3d(...)` — and drops back-facing polygons from + // the DOM entirely instead of using a CSS opacity gate. // // Shadow leaves are inserted BEFORE their caster siblings so they sit // below in DOM order, which keeps them behind the casters when both are // coplanar in 3D (painter-order tie-breaking favors earlier nodes). function emitShadowLeaves(entry: MeshEntry): void { clearShadowLeaves(entry); - if (!entry.castShadow || currentOptions.textureLighting !== "dynamic") return; + if (!entry.castShadow) return; + const isDynamic = currentOptions.textureLighting === "dynamic"; + // Baked mode needs a ground plane to project onto. If none has been + // computed yet (no caster meshes), bail and wait for the next + // recomputeShadowGround pass to drive emission. + if (!isDynamic && currentGroundCssZ === null) return; const shadowColor = currentOptions.shadow?.color ?? "#000000"; const shadowOpacity = currentOptions.shadow?.opacity ?? 0.25; @@ -1125,6 +1162,17 @@ export function createPolyScene( const r = parsed[0], g = parsed[1], b = parsed[2]; const shadowColorCss = `rgba(${r},${g},${b},${shadowOpacity})`; + // Pre-compute the baked projection matrix once per emit pass — the + // light + ground are fixed so it's identical for every leaf in this + // entry. In dynamic mode the matrix lives in CSS and we skip this. + const lightDir = currentOptions.directionalLight?.direction + ?? ([0.4, -0.7, 0.59] as Vec3); + const bakedProjStr = isDynamic + ? null + : `matrix3d(${formatMatrix3dValues( + buildBakedShadowProjectionMatrix(lightDir, currentGroundCssZ ?? 0), + )})`; + // Loose-tolerance dedup for shadow casting ONLY — much more permissive // than the parse-time dedup that affects the rendered model. Multiple // coincident or near-coincident polygons cast overlapping shadow @@ -1152,6 +1200,12 @@ export function createPolyScene( const plan = item.plan; if (!plan) continue; + // Baked mode: skip polygons facing AWAY from the receiver entirely + // (they don't contribute to the silhouette). Dynamic mode keeps them + // mounted and uses a CSS opacity calc to hide them so the gate + // updates live with the light. + if (!isDynamic && !isBakedShadowCaster(plan.normal, lightDir)) continue; + // Read the original matrix3d from the plan (not from the element // style string) so we never parse strings. const origMatrix = `matrix3d(${plan.matrix})`; @@ -1165,23 +1219,23 @@ export function createPolyScene( // mirrored from 's border-color: currentColor mechanism. // clip-path is forbidden by repo policy (4000+ clip-paths inside // preserve-3d = ~15 s/frame on Chromium). - // - // The caster's normal is pinned inline as --pnx/--pny/--pnz so the - // cascade can compute a Lambert factor and gate the shadow's - // opacity: polygons facing AWAY from the light don't cast a - // shadow on the receiver (their projection is inside the - // silhouette of the front-facing parts anyway, just adding - // overdraw). Pure CSS — no JS at light-change time. const shadowEl = doc.createElement("q"); shadowEl.className = "polycss-shadow"; - shadowEl.style.transform = `var(--shadow-proj) ${origMatrix}`; + shadowEl.style.transform = isDynamic + ? `var(--shadow-proj) ${origMatrix}` + : `${bakedProjStr} ${origMatrix}`; shadowEl.style.color = shadowColorCss; shadowEl.style.width = `${plan.canvasW}px`; shadowEl.style.height = `${plan.canvasH}px`; shadowEl.style.setProperty("border-shape", cssBorderShapeForPlan(plan)); - shadowEl.style.setProperty("--pnx", plan.normal[0].toFixed(4)); - shadowEl.style.setProperty("--pny", plan.normal[1].toFixed(4)); - shadowEl.style.setProperty("--pnz", plan.normal[2].toFixed(4)); + // Dynamic mode pins the caster's normal so the per-element opacity + // calc can Lambert-gate back-facing polys. Baked mode already + // dropped those above, so no normal vars are needed. + if (isDynamic) { + shadowEl.style.setProperty("--pnx", plan.normal[0].toFixed(4)); + shadowEl.style.setProperty("--pny", plan.normal[1].toFixed(4)); + shadowEl.style.setProperty("--pnz", plan.normal[2].toFixed(4)); + } fragment.appendChild(shadowEl); entry.shadowRendered.push(shadowEl); @@ -1265,17 +1319,18 @@ export function createPolyScene( emitShadowLeaves(entry); } - // Recomputes --shadow-ground-cssz from the minimum world-Z across all + // Recomputes the shadow ground plane from the minimum world-Z across all // casting meshes. World Z stays as CSS Z under the world→CSS axis swap. // In polycss's world convention Z is up — the red-green plane in the axes // helper is the floor. An optional `lift` (in world units) raises the // plane slightly above the bbox floor to prevent z-fighting with // receiver polygons. + // + // Dynamic mode writes the result to `--shadow-ground-cssz` and lets the + // CSS `--shadow-proj` calc expression rebuild the matrix on the GPU side. + // Baked mode caches it in `currentGroundCssZ` and re-emits all casting + // entries' shadow leaves so the inline matrix3d transforms refresh. function recomputeShadowGround(): void { - if (currentOptions.textureLighting !== "dynamic") { - sceneEl.style.removeProperty("--shadow-ground-cssz"); - return; - } let minWorldZ = Infinity; for (const m of meshes) { if (!m.disposed && m.castShadow) { @@ -1288,6 +1343,14 @@ export function createPolyScene( } if (!Number.isFinite(minWorldZ)) { sceneEl.style.removeProperty("--shadow-ground-cssz"); + const hadGround = currentGroundCssZ !== null; + currentGroundCssZ = null; + // No casters left: drop any baked shadow leaves still mounted. + if (hadGround && currentOptions.textureLighting !== "dynamic") { + for (const entry of meshes) { + if (entry.shadowRendered.length) clearShadowLeaves(entry); + } + } return; } const lift = currentOptions.shadow?.lift ?? 0.05; @@ -1298,7 +1361,21 @@ export function createPolyScene( // Stored as a unitless number (not px) because matrix3d() calc() entries // must be dimensionless — see styles.ts @property --shadow-ground-cssz. const groundCssZ = (minWorldZ + lift) * DEFAULT_TILE; - sceneEl.style.setProperty("--shadow-ground-cssz", groundCssZ.toFixed(3)); + const prevGround = currentGroundCssZ; + currentGroundCssZ = groundCssZ; + if (currentOptions.textureLighting === "dynamic") { + sceneEl.style.setProperty("--shadow-ground-cssz", groundCssZ.toFixed(3)); + return; + } + // Baked mode: the ground value is folded into each leaf's inline + // matrix3d, so a change requires re-emission of every caster's shadows. + // Strip the dynamic-only CSS var in case lighting just toggled. + sceneEl.style.removeProperty("--shadow-ground-cssz"); + if (prevGround !== groundCssZ) { + for (const entry of meshes) { + if (entry.castShadow) emitShadowLeaves(entry); + } + } } async function renderEntryChunked( @@ -1816,6 +1893,8 @@ export function createPolyScene( const prevStrategies = currentOptions.strategies; const prevSeamBleed = currentOptions.seamBleed; const prevTextureLighting = currentOptions.textureLighting; + const prevLightDir = currentOptions.directionalLight?.direction; + const prevShadow = currentOptions.shadow; const normalizedPartial = normalizeSceneOptions(partial); currentOptions = { ...currentOptions, ...normalizedPartial }; applySceneStyle(sceneEl, currentOptions); @@ -1838,10 +1917,24 @@ export function createPolyScene( for (const entry of meshes) renderEntry(entry); } if (prevAutoCenter !== nextAutoCenter) recomputeAutoCenter(); - // When lighting mode changes, re-emit or clear shadow leaves on all meshes - // that have castShadow set. Shadow emission is only valid in dynamic mode. + // Shadow emission depends on lighting mode, light direction, and the + // shadow appearance options. Dynamic mode handles light + shadow-color + // changes purely through CSS vars updated above; the cases that need + // explicit re-emission are: + // - lighting mode toggled (different transform / DOM shape) + // - light direction changed in baked mode (matrix is CPU-baked) + // - shadow color/opacity/lift changed in baked mode (color is inline) const textureLightingChanged = partial.textureLighting !== undefined && prevTextureLighting !== currentOptions.textureLighting; + const nextLightDir = currentOptions.directionalLight?.direction; + const lightDirChanged = partial.directionalLight !== undefined + && !vec3Equal(prevLightDir, nextLightDir); + const nextShadow = currentOptions.shadow; + const shadowAppearanceChanged = partial.shadow !== undefined + && !shadowOptsEqual(prevShadow, nextShadow); + const isBaked = currentOptions.textureLighting !== "dynamic"; + const bakedShadowResetNeeded = isBaked + && (lightDirChanged || shadowAppearanceChanged); if (textureLightingChanged) { for (const entry of meshes) { if (!strategiesChanged && !seamBleedChanged && (entry.voxelSource || entry.voxelRenderer)) { @@ -1851,6 +1944,23 @@ export function createPolyScene( } } recomputeShadowGround(); + } else if (bakedShadowResetNeeded) { + // Light direction or shadow appearance changed in baked mode — + // re-emit all casters' shadow leaves with the new inline matrix / + // color. Cheap: DOM-only, no atlas re-rasterise. + for (const entry of meshes) { + if (entry.castShadow) emitShadowLeaves(entry); + } + } else if (shadowAppearanceChanged && !isBaked) { + // Dynamic mode: shadow color/opacity is per-leaf inline, so a + // change still needs re-emission. Lift affects --shadow-ground-cssz + // which recomputeShadowGround handles below. + for (const entry of meshes) { + if (entry.castShadow) emitShadowLeaves(entry); + } + } + if (shadowAppearanceChanged && partial.shadow?.lift !== prevShadow?.lift) { + recomputeShadowGround(); } } diff --git a/packages/polycss/src/styles/styles.ts b/packages/polycss/src/styles/styles.ts index c66b0f09..58f6dbec 100644 --- a/packages/polycss/src/styles/styles.ts +++ b/packages/polycss/src/styles/styles.ts @@ -417,11 +417,12 @@ const CORE_BASE_STYLES = ` jump quickly to 1, giving a near-binary visibility decision with a smooth edge transition. Pure CSS calc — no JS at light-change time. - The base layout / positioning / pseudo-element-strip rules for - live in the polygon-leaf section above. This rule only adds the - dynamic-light Lambert gating, separated so it's easy to disable the - gate for debugging by commenting out a single block. */ -.polycss-scene q { + Scoped to dynamic mode: baked-mode shadow leaves are dropped from the + DOM up-front by isBakedShadowCaster() and don't carry --pnx/--pny/--pnz, + so an unscoped gate would silently zero them via the @property + initial values. The base layout / positioning / pseudo-element-strip + rules for live in the polygon-leaf section above. */ +.polycss-scene[data-polycss-lighting="dynamic"] q { opacity: clamp(0, calc((var(--pnx) * var(--clx) + var(--pny) * var(--cly) + var(--pnz) * var(--clz)) * 10), 1); } `; diff --git a/packages/react/src/styles/styles.ts b/packages/react/src/styles/styles.ts index ad103493..6bb9acb2 100644 --- a/packages/react/src/styles/styles.ts +++ b/packages/react/src/styles/styles.ts @@ -366,8 +366,13 @@ const CORE_BASE_STYLES = ` would stack inside the silhouette and produce ugly overdraw). The * 10 multiplier sharpens the cutoff so small positive Lambert values jump quickly to 1, giving a near-binary visibility decision with a - smooth edge transition. Pure CSS calc — no JS at light-change time. */ -.polycss-scene q { + smooth edge transition. Pure CSS calc — no JS at light-change time. + + Scoped to dynamic mode: baked-mode shadow leaves are dropped up-front + by isBakedShadowCaster() and don't carry --pnx/--pny/--pnz, so an + unscoped gate would silently zero them via the @property initial + values. */ +.polycss-scene[data-polycss-lighting="dynamic"] q { opacity: clamp(0, calc((var(--pnx) * var(--clx) + var(--pny) * var(--cly) + var(--pnz) * var(--clz)) * 10), 1); } `; diff --git a/packages/vue/src/styles/styles.ts b/packages/vue/src/styles/styles.ts index 0a32a7ac..2ba48caa 100644 --- a/packages/vue/src/styles/styles.ts +++ b/packages/vue/src/styles/styles.ts @@ -321,8 +321,10 @@ const CORE_BASE_STYLES = ` /* shadow leaf — Lambert-gated opacity. Polygons facing the light cast full shadow; polygons facing away cast zero shadow. The * 10 multiplier sharpens the cutoff so small positive Lambert values jump quickly to 1, - giving a near-binary visibility decision with a smooth edge transition. */ -.polycss-scene q { + giving a near-binary visibility decision with a smooth edge transition. + Scoped to dynamic mode: baked-mode shadow leaves are dropped up-front + by isBakedShadowCaster() and don't carry --pnx/--pny/--pnz. */ +.polycss-scene[data-polycss-lighting="dynamic"] q { opacity: clamp(0, calc((var(--pnx) * var(--clx) + var(--pny) * var(--cly) + var(--pnz) * var(--clz)) * 10), 1); } `; From 36bc46f9c9e96a79a0bbba913ab67b5147ff1ef9 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 23 May 2026 02:18:05 +0200 Subject: [PATCH 03/28] feat(react): emit baked-mode shadow leaves with CPU-baked matrix3d --- .../src/scene/PolyMesh.castShadow.test.tsx | 23 ++++- packages/react/src/scene/PolyMesh.tsx | 97 +++++++++++++------ packages/react/src/scene/PolyScene.tsx | 72 +++++++++----- packages/react/src/scene/sceneContext.ts | 8 ++ 4 files changed, 142 insertions(+), 58 deletions(-) diff --git a/packages/react/src/scene/PolyMesh.castShadow.test.tsx b/packages/react/src/scene/PolyMesh.castShadow.test.tsx index 0f2cac1f..76da8d32 100644 --- a/packages/react/src/scene/PolyMesh.castShadow.test.tsx +++ b/packages/react/src/scene/PolyMesh.castShadow.test.tsx @@ -115,12 +115,21 @@ describe("PolyMesh — castShadow", () => { expect(container.querySelectorAll(".polycss-shadow").length).toBe(2); }); - it("castShadow in baked mode emits NO shadow leaves", () => { + it("castShadow in baked mode emits shadow leaves with a CPU-baked matrix3d transform", () => { + // Baked mode bakes the projection into each leaf's inline transform + // — no var(--shadow-proj), no --pnx/--pny/--pnz opacity gate. The + // default light has positive Z, so the +Z-facing triangle is a caster. const { container } = renderScene( { textureLighting: "baked" }, { polygons: [TRIANGLE], castShadow: true }, ); - expect(container.querySelectorAll(".polycss-shadow").length).toBe(0); + const shadow = container.querySelector(".polycss-shadow") as HTMLElement; + expect(shadow).not.toBeNull(); + expect(shadow.style.transform).toMatch(/^matrix3d\(.+\)\s+matrix3d\(/); + expect(shadow.style.transform).not.toContain("var(--shadow-proj)"); + expect(shadow.style.getPropertyValue("--pnx")).toBe(""); + expect(shadow.style.getPropertyValue("--pny")).toBe(""); + expect(shadow.style.getPropertyValue("--pnz")).toBe(""); }); it("shadow leaves are elements", () => { @@ -180,15 +189,19 @@ describe("PolyMesh — castShadow", () => { expect(container.querySelectorAll(".polycss-shadow").length).toBe(0); }); - it("switching scene from dynamic to baked removes shadow leaves", () => { + it("switching scene from dynamic to baked rebuilds shadow leaves with inline matrix3d", () => { const { container, root } = renderScene(DYN_SCENE_PROPS, { polygons: [TRIANGLE], castShadow: true, }); - expect(container.querySelectorAll(".polycss-shadow").length).toBeGreaterThan(0); + const dynamicShadow = container.querySelector(".polycss-shadow") as HTMLElement; + expect(dynamicShadow.style.transform).toContain("var(--shadow-proj)"); rerender(root, { textureLighting: "baked" }, { polygons: [TRIANGLE], castShadow: true }); - expect(container.querySelectorAll(".polycss-shadow").length).toBe(0); + const bakedShadow = container.querySelector(".polycss-shadow") as HTMLElement; + expect(bakedShadow).not.toBeNull(); + expect(bakedShadow.style.transform).not.toContain("var(--shadow-proj)"); + expect(bakedShadow.style.transform).toMatch(/^matrix3d\(.+\)\s+matrix3d\(/); }); it("textured polygons (s) ALSO emit shadow leaves (Frog Guy regression)", () => { diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx index 27c67ba3..90c09683 100644 --- a/packages/react/src/scene/PolyMesh.tsx +++ b/packages/react/src/scene/PolyMesh.tsx @@ -32,10 +32,13 @@ import type { Vec3, } from "@layoutit/polycss-core"; import { + buildBakedShadowProjectionMatrix, computeSceneBbox, DEFAULT_SEAM_BLEED, findOverlappingPolygonDuplicates, + formatMatrix3dValues, inverseRotateVec3, + isBakedShadowCaster, parseHexColor, } from "@layoutit/polycss-core"; import type { TransformProps } from "../shapes/types"; @@ -600,10 +603,12 @@ export const PolyMesh = forwardRef(function PolyM const sceneRegisterShadowCaster = sceneCtx?.registerShadowCaster; // Register/unregister as a shadow caster whenever castShadow or polygons change. - // Cleanup on unmount passes null to deregister. + // Both lighting modes need the registration so the scene can derive the + // shadow ground plane from caster bboxes. Cleanup on unmount passes null + // to deregister. useEffect(() => { if (!sceneRegisterShadowCaster) return; - if (castShadow && effectiveTextureLighting === "dynamic") { + if (castShadow) { sceneRegisterShadowCaster(meshIdRef.current, polygons); } else { sceneRegisterShadowCaster(meshIdRef.current, null); @@ -611,20 +616,38 @@ export const PolyMesh = forwardRef(function PolyM return () => { sceneRegisterShadowCaster(meshIdRef.current, null); }; - }, [sceneRegisterShadowCaster, castShadow, effectiveTextureLighting, polygons]); - - // Build shadow leaf elements. Only emitted when castShadow is true and the - // scene is in dynamic mode. Uses the same plans as the caster polygons so - // the outlines are identical. Deduplication removes stacked coplanar - // shadow leaves that would produce visible double-shadows on the receiver. + }, [sceneRegisterShadowCaster, castShadow, polygons]); + + // Build shadow leaf elements. Emitted in both lighting modes: + // - Dynamic: transform uses `var(--shadow-proj) matrix3d(...)`; the CSS + // opacity calc on the scene root gates back-facing polys at paint time. + // - Baked: transform is `matrix3d() matrix3d(...)` with the + // projection CPU-baked from the fixed light + ground; back-facing + // polys are dropped from the output entirely (no opacity gate needed). + // Uses the same plans as the caster polygons so the outlines are identical. + // Deduplication removes stacked coplanar shadow leaves that would produce + // visible double-shadows on the receiver. + const bakedShadowGroundCssZ = sceneCtx?.groundCssZ ?? null; const shadowLeaves = useMemo(() => { - if (!castShadow || effectiveTextureLighting !== "dynamic" || renderPolygon) return []; + if (!castShadow || renderPolygon) return []; + const isDynamic = effectiveTextureLighting === "dynamic"; + // Baked mode needs a ground plane to project onto. Until the scene's + // recomputeGroundCssZ runs, fall through and let the next render emit. + if (!isDynamic && bakedShadowGroundCssZ === null) return []; const shadowColor = sceneCtx?.shadow?.color ?? "#000000"; const shadowOpacity = sceneCtx?.shadow?.opacity ?? 0.25; const parsed = parseHexColor(shadowColor)?.rgb ?? [0, 0, 0]; const shadowColorCss = `rgba(${parsed[0]},${parsed[1]},${parsed[2]},${shadowOpacity})`; + const lightDir = sceneDirectionalLight?.direction + ?? ([0.4, -0.7, 0.59] as Vec3); + const bakedProjStr = isDynamic + ? null + : `matrix3d(${formatMatrix3dValues( + buildBakedShadowProjectionMatrix(lightDir, bakedShadowGroundCssZ ?? 0), + )})`; + const shadowDedupDrop = findOverlappingPolygonDuplicates(polygons, { normalTolerance: 0.1, distanceTolerance: 0.5, @@ -635,6 +658,8 @@ export const PolyMesh = forwardRef(function PolyM for (const plan of atlasPlans) { if (!plan) continue; if (shadowDedupDrop.has(plan.index)) continue; + // Baked mode: drop back-facing polys before reaching the DOM. + if (!isDynamic && !isBakedShadowCaster(plan.normal, lightDir)) continue; const borderShape = cssBorderShapeForPlan(plan); leaves.push( @@ -643,11 +668,12 @@ export const PolyMesh = forwardRef(function PolyM plan={plan} shadowColorCss={shadowColorCss} borderShape={borderShape} + bakedProjStr={bakedProjStr} /> ); } return leaves; - }, [castShadow, effectiveTextureLighting, renderPolygon, polygons, atlasPlans, sceneCtx?.shadow]); + }, [castShadow, effectiveTextureLighting, renderPolygon, polygons, atlasPlans, sceneCtx?.shadow, sceneDirectionalLight, bakedShadowGroundCssZ]); setPolygonsImplRef.current = (nextPolygons: Polygon[]) => { const nextRenderedPolygons = autoCenter ? recenterPolygons(nextPolygons) : nextPolygons; @@ -792,41 +818,58 @@ function RenderPropPolygon({ return <>{children(polygon, index)}; } -// Shadow leaf — a element that projects the caster polygon's outline onto -// the ground plane via `var(--shadow-proj)`. The transform chain is: -// `var(--shadow-proj) matrix3d(...)` where matrix3d is the original polygon -// placement. border-shape clips the element to the polygon's outline (same -// mechanism as ). The normal is pinned inline as --pnx/y/z so the CSS -// opacity gate in styles.ts can skip back-facing polygons without JS. -// Uses a ref callback for border-shape (non-standard CSS property, must be -// set via setProperty). +// Shadow leaf — a element that projects the caster polygon's outline +// onto the ground plane. border-shape clips the element to the polygon's +// outline (same mechanism as ). +// +// Dynamic mode: transform is `var(--shadow-proj) matrix3d(...)` so the +// projection follows live light vars on the scene root; --pnx/y/z is +// pinned so the CSS opacity gate in styles.ts skips back-facing polys. +// +// Baked mode: `bakedProjStr` carries a CPU-baked `matrix3d()` +// for the projection; back-facing polys are dropped by the caller and +// no normal vars are needed. +// +// Uses a ref callback for border-shape (non-standard CSS property, must +// be set via setProperty). function ShadowLeaf({ plan, shadowColorCss, borderShape, + bakedProjStr, }: { plan: TextureAtlasPlan; shadowColorCss: string; borderShape: string; + bakedProjStr: string | null; }) { const setRef = useCallback((el: HTMLElement | null) => { if (!el) return; el.style.setProperty("border-shape", borderShape); }, [borderShape]); + const baseStyle: CSSProperties = { + transform: bakedProjStr + ? `${bakedProjStr} matrix3d(${plan.matrix})` + : `var(--shadow-proj) matrix3d(${plan.matrix})`, + color: shadowColorCss, + width: plan.canvasW, + height: plan.canvasH, + }; + const style: CSSProperties = bakedProjStr + ? baseStyle + : { + ...baseStyle, + ["--pnx" as string]: plan.normal[0].toFixed(4), + ["--pny" as string]: plan.normal[1].toFixed(4), + ["--pnz" as string]: plan.normal[2].toFixed(4), + } as CSSProperties; + return ( ); } diff --git a/packages/react/src/scene/PolyScene.tsx b/packages/react/src/scene/PolyScene.tsx index 35de8042..5f90a939 100644 --- a/packages/react/src/scene/PolyScene.tsx +++ b/packages/react/src/scene/PolyScene.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react"; +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties, ReactNode } from "react"; import type { Polygon, @@ -295,22 +295,13 @@ function PolySceneInner({ // Shadow caster registry. PolyMesh children call registerShadowCaster when // their castShadow prop or polygon list changes. The scene accumulates the - // polygon lists and writes --shadow-ground-cssz to the scene element. + // polygon lists, derives the ground-plane CSS-Z, and mirrors it into either + // the `--shadow-ground-cssz` CSS var (dynamic mode) or the scene context + // (baked mode, where each mesh embeds the value in its inline matrix3d). const shadowCastersRef = useRef>(new Map()); + const [groundCssZ, setGroundCssZ] = useState(null); - const registerShadowCaster = useCallback((meshId: symbol, meshPolygons: Polygon[] | null) => { - if (meshPolygons === null) { - shadowCastersRef.current.delete(meshId); - } else { - shadowCastersRef.current.set(meshId, meshPolygons); - } - // Recompute --shadow-ground-cssz immediately. - const el = sceneElRef.current; - if (!el) return; - if (textureLighting !== "dynamic") { - el.style.removeProperty("--shadow-ground-cssz"); - return; - } + const recomputeGroundCssZ = useCallback(() => { let minWorldZ = Infinity; for (const polys of shadowCastersRef.current.values()) { for (const poly of polys) { @@ -319,24 +310,52 @@ function PolySceneInner({ } } } - if (!Number.isFinite(minWorldZ)) { + if (!Number.isFinite(minWorldZ)) return null; + const lift = shadow?.lift ?? 0.05; + return (minWorldZ + lift) * BASE_TILE; + }, [shadow]); + + const registerShadowCaster = useCallback((meshId: symbol, meshPolygons: Polygon[] | null) => { + if (meshPolygons === null) { + shadowCastersRef.current.delete(meshId); + } else { + shadowCastersRef.current.set(meshId, meshPolygons); + } + const next = recomputeGroundCssZ(); + setGroundCssZ((prev) => (prev === next ? prev : next)); + const el = sceneElRef.current; + if (!el) return; + if (textureLighting === "dynamic" && next !== null) { + el.style.setProperty("--shadow-ground-cssz", next.toFixed(3)); + } else { + // Baked mode (no CSS var needed — the value flows through context) + // or no casters left. el.style.removeProperty("--shadow-ground-cssz"); - return; } - const lift = shadow?.lift ?? 0.05; - const groundCssZ = (minWorldZ + lift) * BASE_TILE; - el.style.setProperty("--shadow-ground-cssz", groundCssZ.toFixed(3)); - }, [sceneElRef, textureLighting, shadow]); + }, [sceneElRef, textureLighting, recomputeGroundCssZ]); - // When lighting mode switches away from dynamic, clear --shadow-ground-cssz - // from the scene element (shadow projection is only active in dynamic mode). + // Re-sync the CSS var on lighting-mode swaps. Dynamic mode needs the var + // (the --shadow-proj calc reads it); baked mode strips it so a stale + // value can't accidentally drive --shadow-proj for legacy leaves. useEffect(() => { const el = sceneElRef.current; if (!el) return; - if (textureLighting !== "dynamic") { + if (textureLighting === "dynamic" && groundCssZ !== null) { + el.style.setProperty("--shadow-ground-cssz", groundCssZ.toFixed(3)); + } else { el.style.removeProperty("--shadow-ground-cssz"); } - }, [textureLighting, sceneElRef]); + }, [textureLighting, sceneElRef, groundCssZ]); + + // Lift change in baked mode: recompute groundCssZ so meshes re-derive + // their inline matrix3d. (Dynamic mode handles it via the CSS var path + // — recomputeGroundCssZ + the useEffect above keeps that consistent too.) + useEffect(() => { + setGroundCssZ((prev) => { + const next = recomputeGroundCssZ(); + return prev === next ? prev : next; + }); + }, [recomputeGroundCssZ]); const disabledStrategies = useMemo( () => strategies?.disable?.length ? new Set(strategies.disable) : undefined, @@ -392,8 +411,9 @@ function PolySceneInner({ seamBleed, shadow, registerShadowCaster, + groundCssZ, }), - [textureLighting, directionalLight, ambientLight, strategies, seamBleed, shadow, registerShadowCaster], + [textureLighting, directionalLight, ambientLight, strategies, seamBleed, shadow, registerShadowCaster, groundCssZ], ); return ( diff --git a/packages/react/src/scene/sceneContext.ts b/packages/react/src/scene/sceneContext.ts index b2460ce8..9775219a 100644 --- a/packages/react/src/scene/sceneContext.ts +++ b/packages/react/src/scene/sceneContext.ts @@ -32,6 +32,14 @@ export interface PolySceneContextValue { * `polygons` is null when unregistering or when castShadow is false. */ registerShadowCaster?: (meshId: symbol, polygons: Polygon[] | null) => void; + /** + * Computed CSS-Z of the shadow ground plane (= min world Z across all + * casting meshes + scene.shadow.lift, in CSS pixels). Dynamic mode also + * mirrors this into the `--shadow-ground-cssz` CSS var. Baked-mode + * mesh code reads it directly to bake the inline `matrix3d(...)` on + * each shadow leaf. `null` means there are no caster meshes yet. + */ + groundCssZ?: number | null; } export const PolySceneContext = createContext(null); From a61b868c12054eed835a6d837cc22dc418a15286 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 23 May 2026 02:20:43 +0200 Subject: [PATCH 04/28] feat(vue): emit baked-mode shadow leaves with CPU-baked matrix3d --- .../vue/src/scene/PolyMesh.castShadow.test.ts | 17 ++++- packages/vue/src/scene/PolyMesh.ts | 70 +++++++++++++------ packages/vue/src/scene/PolyScene.ts | 40 +++++++---- packages/vue/src/scene/sceneContext.ts | 8 +++ 4 files changed, 98 insertions(+), 37 deletions(-) diff --git a/packages/vue/src/scene/PolyMesh.castShadow.test.ts b/packages/vue/src/scene/PolyMesh.castShadow.test.ts index 7c07e282..7e90ff1a 100644 --- a/packages/vue/src/scene/PolyMesh.castShadow.test.ts +++ b/packages/vue/src/scene/PolyMesh.castShadow.test.ts @@ -94,12 +94,25 @@ describe("PolyMesh (Vue) — castShadow", () => { expect(container.querySelectorAll(".polycss-shadow").length).toBe(2); }); - it("castShadow:true in baked mode emits NO shadow leaves", () => { + it("castShadow:true in baked mode emits shadow leaves with a CPU-baked matrix3d transform", async () => { + // Baked mode bakes the projection into each leaf's inline transform — + // no var(--shadow-proj), no --pnx/--pny/--pnz opacity gate. The + // default light has positive Z so the +Z-facing triangle is a caster. + // nextTick lets the scene's watchEffect derive groundCssZ from the + // child's registration before the shadow nodes recompute. const { container } = mount( { textureLighting: "baked" }, { polygons: [TRIANGLE], castShadow: true }, ); - expect(container.querySelectorAll(".polycss-shadow").length).toBe(0); + await nextTick(); + await nextTick(); + const shadow = container.querySelector(".polycss-shadow") as HTMLElement; + expect(shadow).not.toBeNull(); + expect(shadow.style.transform).toMatch(/^matrix3d\(.+\)\s+matrix3d\(/); + expect(shadow.style.transform).not.toContain("var(--shadow-proj)"); + expect(shadow.style.getPropertyValue("--pnx")).toBe(""); + expect(shadow.style.getPropertyValue("--pny")).toBe(""); + expect(shadow.style.getPropertyValue("--pnz")).toBe(""); }); it("shadow leaves are always with class polycss-shadow", () => { diff --git a/packages/vue/src/scene/PolyMesh.ts b/packages/vue/src/scene/PolyMesh.ts index 397fa411..e9422c90 100644 --- a/packages/vue/src/scene/PolyMesh.ts +++ b/packages/vue/src/scene/PolyMesh.ts @@ -20,10 +20,13 @@ import { defineComponent, h, computed, inject, onMounted, onBeforeUnmount, ref, import type { PropType, VNode, CSSProperties } from "vue"; import type { MeshResolution, Polygon, PolyTextureLightingMode, Vec3 } from "@layoutit/polycss-core"; import { + buildBakedShadowProjectionMatrix, computeSceneBbox, DEFAULT_SEAM_BLEED, inverseRotateVec3, findOverlappingPolygonDuplicates, + formatMatrix3dValues, + isBakedShadowCaster, parseHexColor, } from "@layoutit/polycss-core"; import { usePolyMesh } from "./useMesh"; @@ -303,18 +306,36 @@ export const PolyMesh = defineComponent({ ); const defaultPaintVars = computed(() => solidPaintVars(solidPaintDefaults.value)); - // Shadow leaf emission. Only active when castShadow=true and the scene is - // in dynamic lighting mode. Computed from textureAtlasPlans so every - // polygon (including textured polygons) gets a shadow leaf based on - // its outline. + // Shadow leaf emission. Active when castShadow=true in either lighting + // mode. Dynamic mode emits leaves whose transform uses var(--shadow-proj) + // and whose --pnx/y/z drive a CSS opacity calc that hides back-facing + // polys at paint time. Baked mode CPU-bakes the projection matrix + // (using the scene's fixed light + ground) and drops back-facing polys + // from the DOM entirely instead of opacity-gating them. const shadowNodes = computed>(() => { - if (!props.castShadow || atlasTextureLighting.value !== "dynamic") return []; - const shadowOpts = sceneCtx?.value.shadow; + if (!props.castShadow) return []; + const lighting = atlasTextureLighting.value; + const isDynamic = lighting === "dynamic"; + const ctx = sceneCtx?.value; + const groundCssZ = ctx?.groundCssZ ?? null; + // Baked mode needs a ground plane to project onto. The scene's + // watchEffect will populate it on the next tick after registration. + if (!isDynamic && groundCssZ === null) return []; + + const shadowOpts = ctx?.shadow; const shadowColor = shadowOpts?.color ?? "#000000"; const shadowOpacity = shadowOpts?.opacity ?? 0.25; const parsed = parseHexColor(shadowColor)?.rgb ?? [0, 0, 0]; const shadowColorCss = `rgba(${parsed[0]},${parsed[1]},${parsed[2]},${shadowOpacity})`; + const lightDir = ctx?.directionalLight?.direction + ?? ([0.4, -0.7, 0.59] as Vec3); + const bakedProjStr = isDynamic + ? null + : `matrix3d(${formatMatrix3dValues( + buildBakedShadowProjectionMatrix(lightDir, groundCssZ ?? 0), + )})`; + const plans = textureAtlasPlans.value; if (plans.length === 0) return []; @@ -327,17 +348,25 @@ export const PolyMesh = defineComponent({ return plans.map((plan, index) => { if (!plan) return null; if (dedupDrop.has(index)) return null; + if (!isDynamic && !isBakedShadowCaster(plan.normal, lightDir)) return null; const origMatrix = `matrix3d(${plan.matrix})`; const borderShape = cssBorderShapeForPlan(plan); - const style: CSSProperties = { - transform: `var(--shadow-proj) ${origMatrix}`, - color: shadowColorCss, - width: `${plan.canvasW}px`, - height: `${plan.canvasH}px`, - "--pnx": plan.normal[0].toFixed(4), - "--pny": plan.normal[1].toFixed(4), - "--pnz": plan.normal[2].toFixed(4), - }; + const style: CSSProperties = isDynamic + ? { + transform: `var(--shadow-proj) ${origMatrix}`, + color: shadowColorCss, + width: `${plan.canvasW}px`, + height: `${plan.canvasH}px`, + "--pnx": plan.normal[0].toFixed(4), + "--pny": plan.normal[1].toFixed(4), + "--pnz": plan.normal[2].toFixed(4), + } + : { + transform: `${bakedProjStr} ${origMatrix}`, + color: shadowColorCss, + width: `${plan.canvasW}px`, + height: `${plan.canvasH}px`, + }; const applyShadowBorderShape = (vnode: VNode) => { const el = vnode.el as HTMLElement | null; @@ -354,15 +383,16 @@ export const PolyMesh = defineComponent({ }); }); - // Register this mesh with the shadow registry when castShadow=true so - // PolyScene can compute --shadow-ground-cssz reactively. + // Register this mesh with the shadow registry when castShadow=true in + // either lighting mode — the scene needs caster polygons to derive + // the ground plane regardless of how shadows are projected. const shadowRegistryId = Symbol(); watch( - () => [props.castShadow, atlasTextureLighting.value] as const, - ([castShadow, lighting], _, onCleanup) => { + () => props.castShadow, + (castShadow, _, onCleanup) => { const registry = sceneCtx?.value.shadowRegistry; if (!registry) return; - if (castShadow && lighting === "dynamic") { + if (castShadow) { registry.register(shadowRegistryId, () => polygons.value); } else { registry.unregister(shadowRegistryId); diff --git a/packages/vue/src/scene/PolyScene.ts b/packages/vue/src/scene/PolyScene.ts index a9fef9fe..dda4eb07 100644 --- a/packages/vue/src/scene/PolyScene.ts +++ b/packages/vue/src/scene/PolyScene.ts @@ -154,6 +154,11 @@ export const PolyScene = defineComponent({ }, }; + // Reactive ground-plane CSS-Z. Dynamic mode also mirrors this into + // the `--shadow-ground-cssz` CSS var (the watchEffect below); baked + // mode reads it via context to bake each leaf's inline matrix3d. + const groundCssZ = ref(null); + // Propagate scene-level rendering options to descendants (PolyMesh / // helpers) so they pick up the same dynamic mode + lights as the // scene. Without this, a helper PolyMesh would default to baked @@ -167,6 +172,7 @@ export const PolyScene = defineComponent({ seamBleed: props.seamBleed ?? DEFAULT_SEAM_BLEED, shadow: props.shadow, shadowRegistry, + groundCssZ: groundCssZ.value, })); provide(PolySceneContextKey, sceneCtxValue); @@ -319,24 +325,20 @@ export const PolyScene = defineComponent({ const DEFAULT_TILE = 50; - // --shadow-ground-cssz: written directly to the scene element when casting - // meshes register/unregister. A watchEffect is used instead of a computed - // read in the render function because child PolyMesh components register - // after the parent's first render (child setup runs during mount, not during - // the parent's VNode creation). The watchEffect re-runs after child - // registration because it reads shadowRegistryVersion, which the registry - // mutates when a mesh registers or unregisters. + // Shadow ground plane: derived from the min world-Z of all casting + // meshes + scene.shadow.lift. Drives the `--shadow-ground-cssz` CSS + // var in dynamic mode and the `groundCssZ` scene-context value + // (used by baked-mode meshes to bake their inline matrix3d). A + // watchEffect is used because child PolyMesh components register + // after the parent's first render — watchEffect re-runs after + // registration because it reads shadowRegistryVersion. watchEffect(() => { const el = sceneElLocalRef.value; - if (!el) return; - if (props.textureLighting !== "dynamic") { - el.style.removeProperty("--shadow-ground-cssz"); - return; - } void shadowRegistryVersion.value; const entries = shadowRegistry.getEntries(); if (entries.length === 0) { - el.style.removeProperty("--shadow-ground-cssz"); + if (el) el.style.removeProperty("--shadow-ground-cssz"); + if (groundCssZ.value !== null) groundCssZ.value = null; return; } let minWorldZ = Infinity; @@ -348,11 +350,19 @@ export const PolyScene = defineComponent({ } } if (!Number.isFinite(minWorldZ)) { - el.style.removeProperty("--shadow-ground-cssz"); + if (el) el.style.removeProperty("--shadow-ground-cssz"); + if (groundCssZ.value !== null) groundCssZ.value = null; return; } const lift = props.shadow?.lift ?? 0.05; - el.style.setProperty("--shadow-ground-cssz", ((minWorldZ + lift) * DEFAULT_TILE).toFixed(3)); + const next = (minWorldZ + lift) * DEFAULT_TILE; + if (groundCssZ.value !== next) groundCssZ.value = next; + if (!el) return; + if (props.textureLighting === "dynamic") { + el.style.setProperty("--shadow-ground-cssz", next.toFixed(3)); + } else { + el.style.removeProperty("--shadow-ground-cssz"); + } }); // Bbox-center of all centerable meshes in world coords. Folded into the diff --git a/packages/vue/src/scene/sceneContext.ts b/packages/vue/src/scene/sceneContext.ts index 4555c24c..3f829116 100644 --- a/packages/vue/src/scene/sceneContext.ts +++ b/packages/vue/src/scene/sceneContext.ts @@ -39,6 +39,14 @@ export interface PolySceneContextValue { seamBleed?: PolySeamBleed; shadow?: PolyShadowOptions; shadowRegistry?: PolyShadowRegistry; + /** + * Computed CSS-Z of the shadow ground plane (= min world Z across all + * casting meshes + scene.shadow.lift, in CSS pixels). Dynamic mode also + * mirrors this into `--shadow-ground-cssz`. Baked mode reads it here to + * bake the inline `matrix3d(...)` on each shadow leaf. `null` when no + * casting meshes are registered. + */ + groundCssZ?: number | null; } /** From 7478695c4ba719f7f8845f1d81c96123611e4275 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 23 May 2026 02:21:18 +0200 Subject: [PATCH 05/28] test(polycss): cover baked-shadow light-change re-emit + ground-var stripping --- .../polycss/src/api/createPolyScene.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/polycss/src/api/createPolyScene.test.ts b/packages/polycss/src/api/createPolyScene.test.ts index 32a5c5a1..3b8f8f7f 100644 --- a/packages/polycss/src/api/createPolyScene.test.ts +++ b/packages/polycss/src/api/createPolyScene.test.ts @@ -2036,6 +2036,32 @@ describe("createPolyScene", () => { expect(sceneEl.style.getPropertyValue("--cly")).toBe(""); expect(sceneEl.style.getPropertyValue("--clz")).toBe(""); }); + + it("baked mode re-emits shadow leaves when directionalLight.direction changes", () => { + // Light direction is folded into the CPU-baked matrix, so changing + // it must rewrite the inline transform — otherwise the shadows + // would stay frozen at the original light angle. + scene = makeScene(host, { + textureLighting: "baked", + directionalLight: { direction: [0, 0, 1] }, + }); + scene.add(makeParseResult([triangle()]), { castShadow: true }); + const initial = (host.querySelector(".polycss-shadow") as HTMLElement).style.transform; + scene.setOptions({ directionalLight: { direction: [1, 0, 1] } }); + const next = (host.querySelector(".polycss-shadow") as HTMLElement).style.transform; + expect(next).not.toBe(initial); + expect(next).toMatch(/^matrix3d\(.+\)\s+matrix3d\(/); + }); + + it("baked mode does NOT set --shadow-ground-cssz on the scene element", () => { + // Ground Z lives inside each leaf's baked matrix3d, not on the + // scene root — the CSS var is dynamic-mode-only and would + // accidentally drive --shadow-proj for any stale dynamic leaves. + scene = makeScene(host, { textureLighting: "baked" }); + scene.add(makeParseResult([triangle()]), { castShadow: true }); + const sceneEl = getSceneEl(host); + expect(sceneEl.style.getPropertyValue("--shadow-ground-cssz")).toBe(""); + }); }); }); From 1dc2bf3cf1c9b91a269f3216cad7c886eab98244 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 23 May 2026 02:23:16 +0200 Subject: [PATCH 06/28] docs: update lighting + shadow descriptions for baked-mode shadows --- AGENTS.md | 6 +++--- packages/polycss/src/api/createPolyScene.ts | 8 +++++--- packages/polycss/src/styles/styles.ts | 2 +- packages/react/src/scene/PolyScene.tsx | 8 +++++--- packages/react/src/styles/styles.ts | 2 +- packages/vue/src/scene/PolyScene.ts | 8 +++++--- packages/vue/src/styles/styles.ts | 2 +- 7 files changed, 21 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 47828261..7c7ada8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ Voxel-shaped meshes are the exception to "all polygons stay mounted": meshes wit | `` | **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 | | `` | **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 | | `` | **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 `` for border triangles because transformed CSS border triangles composite incorrectly there. | None | -| `` | **Cast shadow leaf** | Per casting polygon when `castShadow: true` and dynamic lighting mode. Applies regardless of caster strategy — ``/``/``/`` all produce a `` shadow because only the polygon's outline matters, not its surface. | Same `border-color: currentColor` + `border-shape: polygon(...)` as ``, but transform composes `var(--shadow-proj)` to project the polygon onto the ground plane along the CSS-space light direction | None | +| `` | **Cast shadow leaf** | Per casting polygon when `castShadow: true`, in either lighting mode. Applies regardless of caster strategy — ``/``/``/`` all produce a `` shadow because only the polygon's outline matters, not its surface. | Same `border-color: currentColor` + `border-shape: polygon(...)` as ``. 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 `` / `` / `` and minimise `` (see "Meshing implications" below). @@ -45,8 +45,8 @@ The `.vox` fast path emits plain `` elements directly inside the mesh wrapper ### Lighting modes (`PolyTextureLightingMode = "baked" | "dynamic"`) -- **Baked.** Lambert is computed once on the CPU per polygon, multiplied into the inline `color` (for ``/``/``) or into the rasterised atlas pixels (for ``). 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 ``/``/``) or into the rasterised atlas pixels (for ``). Moving a light requires explicit re-rasterising of affected polys via `mesh.rebakeAtlas()`; cast-shadow `` 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`. diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index 6ea1af2f..5f9331e7 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -119,9 +119,11 @@ export interface PolySceneOptions { */ autoCenter?: boolean; /** - * Shadow appearance for meshes with `castShadow: true`. Only applies in - * dynamic lighting mode — baked mode does not emit shadow leaves. - * Defaults: `{ color: "#000000", opacity: 0.25, lift: 0.05 }`. + * Shadow appearance for meshes with `castShadow: true`. Works in both + * lighting modes — dynamic mode projects via CSS vars so shadows + * follow a moving light, baked mode CPU-bakes the projection into + * each leaf's inline `matrix3d` and drops back-facing polys from the + * DOM entirely. Defaults: `{ color: "#000000", opacity: 0.25, lift: 0.05 }`. */ shadow?: { /** Shadow color as a CSS hex string. Default: `"#000000"`. */ diff --git a/packages/polycss/src/styles/styles.ts b/packages/polycss/src/styles/styles.ts index 58f6dbec..6b6223e2 100644 --- a/packages/polycss/src/styles/styles.ts +++ b/packages/polycss/src/styles/styles.ts @@ -365,7 +365,7 @@ const CORE_BASE_STYLES = ` ); } -/* ── Cast shadows (dynamic mode only) ──────────────────────────────────── */ +/* ── Cast shadow projection (dynamic-mode CSS path) ────────────────────── */ /* * Shadow projection matrix. Projects any 3D point P onto the horizontal diff --git a/packages/react/src/scene/PolyScene.tsx b/packages/react/src/scene/PolyScene.tsx index 5f90a939..f09f1c91 100644 --- a/packages/react/src/scene/PolyScene.tsx +++ b/packages/react/src/scene/PolyScene.tsx @@ -79,9 +79,11 @@ export interface PolySceneProps extends TransformProps { */ autoCenter?: boolean; /** - * Shadow appearance for meshes with `castShadow={true}`. Only applies in - * dynamic lighting mode — baked mode does not emit shadow leaves. - * Defaults: `{ color: "#000000", opacity: 0.25, lift: 0.05 }`. + * Shadow appearance for meshes with `castShadow={true}`. Works in both + * lighting modes — dynamic mode projects via CSS vars so shadows + * follow a moving light, baked mode CPU-bakes the projection into + * each leaf's inline `matrix3d` and drops back-facing polys from the + * DOM entirely. Defaults: `{ color: "#000000", opacity: 0.25, lift: 0.05 }`. */ shadow?: ShadowOptions; className?: string; diff --git a/packages/react/src/styles/styles.ts b/packages/react/src/styles/styles.ts index 6bb9acb2..cbd73bc7 100644 --- a/packages/react/src/styles/styles.ts +++ b/packages/react/src/styles/styles.ts @@ -316,7 +316,7 @@ const CORE_BASE_STYLES = ` content: none; } -/* ── Cast shadows (dynamic mode only) ──────────────────────────────────── */ +/* ── Cast shadow projection (dynamic-mode CSS path) ────────────────────── */ /* * Shadow projection matrix. Projects any 3D point P onto the horizontal diff --git a/packages/vue/src/scene/PolyScene.ts b/packages/vue/src/scene/PolyScene.ts index dda4eb07..6b64bdd1 100644 --- a/packages/vue/src/scene/PolyScene.ts +++ b/packages/vue/src/scene/PolyScene.ts @@ -74,9 +74,11 @@ export interface PolySceneProps { */ autoCenter?: boolean; /** - * Shadow appearance for meshes with `castShadow: true`. Only applies in - * dynamic lighting mode — baked mode does not emit shadow leaves. - * Defaults: `{ color: "#000000", opacity: 0.25, lift: 0.05 }`. + * Shadow appearance for meshes with `castShadow: true`. Works in both + * lighting modes — dynamic mode projects via CSS vars so shadows + * follow a moving light, baked mode CPU-bakes the projection into + * each leaf's inline `matrix3d` and drops back-facing polys from the + * DOM entirely. Defaults: `{ color: "#000000", opacity: 0.25, lift: 0.05 }`. */ shadow?: PolyShadowOptions; class?: string; diff --git a/packages/vue/src/styles/styles.ts b/packages/vue/src/styles/styles.ts index 2ba48caa..f4d884e3 100644 --- a/packages/vue/src/styles/styles.ts +++ b/packages/vue/src/styles/styles.ts @@ -237,7 +237,7 @@ const CORE_BASE_STYLES = ` ); } -/* ── Cast shadows (dynamic mode only) ──────────────────────────────────── */ +/* ── Cast shadow projection (dynamic-mode CSS path) ────────────────────── */ /* — dedicated shadow leaf. Same border-shape rendering trick as (border-color: currentColor fills the polygon outline) but with its From 74e9b7bb52a0ecf5c8c3c62cb494a881e349bf56 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 23 May 2026 02:58:42 +0200 Subject: [PATCH 07/28] chore(bench): add baked-shadow + gallery shadow diagnostic scripts --- bench/baked-shadow-diagnose.mjs | 139 +++++++++++++++++++++++++++ bench/baked-shadow.html | 120 +++++++++++++++++++++++ bench/gallery-shadow-compare.mjs | 138 +++++++++++++++++++++++++++ bench/gallery-shadow-diagnose.mjs | 152 ++++++++++++++++++++++++++++++ 4 files changed, 549 insertions(+) create mode 100644 bench/baked-shadow-diagnose.mjs create mode 100644 bench/baked-shadow.html create mode 100644 bench/gallery-shadow-compare.mjs create mode 100644 bench/gallery-shadow-diagnose.mjs diff --git a/bench/baked-shadow-diagnose.mjs b/bench/baked-shadow-diagnose.mjs new file mode 100644 index 00000000..6a5bc9a3 --- /dev/null +++ b/bench/baked-shadow-diagnose.mjs @@ -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)}`); diff --git a/bench/baked-shadow.html b/bench/baked-shadow.html new file mode 100644 index 00000000..66d67eb5 --- /dev/null +++ b/bench/baked-shadow.html @@ -0,0 +1,120 @@ + + + + + polycss baked-shadow diagnose + + + +
+

+
+  
+
+
diff --git a/bench/gallery-shadow-compare.mjs b/bench/gallery-shadow-compare.mjs
new file mode 100644
index 00000000..1a5e484d
--- /dev/null
+++ b/bench/gallery-shadow-compare.mjs
@@ -0,0 +1,138 @@
+#!/usr/bin/env node
+/**
+ * Same as gallery-shadow-diagnose, but takes two captures: baked+shadow
+ * and dynamic+shadow, so we can tell whether the shadow weirdness is a
+ * regression from the new baked path or pre-existing in dynamic too.
+ */
+import { chromium } from "playwright";
+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", "4321"));
+const HEADED = hasFlag("headed");
+
+const outDir = resolve(repoRoot, "bench/results/gallery-shadow");
+await mkdir(outDir, { recursive: true });
+
+const browser = await chromium.launch({
+  headless: !HEADED,
+  args: chromiumArgsWithGpuDefault([], { softwareBackend: false }),
+});
+
+async function snapshot(page) {
+  return await page.evaluate(() => {
+    const scene = document.querySelector(".polycss-scene");
+    const shadows = document.querySelectorAll(".polycss-shadow");
+    return {
+      shadowCount: shadows.length,
+      sceneLighting: scene?.dataset.polycssLighting || "(unset)",
+      groundCssZ: scene?.style.getPropertyValue("--shadow-ground-cssz") || "(unset)",
+      shadowSample: Array.from(shadows).slice(0, 3).map((el) => ({
+        transform: el.style.transform.slice(0, 220),
+        width: el.style.width,
+        height: el.style.height,
+      })),
+    };
+  });
+}
+
+async function toggle(page, label, desiredChecked) {
+  return await page.evaluate(({ label, desiredChecked }) => {
+    const all = Array.from(document.querySelectorAll("div, span, label"));
+    const labelEl = all.find((el) => (el.textContent || "").trim() === label);
+    if (!labelEl) return { found: false };
+    let parent = labelEl.parentElement;
+    for (let i = 0; i < 8 && parent; i++) {
+      const cb = parent.querySelector('input[type="checkbox"]');
+      if (cb) {
+        if (cb.checked !== desiredChecked) cb.click();
+        return { found: true, after: cb.checked };
+      }
+      // tweakpane sometimes uses select/dropdown — return the select element handle name
+      const sel = parent.querySelector("select");
+      if (sel) return { found: true, isSelect: true };
+      parent = parent.parentElement;
+    }
+    return { found: true, hasCheckbox: false };
+  }, { label, desiredChecked });
+}
+
+async function selectDropdown(page, label, valueText) {
+  return await page.evaluate(({ label, valueText }) => {
+    const all = Array.from(document.querySelectorAll("div, span, label"));
+    const labelEl = all.find((el) => (el.textContent || "").trim() === label);
+    if (!labelEl) return { found: false };
+    let parent = labelEl.parentElement;
+    for (let i = 0; i < 8 && parent; i++) {
+      const sel = parent.querySelector("select");
+      if (sel) {
+        const opt = Array.from(sel.options).find((o) => o.text === valueText || o.value === valueText);
+        if (!opt) return { found: true, hasSelect: true, options: Array.from(sel.options).map((o) => o.text) };
+        sel.value = opt.value;
+        sel.dispatchEvent(new Event("change", { bubbles: true }));
+        return { found: true, set: opt.value };
+      }
+      parent = parent.parentElement;
+    }
+    return { found: true, hasSelect: false };
+  }, { label, valueText });
+}
+
+const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } });
+const errors = [];
+try {
+  const page = await ctx.newPage();
+  page.on("console", (msg) => {
+    if (msg.type() === "error") errors.push(`[console.error] ${msg.text()}`);
+  });
+  page.on("pageerror", (err) => errors.push(`[pageerror] ${err.message}`));
+
+  await page.goto(`http://localhost:${PORT}/gallery`, { waitUntil: "networkidle", timeout: 30000 });
+  await page.waitForFunction(
+    () => !!document.querySelector(".polycss-mesh"),
+    { timeout: 30000 },
+  );
+  await page.waitForTimeout(800);
+
+  // 1. Enable castShadow in baked mode (default).
+  const tog1 = await toggle(page, "Cast shadow", true);
+  await page.waitForTimeout(500);
+  const baked = await snapshot(page);
+  await page.screenshot({ path: resolve(outDir, "compare-baked.png"), fullPage: false });
+
+  // 2. Switch lighting to dynamic, keep castShadow on.
+  const sel = await selectDropdown(page, "Texture mode", "dynamic");
+  await page.waitForTimeout(500);
+  const dynamic = await snapshot(page);
+  await page.screenshot({ path: resolve(outDir, "compare-dynamic.png"), fullPage: false });
+
+  const report = { castToggle: tog1, modeSelect: sel, baked, dynamic, errors };
+  await writeFile(resolve(outDir, "compare.json"), JSON.stringify(report, null, 2));
+
+  console.log("\n──── baked vs dynamic with castShadow=true ────\n");
+  console.log("Toggle:", tog1, "Mode select:", sel);
+  console.log("\nBAKED:");
+  console.log(JSON.stringify(baked, null, 2));
+  console.log("\nDYNAMIC:");
+  console.log(JSON.stringify(dynamic, null, 2));
+  if (errors.length) {
+    console.log("\nErrors:");
+    errors.forEach((e) => console.log(`  ${e}`));
+  }
+} finally {
+  await browser.close();
+}
diff --git a/bench/gallery-shadow-diagnose.mjs b/bench/gallery-shadow-diagnose.mjs
new file mode 100644
index 00000000..e6c3268d
--- /dev/null
+++ b/bench/gallery-shadow-diagnose.mjs
@@ -0,0 +1,152 @@
+#!/usr/bin/env node
+/**
+ * Targets the website's gallery (http://localhost:4321/gallery) and
+ * captures before/after state when the user toggles castShadow with
+ * the scene in baked mode — the exact failure scenario the user is
+ * reporting ("UI disappears, shadows generally break").
+ *
+ * Usage:
+ *   node bench/gallery-shadow-diagnose.mjs
+ *   node bench/gallery-shadow-diagnose.mjs --headed
+ *   node bench/gallery-shadow-diagnose.mjs --port=4321
+ */
+import { chromium } from "playwright";
+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", "4321"));
+const HEADED = hasFlag("headed");
+
+const outDir = resolve(repoRoot, "bench/results/gallery-shadow");
+await mkdir(outDir, { recursive: true });
+
+const browser = await chromium.launch({
+  headless: !HEADED,
+  args: chromiumArgsWithGpuDefault([], { softwareBackend: false }),
+});
+
+async function snapshot(page) {
+  return await page.evaluate(() => {
+    const scene = document.querySelector(".polycss-scene");
+    const meshes = document.querySelectorAll(".polycss-mesh");
+    const shadows = document.querySelectorAll(".polycss-shadow");
+    const leafSel = ".polycss-scene b, .polycss-scene i, .polycss-scene s, .polycss-scene u";
+    const leaves = document.querySelectorAll(leafSel);
+    return {
+      meshCount: meshes.length,
+      leafCount: leaves.length,
+      shadowCount: shadows.length,
+      sceneStyle: scene
+        ? {
+            transform: scene.style.transform.slice(0, 100),
+            groundCssZ: scene.style.getPropertyValue("--shadow-ground-cssz") || "(unset)",
+            clx: scene.style.getPropertyValue("--clx") || "(unset)",
+            lighting: scene.dataset.polycssLighting || "(unset)",
+          }
+        : null,
+      shadowSample: Array.from(shadows).slice(0, 2).map((el) => ({
+        transform: el.style.transform.slice(0, 200),
+        width: el.style.width,
+        height: el.style.height,
+      })),
+    };
+  });
+}
+
+const errors = [];
+
+try {
+  const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } });
+  const page = await ctx.newPage();
+  page.on("console", (msg) => {
+    if (msg.type() === "error") errors.push(`[console.error] ${msg.text()}`);
+    if (msg.type() === "warning") errors.push(`[console.warn] ${msg.text()}`);
+  });
+  page.on("pageerror", (err) => errors.push(`[pageerror] ${err.message}`));
+
+  await page.goto(`http://localhost:${PORT}/gallery`, { waitUntil: "networkidle", timeout: 30000 });
+  // Wait for the gallery scene to fully mount and a mesh to render.
+  await page.waitForFunction(
+    () => {
+      const mesh = document.querySelector(".polycss-mesh");
+      const sceneChildren = document.querySelectorAll(".polycss-scene > *");
+      return !!mesh && sceneChildren.length > 0;
+    },
+    { timeout: 30000 },
+  );
+  await page.waitForTimeout(800);
+
+  const before = await snapshot(page);
+  await page.screenshot({ path: resolve(outDir, "01-baseline.png"), fullPage: false });
+
+  // Try to find and click the "Cast shadow" toggle inside the tweakpane dock.
+  // tweakpane renders checkboxes as . We look for the
+  // label text "Cast shadow" and click its associated control.
+  const beforeClickErrors = errors.length;
+  const clicked = await page.evaluate(() => {
+    // Tweakpane wraps each control with a label div. Walk every element
+    // whose text reads "Cast shadow" and find a sibling/descendant input.
+    const all = Array.from(document.querySelectorAll("div, span, label"));
+    const labelEl = all.find((el) => (el.textContent || "").trim() === "Cast shadow");
+    if (!labelEl) return { found: false };
+    // Walk up looking for the row that contains the checkbox.
+    let parent = labelEl.parentElement;
+    for (let i = 0; i < 8 && parent; i++) {
+      const cb = parent.querySelector('input[type="checkbox"]');
+      if (cb) {
+        const beforeChecked = cb.checked;
+        cb.click();
+        return { found: true, hasCheckbox: true, before: beforeChecked, after: cb.checked };
+      }
+      parent = parent.parentElement;
+    }
+    return { found: true, hasCheckbox: false };
+  });
+
+  await page.waitForTimeout(600);
+  const after = await snapshot(page);
+  await page.screenshot({ path: resolve(outDir, "02-cast-shadow.png"), fullPage: false });
+
+  const clickErrors = errors.slice(beforeClickErrors);
+
+  const report = {
+    clickedToggle: clicked,
+    before,
+    after,
+    errorsAfterToggle: clickErrors,
+    allErrors: errors,
+  };
+  await writeFile(resolve(outDir, "report.json"), JSON.stringify(report, null, 2));
+
+  console.log("\n──── gallery-shadow diagnose ────\n");
+  console.log("Toggle click result:", clicked);
+  console.log("\nBefore toggle:");
+  console.log(JSON.stringify(before, null, 2));
+  console.log("\nAfter toggle:");
+  console.log(JSON.stringify(after, null, 2));
+  if (clickErrors.length) {
+    console.log("\n⚠️  Errors after toggle:");
+    clickErrors.forEach((e) => console.log(`  ${e}`));
+  }
+  if (errors.length && !clickErrors.length) {
+    console.log("\n(Page errors before toggle, possibly unrelated):");
+    errors.forEach((e) => console.log(`  ${e}`));
+  }
+  console.log(`\nScreenshots: ${outDir.slice(repoRoot.length + 1)}/`);
+} finally {
+  await browser.close();
+}

From 179207ff8ce47727e5c69005432742503adfd0e9 Mon Sep 17 00:00:00 2001
From: Juan Cruz Fortunatti 
Date: Sat, 23 May 2026 04:42:04 +0200
Subject: [PATCH 08/28] feat(polycss): render baked-mode shadows as per-mesh
 SVG

---
 packages/core/src/index.ts                    |   1 +
 packages/core/src/shadow/projection.test.ts   |  34 +++
 packages/core/src/shadow/projection.ts        |  31 +++
 .../polycss/src/api/createPolyScene.test.ts   |  61 +++---
 packages/polycss/src/api/createPolyScene.ts   | 196 ++++++++++++++----
 5 files changed, 253 insertions(+), 70 deletions(-)

diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 6761b82a..6036beda 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -147,6 +147,7 @@ export {
   BAKED_SHADOW_Z_SQUASH,
   buildBakedShadowProjectionMatrix,
   isBakedShadowCaster,
+  projectCssVertexToGround,
 } from "./shadow/projection";
 
 // ── Animation ─────────────────────────────────────────────────────
diff --git a/packages/core/src/shadow/projection.test.ts b/packages/core/src/shadow/projection.test.ts
index beb18be7..b9ea887a 100644
--- a/packages/core/src/shadow/projection.test.ts
+++ b/packages/core/src/shadow/projection.test.ts
@@ -4,6 +4,7 @@ import {
   BAKED_SHADOW_Z_SQUASH,
   buildBakedShadowProjectionMatrix,
   isBakedShadowCaster,
+  projectCssVertexToGround,
 } from "./projection";
 
 describe("buildBakedShadowProjectionMatrix", () => {
@@ -84,3 +85,36 @@ describe("isBakedShadowCaster", () => {
     expect(isBakedShadowCaster([0, 0, -1], [0, 0, -10])).toBe(true);
   });
 });
+
+describe("projectCssVertexToGround", () => {
+  it("returns the vertex's own XY when it sits on the ground plane", () => {
+    const [x, y] = projectCssVertexToGround([10, 20, 50], [0, 0, 1], 50);
+    expect(x).toBeCloseTo(10, 6);
+    expect(y).toBeCloseTo(20, 6);
+  });
+
+  it("returns the vertex's own XY for a straight top-down light regardless of height", () => {
+    // Light TO-source = [0, 0, 1] (sun directly overhead) → no shear; the
+    // shadow of any point lands directly below it on the ground.
+    const [x, y] = projectCssVertexToGround([10, 20, 150], [0, 0, 1], 0);
+    expect(x).toBeCloseTo(10, 6);
+    expect(y).toBeCloseTo(20, 6);
+  });
+
+  it("shears the XY proportionally to height above ground for oblique lights", () => {
+    // Light TO-source = [1, 0, 1] (up-right). For a point at height H above
+    // the ground, the shadow lands at x - H (because lx/lz = 1) and y unchanged.
+    const [x, y] = projectCssVertexToGround([100, 50, 25], [1, 0, 1], 0);
+    expect(x).toBeCloseTo(75, 6); // 100 - 25*(1/1)
+    expect(y).toBeCloseTo(50, 6);
+  });
+
+  it("clamps near-horizontal light's up-axis to BAKED_SHADOW_MIN_UP", () => {
+    // Very near-horizontal light: lz ≈ 0.001 → clamped to 0.01. For a point
+    // 25 above ground, x-shear is 25 * (1 / 0.01) = 2500. With clamp this is
+    // bounded; without it the projection would shoot to infinity.
+    const [x] = projectCssVertexToGround([100, 0, 25], [1, 0, 0.001], 0);
+    expect(Math.abs(x - 100)).toBeLessThanOrEqual(25 / BAKED_SHADOW_MIN_UP + 1);
+    expect(Math.abs(x - 100)).toBeGreaterThan(100);
+  });
+});
diff --git a/packages/core/src/shadow/projection.ts b/packages/core/src/shadow/projection.ts
index fd7a5aff..ed0fae56 100644
--- a/packages/core/src/shadow/projection.ts
+++ b/packages/core/src/shadow/projection.ts
@@ -81,3 +81,34 @@ export function isBakedShadowCaster(
   const lz = lightDir[2] / len;
   return normal[0] * lx + normal[1] * ly + normal[2] * lz > 0;
 }
+
+/**
+ * Projects a single CSS-3D vertex onto the shadow ground plane, returning
+ * the resulting 2D point in CSS coordinates. Mirrors the per-element
+ * matrix3d that the dynamic-mode `--shadow-proj` builds, but evaluated on
+ * the CPU for a fixed light + ground — handy when many projected vertices
+ * are needed at once (e.g. rendering shadow outlines into a single SVG
+ * per mesh instead of one DOM leaf per casting polygon).
+ *
+ * `cssVertex` is a 3D point that has already been through the world→CSS
+ * axis swap and unit scale (so its components are dimensionless CSS-space
+ * coordinates). `lightDir` follows the same `--clx/--cly/--clz` convention
+ * as `buildBakedShadowProjectionMatrix`.
+ */
+export function projectCssVertexToGround(
+  cssVertex: Vec3,
+  lightDir: Vec3,
+  groundCssZ: number,
+): [number, number] {
+  const len = Math.hypot(lightDir[0], lightDir[1], lightDir[2]) || 1;
+  const lx = lightDir[0] / len;
+  const ly = lightDir[1] / len;
+  const lzRaw = lightDir[2] / len;
+  const lz =
+    Math.sign(lzRaw || 1) * Math.max(Math.abs(lzRaw), BAKED_SHADOW_MIN_UP);
+  const zDelta = cssVertex[2] - groundCssZ;
+  return [
+    cssVertex[0] - (lx / lz) * zDelta,
+    cssVertex[1] - (ly / lz) * zDelta,
+  ];
+}
diff --git a/packages/polycss/src/api/createPolyScene.test.ts b/packages/polycss/src/api/createPolyScene.test.ts
index 3b8f8f7f..8feb9259 100644
--- a/packages/polycss/src/api/createPolyScene.test.ts
+++ b/packages/polycss/src/api/createPolyScene.test.ts
@@ -1890,22 +1890,26 @@ describe("createPolyScene", () => {
       expect(host.querySelectorAll(".polycss-shadow").length).toBe(2);
     });
 
-    it("castShadow:true in baked mode emits shadow leaves with a CPU-baked matrix3d transform", () => {
-      // Baked mode bakes the shadow projection into each leaf's inline
-      // transform on the CPU — no var(--shadow-proj), no --pnx/--pny/--pnz
-      // opacity gate. The default light has +Z so the +Z-facing triangle
-      // is a caster and emits one shadow.
+    it("castShadow:true in baked mode emits a single  shadow per mesh containing one  per caster polygon", () => {
+      // Baked mode builds a per-mesh  with CPU-projected outlines so
+      // overlapping leaves composite as one silhouette (no alpha stacking).
+      // The default light has +Z so the +Z-facing triangle is a caster.
       scene = makeScene(host, { textureLighting: "baked" });
       scene.add(makeParseResult([triangle()]), { castShadow: true });
-      const shadow = host.querySelector(".polycss-shadow") as HTMLElement;
-      expect(shadow).not.toBeNull();
-      // Inline transform is a literal matrix3d() chain — no CSS var.
-      expect(shadow.style.transform).toMatch(/^matrix3d\(.+\)\s+matrix3d\(/);
+      const shadows = host.querySelectorAll(".polycss-shadow");
+      expect(shadows.length).toBe(1);
+      const shadow = shadows[0] as SVGSVGElement;
+      expect(shadow.tagName.toLowerCase()).toBe("svg");
+      expect(shadow.classList.contains("polycss-shadow-svg")).toBe(true);
+      // SVG positioning is a translate3d at the ground plane (no var(--shadow-proj)).
+      expect(shadow.style.transform).toMatch(/^translate3d\(/);
       expect(shadow.style.transform).not.toContain("var(--shadow-proj)");
-      // Back-facing polys are skipped, not opacity-gated — no normal vars.
-      expect(shadow.style.getPropertyValue("--pnx")).toBe("");
-      expect(shadow.style.getPropertyValue("--pny")).toBe("");
-      expect(shadow.style.getPropertyValue("--pnz")).toBe("");
+      // One path per caster polygon, grouped under a  so
+      // overlapping outlines collapse to one silhouette before alpha applies.
+      const group = shadow.querySelector("g");
+      expect(group).not.toBeNull();
+      expect(group!.getAttribute("opacity")).toBe("0.2500");
+      expect(group!.querySelectorAll("path").length).toBe(1);
     });
 
     it("baked mode skips shadow leaves for polygons facing away from the light", () => {
@@ -1986,16 +1990,18 @@ describe("createPolyScene", () => {
       expect(host.querySelectorAll(".polycss-shadow").length).toBe(0);
     });
 
-    it("switching from dynamic to baked rebuilds shadow leaves with inline matrix3d", () => {
+    it("switching from dynamic to baked rebuilds shadow as a translated ", () => {
       scene = makeScene(host, dynOpts);
       scene.add(makeParseResult([triangle()]), { castShadow: true });
       const dynamicShadow = host.querySelector(".polycss-shadow") as HTMLElement;
+      expect(dynamicShadow.tagName.toLowerCase()).toBe("q");
       expect(dynamicShadow.style.transform).toContain("var(--shadow-proj)");
       scene.setOptions({ textureLighting: "baked" });
-      const bakedShadow = host.querySelector(".polycss-shadow") as HTMLElement;
+      const bakedShadow = host.querySelector(".polycss-shadow") as SVGSVGElement;
       expect(bakedShadow).not.toBeNull();
+      expect(bakedShadow.tagName.toLowerCase()).toBe("svg");
       expect(bakedShadow.style.transform).not.toContain("var(--shadow-proj)");
-      expect(bakedShadow.style.transform).toMatch(/^matrix3d\(.+\)\s+matrix3d\(/);
+      expect(bakedShadow.style.transform).toMatch(/^translate3d\(/);
     });
 
     it("switching from baked back to dynamic re-emits shadow leaves using var(--shadow-proj)", () => {
@@ -2037,20 +2043,27 @@ describe("createPolyScene", () => {
       expect(sceneEl.style.getPropertyValue("--clz")).toBe("");
     });
 
-    it("baked mode re-emits shadow leaves when directionalLight.direction changes", () => {
-      // Light direction is folded into the CPU-baked matrix, so changing
-      // it must rewrite the inline transform — otherwise the shadows
-      // would stay frozen at the original light angle.
+    it("baked mode re-emits SVG shadows when directionalLight.direction changes", () => {
+      // Light direction is folded into the CPU projection that builds the
+      // SVG paths, so changing it must rewrite the SVG outlines (and the
+      // SVG's translate3d) — otherwise the shadows stay frozen at the
+      // original light angle.
       scene = makeScene(host, {
         textureLighting: "baked",
         directionalLight: { direction: [0, 0, 1] },
       });
       scene.add(makeParseResult([triangle()]), { castShadow: true });
-      const initial = (host.querySelector(".polycss-shadow") as HTMLElement).style.transform;
+      const initialSvg = host.querySelector(".polycss-shadow") as SVGSVGElement;
+      const initialTransform = initialSvg.style.transform;
+      const initialPathD = initialSvg.querySelector("path")?.getAttribute("d");
       scene.setOptions({ directionalLight: { direction: [1, 0, 1] } });
-      const next = (host.querySelector(".polycss-shadow") as HTMLElement).style.transform;
-      expect(next).not.toBe(initial);
-      expect(next).toMatch(/^matrix3d\(.+\)\s+matrix3d\(/);
+      const nextSvg = host.querySelector(".polycss-shadow") as SVGSVGElement;
+      const nextTransform = nextSvg.style.transform;
+      const nextPathD = nextSvg.querySelector("path")?.getAttribute("d");
+      expect(nextTransform).toMatch(/^translate3d\(/);
+      // EITHER the SVG positioning OR the path geometry must have changed
+      // — both encode the projection so both should reflect the new light.
+      expect(nextTransform !== initialTransform || nextPathD !== initialPathD).toBe(true);
     });
 
     it("baked mode does NOT set --shadow-ground-cssz on the scene element", () => {
diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts
index 5f9331e7..5752ab3d 100644
--- a/packages/polycss/src/api/createPolyScene.ts
+++ b/packages/polycss/src/api/createPolyScene.ts
@@ -39,12 +39,10 @@ import {
   CAMERA_BACKFACE_CULL_EPS,
   DEFAULT_SEAM_BLEED,
   VOXEL_CAMERA_CULL_NORMAL_LIMIT,
-  buildBakedShadowProjectionMatrix,
   cameraCullNormalKey,
   cameraCullVisibleSignature,
   computeSceneBbox,
   findOverlappingPolygonDuplicates,
-  formatMatrix3dValues,
   inverseRotateVec3,
   isAxisAlignedSurfaceNormal,
   isBakedShadowCaster,
@@ -53,6 +51,7 @@ import {
   optimizeMeshPolygons,
   parseHexColor,
   polygonCssSurfaceNormal,
+  projectCssVertexToGround,
 } from "@layoutit/polycss-core";
 import {
   cssBorderShapeForPlan,
@@ -358,6 +357,17 @@ function strategiesEqual(
   return true;
 }
 
+function pointsToSvgPath(points: Array<[number, number]>, originX: number, originY: number): string {
+  if (points.length === 0) return "";
+  const [x0, y0] = points[0]!;
+  let d = `M${(x0 - originX).toFixed(3)},${(y0 - originY).toFixed(3)}`;
+  for (let i = 1; i < points.length; i++) {
+    const [x, y] = points[i]!;
+    d += `L${(x - originX).toFixed(3)},${(y - originY).toFixed(3)}`;
+  }
+  return d + "Z";
+}
+
 function vec3Equal(a: Vec3 | undefined, b: Vec3 | undefined): boolean {
   if (a === b) return true;
   if (!a || !b) return false;
@@ -591,10 +601,16 @@ export function createPolyScene(
     wrapper: HTMLDivElement;
     parseResult: ParseResult;
     rendered: RenderedPoly[];
-    /** Shadow leaf elements, one per non-textured non-atlas polygon. Kept
-     *  separate from `rendered` so they can be removed independently when
-     *  castShadow is toggled or lighting mode changes. */
+    /** Dynamic-mode shadow `` leaves, one per non-deduped casting
+     *  polygon. Empty in baked mode (which uses `shadowSvg` instead). */
     shadowRendered: HTMLElement[];
+    /** Baked-mode shadow `` — a single per-mesh element whose ``
+     *  composites every casting polygon's projected outline into one
+     *  silhouette before applying `shadow.opacity`. Carries the same
+     *  overall visual as a stack of per-polygon `` leaves but avoids
+     *  the alpha-accumulation darkening at polygon intersections, and
+     *  reduces DOM weight to a single element per mesh. */
+    shadowSvg?: SVGSVGElement;
     voxelRenderer?: PolyVoxelRenderer;
     disposeAtlas?: () => void;
     polygons: Polygon[];
@@ -751,6 +767,10 @@ export function createPolyScene(
       if (el.parentNode) el.parentNode.removeChild(el);
     }
     entry.shadowRendered.length = 0;
+    if (entry.shadowSvg?.parentNode) {
+      entry.shadowSvg.parentNode.removeChild(entry.shadowSvg);
+    }
+    entry.shadowSvg = undefined;
   }
 
   function disposeRendered(rendered: RenderedPoly[], disposeAtlas?: () => void): void {
@@ -1134,20 +1154,25 @@ export function createPolyScene(
     }
   }
 
-  // Emits shadow leaves for all rendered polys in the entry. Each shadow
-  // leaf uses the same border-shape as the original but with a flat shadow
-  // color and a transform that projects the polygon onto the ground plane
-  // along the directional light.
+  // Emits shadow leaves for all rendered polys in the entry.
+  //
+  // Dynamic mode emits one `` per casting polygon whose transform
+  // chains `var(--shadow-proj)` so the projection follows the live light
+  // vars on the scene root (zero JS at light-change time). A CSS opacity
+  // calc on the scene root hides back-facing polys at paint time.
   //
-  // Dynamic mode prepends `var(--shadow-proj)` so the projection follows
-  // the live light vars on the scene root (zero JS at light-change time).
-  // Baked mode pre-composes the projection matrix on the CPU and inlines
-  // it as a literal `matrix3d(...)` — and drops back-facing polygons from
-  // the DOM entirely instead of using a CSS opacity gate.
+  // Baked mode emits a single `` per mesh containing one ``
+  // per casting polygon (projected to ground on the CPU). The shared
+  // `` collapses overlapping outlines into one solid silhouette
+  // before applying the alpha — no per-leaf alpha accumulation at
+  // polygon intersections, and no `opacity + preserve-3d` flatten trap
+  // because SVG content is internally 2D. Back-facing polys are dropped
+  // up front.
   //
-  // Shadow leaves are inserted BEFORE their caster siblings so they sit
-  // below in DOM order, which keeps them behind the casters when both are
-  // coplanar in 3D (painter-order tie-breaking favors earlier nodes).
+  // Shadow elements are inserted BEFORE the first non-shadow child so
+  // they sit below casters in DOM order, which keeps them behind the
+  // casters when both are coplanar in 3D (painter-order tie-breaking
+  // favors earlier nodes).
   function emitShadowLeaves(entry: MeshEntry): void {
     clearShadowLeaves(entry);
     if (!entry.castShadow) return;
@@ -1159,21 +1184,8 @@ export function createPolyScene(
 
     const shadowColor = currentOptions.shadow?.color ?? "#000000";
     const shadowOpacity = currentOptions.shadow?.opacity ?? 0.25;
-    // Build a CSS rgba color from the hex + opacity.
     const parsed = parseHexColor(shadowColor)?.rgb ?? [0, 0, 0];
     const r = parsed[0], g = parsed[1], b = parsed[2];
-    const shadowColorCss = `rgba(${r},${g},${b},${shadowOpacity})`;
-
-    // Pre-compute the baked projection matrix once per emit pass — the
-    // light + ground are fixed so it's identical for every leaf in this
-    // entry. In dynamic mode the matrix lives in CSS and we skip this.
-    const lightDir = currentOptions.directionalLight?.direction
-      ?? ([0.4, -0.7, 0.59] as Vec3);
-    const bakedProjStr = isDynamic
-      ? null
-      : `matrix3d(${formatMatrix3dValues(
-          buildBakedShadowProjectionMatrix(lightDir, currentGroundCssZ ?? 0),
-        )})`;
 
     // Loose-tolerance dedup for shadow casting ONLY — much more permissive
     // than the parse-time dedup that affects the rendered model. Multiple
@@ -1191,6 +1203,22 @@ export function createPolyScene(
       overlapFraction: 0.4,
     });
 
+    const lightDir = currentOptions.directionalLight?.direction
+      ?? ([0.4, -0.7, 0.59] as Vec3);
+
+    if (!isDynamic) {
+      emitBakedShadowSvg(
+        entry,
+        shadowDedupDrop,
+        lightDir,
+        currentGroundCssZ ?? 0,
+        r, g, b,
+        shadowOpacity,
+      );
+      return;
+    }
+
+    const shadowColorCss = `rgba(${r},${g},${b},${shadowOpacity})`;
     const fragment = doc.createDocumentFragment();
     for (const item of renderedItemsForCamera(entry)) {
       // Atlas () polygons cast shadows too — the shadow only needs
@@ -1202,12 +1230,6 @@ export function createPolyScene(
       const plan = item.plan;
       if (!plan) continue;
 
-      // Baked mode: skip polygons facing AWAY from the receiver entirely
-      // (they don't contribute to the silhouette). Dynamic mode keeps them
-      // mounted and uses a CSS opacity calc to hide them so the gate
-      // updates live with the light.
-      if (!isDynamic && !isBakedShadowCaster(plan.normal, lightDir)) continue;
-
       // Read the original matrix3d from the plan (not from the element
       // style string) so we never parse strings.
       const origMatrix = `matrix3d(${plan.matrix})`;
@@ -1223,21 +1245,16 @@ export function createPolyScene(
       // preserve-3d = ~15 s/frame on Chromium).
       const shadowEl = doc.createElement("q");
       shadowEl.className = "polycss-shadow";
-      shadowEl.style.transform = isDynamic
-        ? `var(--shadow-proj) ${origMatrix}`
-        : `${bakedProjStr} ${origMatrix}`;
+      shadowEl.style.transform = `var(--shadow-proj) ${origMatrix}`;
       shadowEl.style.color = shadowColorCss;
       shadowEl.style.width = `${plan.canvasW}px`;
       shadowEl.style.height = `${plan.canvasH}px`;
       shadowEl.style.setProperty("border-shape", cssBorderShapeForPlan(plan));
       // Dynamic mode pins the caster's normal so the per-element opacity
-      // calc can Lambert-gate back-facing polys. Baked mode already
-      // dropped those above, so no normal vars are needed.
-      if (isDynamic) {
-        shadowEl.style.setProperty("--pnx", plan.normal[0].toFixed(4));
-        shadowEl.style.setProperty("--pny", plan.normal[1].toFixed(4));
-        shadowEl.style.setProperty("--pnz", plan.normal[2].toFixed(4));
-      }
+      // calc can Lambert-gate back-facing polys.
+      shadowEl.style.setProperty("--pnx", plan.normal[0].toFixed(4));
+      shadowEl.style.setProperty("--pny", plan.normal[1].toFixed(4));
+      shadowEl.style.setProperty("--pnz", plan.normal[2].toFixed(4));
 
       fragment.appendChild(shadowEl);
       entry.shadowRendered.push(shadowEl);
@@ -1254,6 +1271,93 @@ export function createPolyScene(
     }
   }
 
+  // Builds a single per-mesh  for baked-mode shadows. Projects every
+  // casting polygon to the ground plane on the CPU, then drops one 
+  // per polygon into a shared  so overlapping
+  // outlines composite as one silhouette (no alpha accumulation at
+  // intersections). SVG content is internally 2D so this sidesteps the
+  // `opacity + transform-style: preserve-3d` flatten trap that breaks
+  // CSS-only shadow grouping in a 3D scene.
+  function emitBakedShadowSvg(
+    entry: MeshEntry,
+    dedupDrop: Set,
+    lightDir: Vec3,
+    groundCssZ: number,
+    r: number,
+    g: number,
+    b: number,
+    opacity: number,
+  ): void {
+    const polyProjections: Array> = [];
+    let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+    for (const item of renderedItemsForCamera(entry)) {
+      if (dedupDrop.has(item.polygonIndex)) continue;
+      const plan = item.plan;
+      if (!plan) continue;
+      if (!isBakedShadowCaster(plan.normal, lightDir)) continue;
+      const polygon = entry.polygons[item.polygonIndex];
+      if (!polygon) continue;
+
+      const projected: Array<[number, number]> = [];
+      for (const v of polygon.vertices) {
+        // World → CSS-3D: swap X and Y, scale by BASE_TILE. Matches the
+        // axis convention used by plan.matrix / --shadow-proj so the
+        // projected output sits where the dynamic-mode shadow would.
+        const cssVertex: Vec3 = [
+          v[1] * DEFAULT_TILE,
+          v[0] * DEFAULT_TILE,
+          v[2] * DEFAULT_TILE,
+        ];
+        const p = projectCssVertexToGround(cssVertex, lightDir, groundCssZ);
+        projected.push(p);
+        if (p[0] < minX) minX = p[0];
+        if (p[1] < minY) minY = p[1];
+        if (p[0] > maxX) maxX = p[0];
+        if (p[1] > maxY) maxY = p[1];
+      }
+      polyProjections.push(projected);
+    }
+
+    if (polyProjections.length === 0) return;
+    const width = maxX - minX;
+    const height = maxY - minY;
+    if (!(width > 0) || !(height > 0)) return;
+
+    const svgNS = "http://www.w3.org/2000/svg";
+    const svg = doc.createElementNS(svgNS, "svg");
+    svg.setAttribute("class", "polycss-shadow polycss-shadow-svg");
+    svg.setAttribute("width", String(width));
+    svg.setAttribute("height", String(height));
+    svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
+    // CSS-Z places the SVG plane at the ground in the mesh's local frame;
+    // the mesh wrapper's own transform is applied above this. X/Y origin
+    // shifts the SVG so its (0,0) lines up with the projected bbox corner.
+    svg.setAttribute(
+      "style",
+      `position:absolute;top:0;left:0;display:block;overflow:visible;` +
+      `transform-origin:0 0;pointer-events:none;` +
+      `transform:translate3d(${minX.toFixed(3)}px,${minY.toFixed(3)}px,${groundCssZ.toFixed(3)}px)`,
+    );
+
+    const group = doc.createElementNS(svgNS, "g");
+    group.setAttribute("fill", `rgb(${r},${g},${b})`);
+    group.setAttribute("opacity", opacity.toFixed(4));
+    for (const verts of polyProjections) {
+      const path = doc.createElementNS(svgNS, "path");
+      path.setAttribute("d", pointsToSvgPath(verts, minX, minY));
+      group.appendChild(path);
+    }
+    svg.appendChild(group);
+
+    entry.shadowSvg = svg;
+    const firstChild = entry.wrapper.firstChild;
+    if (firstChild) {
+      entry.wrapper.insertBefore(svg, firstChild);
+    } else {
+      entry.wrapper.appendChild(svg);
+    }
+  }
+
   function remountEntry(entry: MeshEntry): void {
     if (entry.voxelRenderer) {
       entry.voxelRenderer.render(cameraCullRotation(entry));

From 7731075eb63fad923c5ac01596f80ccc0c979f6e Mon Sep 17 00:00:00 2001
From: Juan Cruz Fortunatti 
Date: Sat, 23 May 2026 04:47:56 +0200
Subject: [PATCH 09/28] feat(react,vue): mirror SVG baked shadow rendering

---
 .../src/scene/PolyMesh.castShadow.test.tsx    |  32 ++--
 packages/react/src/scene/PolyMesh.tsx         | 171 ++++++++++++------
 .../vue/src/scene/PolyMesh.castShadow.test.ts |  24 ++-
 packages/vue/src/scene/PolyMesh.ts            | 163 ++++++++++++-----
 4 files changed, 265 insertions(+), 125 deletions(-)

diff --git a/packages/react/src/scene/PolyMesh.castShadow.test.tsx b/packages/react/src/scene/PolyMesh.castShadow.test.tsx
index 76da8d32..28be34d6 100644
--- a/packages/react/src/scene/PolyMesh.castShadow.test.tsx
+++ b/packages/react/src/scene/PolyMesh.castShadow.test.tsx
@@ -115,21 +115,25 @@ describe("PolyMesh — castShadow", () => {
     expect(container.querySelectorAll(".polycss-shadow").length).toBe(2);
   });
 
-  it("castShadow in baked mode emits shadow leaves with a CPU-baked matrix3d transform", () => {
-    // Baked mode bakes the projection into each leaf's inline transform
-    // — no var(--shadow-proj), no --pnx/--pny/--pnz opacity gate. The
-    // default light has positive Z, so the +Z-facing triangle is a caster.
+  it("castShadow in baked mode emits a single  shadow per mesh containing one  per caster polygon", () => {
+    // Baked mode builds a per-mesh  with CPU-projected outlines so
+    // overlapping leaves composite as one silhouette (no alpha stacking).
+    // The default light has positive Z, so the +Z-facing triangle is a caster.
     const { container } = renderScene(
       { textureLighting: "baked" },
       { polygons: [TRIANGLE], castShadow: true },
     );
-    const shadow = container.querySelector(".polycss-shadow") as HTMLElement;
-    expect(shadow).not.toBeNull();
-    expect(shadow.style.transform).toMatch(/^matrix3d\(.+\)\s+matrix3d\(/);
+    const shadows = container.querySelectorAll(".polycss-shadow");
+    expect(shadows.length).toBe(1);
+    const shadow = shadows[0] as SVGSVGElement;
+    expect(shadow.tagName.toLowerCase()).toBe("svg");
+    expect(shadow.classList.contains("polycss-shadow-svg")).toBe(true);
+    expect(shadow.style.transform).toMatch(/^translate3d\(/);
     expect(shadow.style.transform).not.toContain("var(--shadow-proj)");
-    expect(shadow.style.getPropertyValue("--pnx")).toBe("");
-    expect(shadow.style.getPropertyValue("--pny")).toBe("");
-    expect(shadow.style.getPropertyValue("--pnz")).toBe("");
+    const group = shadow.querySelector("g");
+    expect(group).not.toBeNull();
+    expect(group!.getAttribute("opacity")).toBe("0.2500");
+    expect(group!.querySelectorAll("path").length).toBe(1);
   });
 
   it("shadow leaves are  elements", () => {
@@ -189,19 +193,21 @@ describe("PolyMesh — castShadow", () => {
     expect(container.querySelectorAll(".polycss-shadow").length).toBe(0);
   });
 
-  it("switching scene from dynamic to baked rebuilds shadow leaves with inline matrix3d", () => {
+  it("switching scene from dynamic to baked replaces per-polygon  with one  shadow", () => {
     const { container, root } = renderScene(DYN_SCENE_PROPS, {
       polygons: [TRIANGLE],
       castShadow: true,
     });
     const dynamicShadow = container.querySelector(".polycss-shadow") as HTMLElement;
+    expect(dynamicShadow.tagName.toLowerCase()).toBe("q");
     expect(dynamicShadow.style.transform).toContain("var(--shadow-proj)");
 
     rerender(root, { textureLighting: "baked" }, { polygons: [TRIANGLE], castShadow: true });
-    const bakedShadow = container.querySelector(".polycss-shadow") as HTMLElement;
+    const bakedShadow = container.querySelector(".polycss-shadow") as SVGSVGElement;
     expect(bakedShadow).not.toBeNull();
+    expect(bakedShadow.tagName.toLowerCase()).toBe("svg");
     expect(bakedShadow.style.transform).not.toContain("var(--shadow-proj)");
-    expect(bakedShadow.style.transform).toMatch(/^matrix3d\(.+\)\s+matrix3d\(/);
+    expect(bakedShadow.style.transform).toMatch(/^translate3d\(/);
   });
 
   it("textured polygons (s) ALSO emit shadow leaves (Frog Guy regression)", () => {
diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx
index 90c09683..91c89dcf 100644
--- a/packages/react/src/scene/PolyMesh.tsx
+++ b/packages/react/src/scene/PolyMesh.tsx
@@ -32,14 +32,14 @@ import type {
   Vec3,
 } from "@layoutit/polycss-core";
 import {
-  buildBakedShadowProjectionMatrix,
+  BASE_TILE,
   computeSceneBbox,
   DEFAULT_SEAM_BLEED,
   findOverlappingPolygonDuplicates,
-  formatMatrix3dValues,
   inverseRotateVec3,
   isBakedShadowCaster,
   parseHexColor,
+  projectCssVertexToGround,
 } from "@layoutit/polycss-core";
 import type { TransformProps } from "../shapes/types";
 import { usePolyMesh, type UseMeshOptions } from "./useMesh";
@@ -618,36 +618,28 @@ export const PolyMesh = forwardRef(function PolyM
     };
   }, [sceneRegisterShadowCaster, castShadow, polygons]);
 
-  // Build shadow leaf elements. Emitted in both lighting modes:
-  //   - Dynamic: transform uses `var(--shadow-proj) matrix3d(...)`; the CSS
-  //     opacity calc on the scene root gates back-facing polys at paint time.
-  //   - Baked: transform is `matrix3d() matrix3d(...)` with the
-  //     projection CPU-baked from the fixed light + ground; back-facing
-  //     polys are dropped from the output entirely (no opacity gate needed).
-  // Uses the same plans as the caster polygons so the outlines are identical.
-  // Deduplication removes stacked coplanar shadow leaves that would produce
-  // visible double-shadows on the receiver.
+  // Build shadow leaf elements. Two paths:
+  //   - Dynamic: one `` per casting polygon, transform chains
+  //     `var(--shadow-proj) matrix3d(...)` so the projection follows
+  //     live light updates on the scene root; CSS opacity calc gates
+  //     back-facing polys.
+  //   - Baked: a single `` per mesh containing one `` per
+  //     caster polygon (projected to ground on the CPU), grouped under
+  //     `` so overlapping outlines composite as one
+  //     silhouette before alpha is applied. SVG content is internally
+  //     2D so this sidesteps the `opacity + preserve-3d` flatten trap.
+  //     Back-facing polys are dropped up front.
   const bakedShadowGroundCssZ = sceneCtx?.groundCssZ ?? null;
   const shadowLeaves = useMemo(() => {
     if (!castShadow || renderPolygon) return [];
     const isDynamic = effectiveTextureLighting === "dynamic";
-    // Baked mode needs a ground plane to project onto. Until the scene's
-    // recomputeGroundCssZ runs, fall through and let the next render emit.
-    if (!isDynamic && bakedShadowGroundCssZ === null) return [];
+    if (!isDynamic) return [];
 
     const shadowColor = sceneCtx?.shadow?.color ?? "#000000";
     const shadowOpacity = sceneCtx?.shadow?.opacity ?? 0.25;
     const parsed = parseHexColor(shadowColor)?.rgb ?? [0, 0, 0];
     const shadowColorCss = `rgba(${parsed[0]},${parsed[1]},${parsed[2]},${shadowOpacity})`;
 
-    const lightDir = sceneDirectionalLight?.direction
-      ?? ([0.4, -0.7, 0.59] as Vec3);
-    const bakedProjStr = isDynamic
-      ? null
-      : `matrix3d(${formatMatrix3dValues(
-          buildBakedShadowProjectionMatrix(lightDir, bakedShadowGroundCssZ ?? 0),
-        )})`;
-
     const shadowDedupDrop = findOverlappingPolygonDuplicates(polygons, {
       normalTolerance: 0.1,
       distanceTolerance: 0.5,
@@ -658,8 +650,6 @@ export const PolyMesh = forwardRef(function PolyM
     for (const plan of atlasPlans) {
       if (!plan) continue;
       if (shadowDedupDrop.has(plan.index)) continue;
-      // Baked mode: drop back-facing polys before reaching the DOM.
-      if (!isDynamic && !isBakedShadowCaster(plan.normal, lightDir)) continue;
 
       const borderShape = cssBorderShapeForPlan(plan);
       leaves.push(
@@ -668,12 +658,93 @@ export const PolyMesh = forwardRef(function PolyM
           plan={plan}
           shadowColorCss={shadowColorCss}
           borderShape={borderShape}
-          bakedProjStr={bakedProjStr}
         />
       );
     }
     return leaves;
-  }, [castShadow, effectiveTextureLighting, renderPolygon, polygons, atlasPlans, sceneCtx?.shadow, sceneDirectionalLight, bakedShadowGroundCssZ]);
+  }, [castShadow, effectiveTextureLighting, renderPolygon, polygons, atlasPlans, sceneCtx?.shadow]);
+
+  // Baked-mode SVG shadow: single per-mesh element.
+  const sceneShadow = sceneCtx?.shadow;
+  const shadowSvgNode = useMemo(() => {
+    if (!castShadow || renderPolygon) return null;
+    if (effectiveTextureLighting === "dynamic") return null;
+    if (bakedShadowGroundCssZ === null) return null;
+
+    const lightDir = sceneDirectionalLight?.direction
+      ?? ([0.4, -0.7, 0.59] as Vec3);
+    const shadowDedupDrop = findOverlappingPolygonDuplicates(polygons, {
+      normalTolerance: 0.1,
+      distanceTolerance: 0.5,
+      overlapFraction: 0.4,
+    });
+
+    const projections: Array> = [];
+    let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+    for (let i = 0; i < polygons.length; i++) {
+      const polygon = polygons[i]!;
+      if (shadowDedupDrop.has(i)) continue;
+      const plan = atlasPlans[i];
+      if (!plan) continue;
+      if (!isBakedShadowCaster(plan.normal, lightDir)) continue;
+      const projected: Array<[number, number]> = [];
+      for (const v of polygon.vertices) {
+        const cssVertex: Vec3 = [
+          v[1] * BASE_TILE,
+          v[0] * BASE_TILE,
+          v[2] * BASE_TILE,
+        ];
+        const p = projectCssVertexToGround(cssVertex, lightDir, bakedShadowGroundCssZ);
+        projected.push(p);
+        if (p[0] < minX) minX = p[0];
+        if (p[1] < minY) minY = p[1];
+        if (p[0] > maxX) maxX = p[0];
+        if (p[1] > maxY) maxY = p[1];
+      }
+      projections.push(projected);
+    }
+    if (projections.length === 0) return null;
+    const width = maxX - minX;
+    const height = maxY - minY;
+    if (!(width > 0) || !(height > 0)) return null;
+
+    const shadowColor = sceneShadow?.color ?? "#000000";
+    const shadowOpacity = sceneShadow?.opacity ?? 0.25;
+    const parsed = parseHexColor(shadowColor)?.rgb ?? [0, 0, 0];
+
+    const paths = projections.map((verts, idx) => {
+      let d = `M${(verts[0]![0] - minX).toFixed(3)},${(verts[0]![1] - minY).toFixed(3)}`;
+      for (let j = 1; j < verts.length; j++) {
+        d += `L${(verts[j]![0] - minX).toFixed(3)},${(verts[j]![1] - minY).toFixed(3)}`;
+      }
+      d += "Z";
+      return ;
+    });
+
+    return (
+      
+        
+          {paths}
+        
+      
+    );
+  }, [castShadow, renderPolygon, effectiveTextureLighting, polygons, atlasPlans, sceneDirectionalLight, bakedShadowGroundCssZ, sceneShadow]);
 
   setPolygonsImplRef.current = (nextPolygons: Polygon[]) => {
     const nextRenderedPolygons = autoCenter ? recenterPolygons(nextPolygons) : nextPolygons;
@@ -796,6 +867,7 @@ export const PolyMesh = forwardRef(function PolyM
       style={wrapperStyle}
       {...wrapperHandlers}
     >
+      {shadowSvgNode}
       {shadowLeaves}
       {renderedPolygons}
       {staticChildren}
@@ -818,58 +890,41 @@ function RenderPropPolygon({
   return <>{children(polygon, index)};
 }
 
-// Shadow leaf — a  element that projects the caster polygon's outline
-// onto the ground plane. border-shape clips the element to the polygon's
-// outline (same mechanism as ).
-//
-// Dynamic mode: transform is `var(--shadow-proj) matrix3d(...)` so the
-// projection follows live light vars on the scene root; --pnx/y/z is
-// pinned so the CSS opacity gate in styles.ts skips back-facing polys.
-//
-// Baked mode: `bakedProjStr` carries a CPU-baked `matrix3d()`
-// for the projection; back-facing polys are dropped by the caller and
-// no normal vars are needed.
-//
+// Dynamic-mode shadow leaf — one  per caster polygon, transform
+// chains `var(--shadow-proj) matrix3d(...)` so the projection follows
+// the live light vars on the scene root. --pnx/y/z is pinned so the CSS
+// opacity gate in styles.ts hides back-facing polys at paint time.
+// Baked mode uses a single per-mesh  instead (see shadowSvgNode in
+// PolyMeshInner above).
 // Uses a ref callback for border-shape (non-standard CSS property, must
 // be set via setProperty).
 function ShadowLeaf({
   plan,
   shadowColorCss,
   borderShape,
-  bakedProjStr,
 }: {
   plan: TextureAtlasPlan;
   shadowColorCss: string;
   borderShape: string;
-  bakedProjStr: string | null;
 }) {
   const setRef = useCallback((el: HTMLElement | null) => {
     if (!el) return;
     el.style.setProperty("border-shape", borderShape);
   }, [borderShape]);
 
-  const baseStyle: CSSProperties = {
-    transform: bakedProjStr
-      ? `${bakedProjStr} matrix3d(${plan.matrix})`
-      : `var(--shadow-proj) matrix3d(${plan.matrix})`,
-    color: shadowColorCss,
-    width: plan.canvasW,
-    height: plan.canvasH,
-  };
-  const style: CSSProperties = bakedProjStr
-    ? baseStyle
-    : {
-        ...baseStyle,
-        ["--pnx" as string]: plan.normal[0].toFixed(4),
-        ["--pny" as string]: plan.normal[1].toFixed(4),
-        ["--pnz" as string]: plan.normal[2].toFixed(4),
-      } as CSSProperties;
-
   return (
     
   );
 }
diff --git a/packages/vue/src/scene/PolyMesh.castShadow.test.ts b/packages/vue/src/scene/PolyMesh.castShadow.test.ts
index 7e90ff1a..e158062f 100644
--- a/packages/vue/src/scene/PolyMesh.castShadow.test.ts
+++ b/packages/vue/src/scene/PolyMesh.castShadow.test.ts
@@ -94,10 +94,10 @@ describe("PolyMesh (Vue) — castShadow", () => {
     expect(container.querySelectorAll(".polycss-shadow").length).toBe(2);
   });
 
-  it("castShadow:true in baked mode emits shadow leaves with a CPU-baked matrix3d transform", async () => {
-    // Baked mode bakes the projection into each leaf's inline transform —
-    // no var(--shadow-proj), no --pnx/--pny/--pnz opacity gate. The
-    // default light has positive Z so the +Z-facing triangle is a caster.
+  it("castShadow:true in baked mode emits a single  shadow per mesh containing one  per caster polygon", async () => {
+    // Baked mode builds a per-mesh  with CPU-projected outlines so
+    // overlapping leaves composite as one silhouette (no alpha stacking).
+    // The default light has positive Z so the +Z-facing triangle is a caster.
     // nextTick lets the scene's watchEffect derive groundCssZ from the
     // child's registration before the shadow nodes recompute.
     const { container } = mount(
@@ -106,13 +106,17 @@ describe("PolyMesh (Vue) — castShadow", () => {
     );
     await nextTick();
     await nextTick();
-    const shadow = container.querySelector(".polycss-shadow") as HTMLElement;
-    expect(shadow).not.toBeNull();
-    expect(shadow.style.transform).toMatch(/^matrix3d\(.+\)\s+matrix3d\(/);
+    const shadows = container.querySelectorAll(".polycss-shadow");
+    expect(shadows.length).toBe(1);
+    const shadow = shadows[0] as SVGSVGElement;
+    expect(shadow.tagName.toLowerCase()).toBe("svg");
+    expect(shadow.classList.contains("polycss-shadow-svg")).toBe(true);
+    expect(shadow.style.transform).toMatch(/^translate3d\(/);
     expect(shadow.style.transform).not.toContain("var(--shadow-proj)");
-    expect(shadow.style.getPropertyValue("--pnx")).toBe("");
-    expect(shadow.style.getPropertyValue("--pny")).toBe("");
-    expect(shadow.style.getPropertyValue("--pnz")).toBe("");
+    const group = shadow.querySelector("g");
+    expect(group).not.toBeNull();
+    expect(group!.getAttribute("opacity")).toBe("0.2500");
+    expect(group!.querySelectorAll("path").length).toBe(1);
   });
 
   it("shadow leaves are always  with class polycss-shadow", () => {
diff --git a/packages/vue/src/scene/PolyMesh.ts b/packages/vue/src/scene/PolyMesh.ts
index e9422c90..bfacd2ef 100644
--- a/packages/vue/src/scene/PolyMesh.ts
+++ b/packages/vue/src/scene/PolyMesh.ts
@@ -20,14 +20,14 @@ import { defineComponent, h, computed, inject, onMounted, onBeforeUnmount, ref,
 import type { PropType, VNode, CSSProperties } from "vue";
 import type { MeshResolution, Polygon, PolyTextureLightingMode, Vec3 } from "@layoutit/polycss-core";
 import {
-  buildBakedShadowProjectionMatrix,
+  BASE_TILE,
   computeSceneBbox,
   DEFAULT_SEAM_BLEED,
   inverseRotateVec3,
   findOverlappingPolygonDuplicates,
-  formatMatrix3dValues,
   isBakedShadowCaster,
   parseHexColor,
+  projectCssVertexToGround,
 } from "@layoutit/polycss-core";
 import { usePolyMesh } from "./useMesh";
 import {
@@ -306,36 +306,21 @@ export const PolyMesh = defineComponent({
     );
     const defaultPaintVars = computed(() => solidPaintVars(solidPaintDefaults.value));
 
-    // Shadow leaf emission. Active when castShadow=true in either lighting
-    // mode. Dynamic mode emits leaves whose transform uses var(--shadow-proj)
-    // and whose --pnx/y/z drive a CSS opacity calc that hides back-facing
-    // polys at paint time. Baked mode CPU-bakes the projection matrix
-    // (using the scene's fixed light + ground) and drops back-facing polys
-    // from the DOM entirely instead of opacity-gating them.
+    // Dynamic-mode shadow leaves — one  per casting polygon whose
+    // transform chains var(--shadow-proj) so shadows reflow as the light
+    // moves. --pnx/y/z drive the CSS opacity gate that hides back-facing
+    // polys. Baked mode uses the per-mesh  below instead.
     const shadowNodes = computed>(() => {
       if (!props.castShadow) return [];
-      const lighting = atlasTextureLighting.value;
-      const isDynamic = lighting === "dynamic";
-      const ctx = sceneCtx?.value;
-      const groundCssZ = ctx?.groundCssZ ?? null;
-      // Baked mode needs a ground plane to project onto. The scene's
-      // watchEffect will populate it on the next tick after registration.
-      if (!isDynamic && groundCssZ === null) return [];
+      if (atlasTextureLighting.value !== "dynamic") return [];
 
+      const ctx = sceneCtx?.value;
       const shadowOpts = ctx?.shadow;
       const shadowColor = shadowOpts?.color ?? "#000000";
       const shadowOpacity = shadowOpts?.opacity ?? 0.25;
       const parsed = parseHexColor(shadowColor)?.rgb ?? [0, 0, 0];
       const shadowColorCss = `rgba(${parsed[0]},${parsed[1]},${parsed[2]},${shadowOpacity})`;
 
-      const lightDir = ctx?.directionalLight?.direction
-        ?? ([0.4, -0.7, 0.59] as Vec3);
-      const bakedProjStr = isDynamic
-        ? null
-        : `matrix3d(${formatMatrix3dValues(
-            buildBakedShadowProjectionMatrix(lightDir, groundCssZ ?? 0),
-          )})`;
-
       const plans = textureAtlasPlans.value;
       if (plans.length === 0) return [];
 
@@ -348,25 +333,17 @@ export const PolyMesh = defineComponent({
       return plans.map((plan, index) => {
         if (!plan) return null;
         if (dedupDrop.has(index)) return null;
-        if (!isDynamic && !isBakedShadowCaster(plan.normal, lightDir)) return null;
         const origMatrix = `matrix3d(${plan.matrix})`;
         const borderShape = cssBorderShapeForPlan(plan);
-        const style: CSSProperties = isDynamic
-          ? {
-              transform: `var(--shadow-proj) ${origMatrix}`,
-              color: shadowColorCss,
-              width: `${plan.canvasW}px`,
-              height: `${plan.canvasH}px`,
-              "--pnx": plan.normal[0].toFixed(4),
-              "--pny": plan.normal[1].toFixed(4),
-              "--pnz": plan.normal[2].toFixed(4),
-            }
-          : {
-              transform: `${bakedProjStr} ${origMatrix}`,
-              color: shadowColorCss,
-              width: `${plan.canvasW}px`,
-              height: `${plan.canvasH}px`,
-            };
+        const style: CSSProperties = {
+          transform: `var(--shadow-proj) ${origMatrix}`,
+          color: shadowColorCss,
+          width: `${plan.canvasW}px`,
+          height: `${plan.canvasH}px`,
+          "--pnx": plan.normal[0].toFixed(4),
+          "--pny": plan.normal[1].toFixed(4),
+          "--pnz": plan.normal[2].toFixed(4),
+        };
 
         const applyShadowBorderShape = (vnode: VNode) => {
           const el = vnode.el as HTMLElement | null;
@@ -383,6 +360,101 @@ export const PolyMesh = defineComponent({
       });
     });
 
+    // Baked-mode SVG shadow — single per-mesh  with one  per
+    // caster polygon. Overlapping outlines composite as one silhouette
+    // inside the  before alpha is applied, sidestepping
+    // the `opacity + preserve-3d` flatten trap.
+    const shadowSvg = computed(() => {
+      if (!props.castShadow) return null;
+      if (atlasTextureLighting.value === "dynamic") return null;
+      const ctx = sceneCtx?.value;
+      const groundCssZ = ctx?.groundCssZ ?? null;
+      if (groundCssZ === null) return null;
+
+      const lightDir = ctx?.directionalLight?.direction
+        ?? ([0.4, -0.7, 0.59] as Vec3);
+      const dedupDrop = findOverlappingPolygonDuplicates(polygons.value, {
+        normalTolerance: 0.1,
+        distanceTolerance: 0.5,
+        overlapFraction: 0.4,
+      });
+
+      const projections: Array> = [];
+      let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+      const polys = polygons.value;
+      const plans = textureAtlasPlans.value;
+      for (let i = 0; i < polys.length; i++) {
+        if (dedupDrop.has(i)) continue;
+        const plan = plans[i];
+        if (!plan) continue;
+        if (!isBakedShadowCaster(plan.normal, lightDir)) continue;
+        const polygon = polys[i]!;
+        const projected: Array<[number, number]> = [];
+        for (const v of polygon.vertices) {
+          const cssVertex: Vec3 = [
+            v[1] * BASE_TILE,
+            v[0] * BASE_TILE,
+            v[2] * BASE_TILE,
+          ];
+          const p = projectCssVertexToGround(cssVertex, lightDir, groundCssZ);
+          projected.push(p);
+          if (p[0] < minX) minX = p[0];
+          if (p[1] < minY) minY = p[1];
+          if (p[0] > maxX) maxX = p[0];
+          if (p[1] > maxY) maxY = p[1];
+        }
+        projections.push(projected);
+      }
+      if (projections.length === 0) return null;
+      const width = maxX - minX;
+      const height = maxY - minY;
+      if (!(width > 0) || !(height > 0)) return null;
+
+      const shadowOpts = ctx?.shadow;
+      const shadowColor = shadowOpts?.color ?? "#000000";
+      const shadowOpacity = shadowOpts?.opacity ?? 0.25;
+      const parsed = parseHexColor(shadowColor)?.rgb ?? [0, 0, 0];
+
+      const pathNodes = projections.map((verts, idx) => {
+        let d = `M${(verts[0]![0] - minX).toFixed(3)},${(verts[0]![1] - minY).toFixed(3)}`;
+        for (let j = 1; j < verts.length; j++) {
+          d += `L${(verts[j]![0] - minX).toFixed(3)},${(verts[j]![1] - minY).toFixed(3)}`;
+        }
+        d += "Z";
+        return h("path", { key: idx, d });
+      });
+
+      return h(
+        "svg",
+        {
+          class: "polycss-shadow polycss-shadow-svg",
+          width: String(width),
+          height: String(height),
+          viewBox: `0 0 ${width} ${height}`,
+          style: {
+            position: "absolute",
+            top: "0",
+            left: "0",
+            display: "block",
+            overflow: "visible",
+            transformOrigin: "0 0",
+            pointerEvents: "none",
+            transform: `translate3d(${minX.toFixed(3)}px,${minY.toFixed(3)}px,${groundCssZ.toFixed(3)}px)`,
+          } as CSSProperties,
+        },
+        [
+          h(
+            "g",
+            {
+              fill: `rgb(${parsed[0]},${parsed[1]},${parsed[2]})`,
+              opacity: shadowOpacity.toFixed(4),
+            },
+            pathNodes,
+          ),
+        ],
+      );
+    });
+
     // Register this mesh with the shadow registry when castShadow=true in
     // either lighting mode — the scene needs caster polygons to derive
     // the ground plane regardless of how shadows are projected.
@@ -709,10 +781,13 @@ export const PolyMesh = defineComponent({
       // Static default slot children (e.g. additional  children)
       const defaultChildren = slots.default?.() ?? [];
 
-      // Shadow leaves go before polygon nodes so they sit below casters in
-      // DOM order — painter-order tie-breaking favors earlier nodes when both
-      // are coplanar in 3D.
+      // Shadow elements go before polygon nodes so they sit below casters
+      // in DOM order — painter-order tie-breaking favors earlier nodes when
+      // both are coplanar in 3D. Dynamic mode emits per-polygon ; baked
+      // mode emits a single  per mesh (see shadowSvg above).
       const shadows = shadowNodes.value;
+      const svgNode = shadowSvg.value;
+      const shadowChildren: VNode[] = svgNode ? [svgNode] : [];
 
       return h(
         "div",
@@ -724,7 +799,7 @@ export const PolyMesh = defineComponent({
           ...handlers,
           ...extraAttrs,
         },
-        [...shadows, ...polyNodes, ...defaultChildren]
+        [...shadowChildren, ...shadows, ...polyNodes, ...defaultChildren]
       );
     };
   },

From f9ac11b5832ad0c824c05667abbfd2a24a1db44c Mon Sep 17 00:00:00 2001
From: Juan Cruz Fortunatti 
Date: Sat, 23 May 2026 04:48:38 +0200
Subject: [PATCH 10/28] feat(website): clamp Lighting Elev. slider minimum to 0

---
 website/src/components/Dock/folders/useLightingFolder.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/website/src/components/Dock/folders/useLightingFolder.ts b/website/src/components/Dock/folders/useLightingFolder.ts
index c822c88c..ca30dabf 100644
--- a/website/src/components/Dock/folders/useLightingFolder.ts
+++ b/website/src/components/Dock/folders/useLightingFolder.ts
@@ -56,7 +56,7 @@ export function useLightingFolder(parent: GUI | null, inputs: LightingFolderInpu
   useSlider(folder, "Azimuth", { min: 0, max: 360, step: 1 }, lightAzimuth, (value) =>
     onUpdateScene({ lightAzimuth: value }),
   );
-  useSlider(folder, "Elev.", { min: -90, max: 90, step: 1 }, lightElevation, (value) =>
+  useSlider(folder, "Elev.", { min: 0, max: 90, step: 1 }, lightElevation, (value) =>
     onUpdateScene({ lightElevation: value }),
   );
   useSlider(folder, "Key", { min: 0, max: 2, step: 0.05 }, lightIntensity, (value) =>

From f4101474f57f178774b72f1c15ed4a8fa9ce07b0 Mon Sep 17 00:00:00 2001
From: Juan Cruz Fortunatti 
Date: Sat, 23 May 2026 05:24:26 +0200
Subject: [PATCH 11/28] fix(polycss): emit shadows for all rendered polys, not
 just camera-visible

---
 packages/polycss/src/api/createPolyScene.ts | 10 ++++++++--
 1 file changed, 8 insertions(+), 2 deletions(-)

diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts
index 5752ab3d..085ce975 100644
--- a/packages/polycss/src/api/createPolyScene.ts
+++ b/packages/polycss/src/api/createPolyScene.ts
@@ -1220,7 +1220,10 @@ export function createPolyScene(
 
     const shadowColorCss = `rgba(${r},${g},${b},${shadowOpacity})`;
     const fragment = doc.createDocumentFragment();
-    for (const item of renderedItemsForCamera(entry)) {
+    // Iterate all rendered polys (not camera-filtered) — a polygon on the
+    // lit side of the mesh that's currently camera-culled still casts a
+    // valid shadow on the ground.
+    for (const item of entry.rendered) {
       // Atlas () polygons cast shadows too — the shadow only needs
       // the polygon's OUTLINE (border-shape) and a flat dark color, not
       // the texture content. So fully textured meshes like the Frog Guy
@@ -1290,7 +1293,10 @@ export function createPolyScene(
   ): void {
     const polyProjections: Array> = [];
     let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
-    for (const item of renderedItemsForCamera(entry)) {
+    // Iterate all rendered polys (not camera-filtered) — a casting polygon
+    // hidden from the camera can still project a visible shadow onto the
+    // ground. The light-facing filter below does the real culling.
+    for (const item of entry.rendered) {
       if (dedupDrop.has(item.polygonIndex)) continue;
       const plan = item.plan;
       if (!plan) continue;

From 0fb2166f6d7821431bd9ed0aab02aa23403e6d95 Mon Sep 17 00:00:00 2001
From: Juan Cruz Fortunatti 
Date: Sat, 23 May 2026 05:30:29 +0200
Subject: [PATCH 12/28] feat: merge baked shadows into single convex-hull
 silhouette per mesh

---
 packages/core/src/index.ts                    |  1 +
 packages/core/src/shadow/projection.test.ts   | 51 ++++++++++++++++
 packages/core/src/shadow/projection.ts        | 50 ++++++++++++++++
 .../polycss/src/api/createPolyScene.test.ts   | 18 +++---
 packages/polycss/src/api/createPolyScene.ts   | 46 ++++++++-------
 .../src/scene/PolyMesh.castShadow.test.tsx    | 13 ++--
 packages/react/src/scene/PolyMesh.tsx         | 53 +++++++++--------
 .../vue/src/scene/PolyMesh.castShadow.test.ts | 14 ++---
 packages/vue/src/scene/PolyMesh.ts            | 59 +++++++++----------
 9 files changed, 205 insertions(+), 100 deletions(-)

diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 6036beda..0b68e3de 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -146,6 +146,7 @@ export {
   BAKED_SHADOW_MIN_UP,
   BAKED_SHADOW_Z_SQUASH,
   buildBakedShadowProjectionMatrix,
+  convexHull2D,
   isBakedShadowCaster,
   projectCssVertexToGround,
 } from "./shadow/projection";
diff --git a/packages/core/src/shadow/projection.test.ts b/packages/core/src/shadow/projection.test.ts
index b9ea887a..aad154f9 100644
--- a/packages/core/src/shadow/projection.test.ts
+++ b/packages/core/src/shadow/projection.test.ts
@@ -3,6 +3,7 @@ import {
   BAKED_SHADOW_MIN_UP,
   BAKED_SHADOW_Z_SQUASH,
   buildBakedShadowProjectionMatrix,
+  convexHull2D,
   isBakedShadowCaster,
   projectCssVertexToGround,
 } from "./projection";
@@ -118,3 +119,53 @@ describe("projectCssVertexToGround", () => {
     expect(Math.abs(x - 100)).toBeGreaterThan(100);
   });
 });
+
+describe("convexHull2D", () => {
+  it("returns input unchanged for ≤ 1 points", () => {
+    expect(convexHull2D([])).toEqual([]);
+    expect(convexHull2D([[5, 7]])).toEqual([[5, 7]]);
+  });
+
+  it("returns the unit square's 4 corners for a dense interior cloud", () => {
+    const pts: Array<[number, number]> = [
+      [0, 0], [1, 0], [1, 1], [0, 1],
+      [0.5, 0.5], [0.25, 0.75], [0.8, 0.2], [0.3, 0.3],
+    ];
+    const hull = convexHull2D(pts);
+    expect(hull.length).toBe(4);
+    // Must contain all 4 corners (in some CCW rotation).
+    const set = new Set(hull.map((p) => p.join(",")));
+    expect(set.has("0,0")).toBe(true);
+    expect(set.has("1,0")).toBe(true);
+    expect(set.has("1,1")).toBe(true);
+    expect(set.has("0,1")).toBe(true);
+  });
+
+  it("drops collinear-edge points", () => {
+    // Square with an extra collinear point on the bottom edge. The hull
+    // should still be the 4 corners.
+    const hull = convexHull2D([[0, 0], [0.5, 0], [1, 0], [1, 1], [0, 1]]);
+    expect(hull.length).toBe(4);
+    const set = new Set(hull.map((p) => p.join(",")));
+    expect(set.has("0.5,0")).toBe(false);
+  });
+
+  it("handles a tilted parallelogram", () => {
+    // The convex hull of a sheared square (typical baked-shadow output)
+    // should still be 4 corners.
+    const hull = convexHull2D([[0, 0], [10, 5], [12, 9], [2, 4]]);
+    expect(hull.length).toBe(4);
+  });
+
+  it("returns the vertices CCW", () => {
+    const hull = convexHull2D([[0, 0], [1, 0], [1, 1], [0, 1]]);
+    // Sum of signed cross products of consecutive edges → positive for CCW.
+    let signedArea = 0;
+    for (let i = 0; i < hull.length; i++) {
+      const a = hull[i]!;
+      const b = hull[(i + 1) % hull.length]!;
+      signedArea += a[0] * b[1] - b[0] * a[1];
+    }
+    expect(signedArea).toBeGreaterThan(0);
+  });
+});
diff --git a/packages/core/src/shadow/projection.ts b/packages/core/src/shadow/projection.ts
index ed0fae56..f844d05c 100644
--- a/packages/core/src/shadow/projection.ts
+++ b/packages/core/src/shadow/projection.ts
@@ -82,6 +82,56 @@ export function isBakedShadowCaster(
   return normal[0] * lx + normal[1] * ly + normal[2] * lz > 0;
 }
 
+/**
+ * Computes the 2D convex hull of a set of points using Andrew's monotone
+ * chain (O(n log n)). Returns the hull vertices in counter-clockwise order
+ * with no repeated endpoints. Used to merge a mesh's per-polygon shadow
+ * projections into one silhouette outline — convex meshes (cubes, spheres,
+ * low-poly hero shapes) collapse cleanly; concave shapes over-approximate.
+ *
+ * Collinear points on the hull edge are dropped. Input may contain
+ * duplicates and is left unmodified.
+ */
+export function convexHull2D(
+  points: ReadonlyArray,
+): Array<[number, number]> {
+  const n = points.length;
+  if (n <= 1) return points.map((p) => [p[0], p[1]]);
+  // Stable sort by x then y so collinear-on-edge points sort consistently.
+  const sorted = points.map((p) => [p[0], p[1]] as [number, number]);
+  sorted.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
+  const cross = (
+    o: [number, number],
+    a: [number, number],
+    b: [number, number],
+  ): number => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
+  const lower: Array<[number, number]> = [];
+  for (const p of sorted) {
+    while (
+      lower.length >= 2 &&
+      cross(lower[lower.length - 2]!, lower[lower.length - 1]!, p) <= 0
+    ) {
+      lower.pop();
+    }
+    lower.push(p);
+  }
+  const upper: Array<[number, number]> = [];
+  for (let i = sorted.length - 1; i >= 0; i--) {
+    const p = sorted[i]!;
+    while (
+      upper.length >= 2 &&
+      cross(upper[upper.length - 2]!, upper[upper.length - 1]!, p) <= 0
+    ) {
+      upper.pop();
+    }
+    upper.push(p);
+  }
+  // Drop the last point of each chain (it's the first of the other).
+  lower.pop();
+  upper.pop();
+  return lower.concat(upper);
+}
+
 /**
  * Projects a single CSS-3D vertex onto the shadow ground plane, returning
  * the resulting 2D point in CSS coordinates. Mirrors the per-element
diff --git a/packages/polycss/src/api/createPolyScene.test.ts b/packages/polycss/src/api/createPolyScene.test.ts
index 8feb9259..a6e1a2c4 100644
--- a/packages/polycss/src/api/createPolyScene.test.ts
+++ b/packages/polycss/src/api/createPolyScene.test.ts
@@ -1890,10 +1890,10 @@ describe("createPolyScene", () => {
       expect(host.querySelectorAll(".polycss-shadow").length).toBe(2);
     });
 
-    it("castShadow:true in baked mode emits a single  shadow per mesh containing one  per caster polygon", () => {
-      // Baked mode builds a per-mesh  with CPU-projected outlines so
-      // overlapping leaves composite as one silhouette (no alpha stacking).
-      // The default light has +Z so the +Z-facing triangle is a caster.
+    it("castShadow:true in baked mode emits a single  shadow per mesh with one merged silhouette ", () => {
+      // Baked mode builds a per-mesh  with the convex hull of every
+      // caster polygon's projected vertices — one merged silhouette
+      // instead of overlapping rectangles.
       scene = makeScene(host, { textureLighting: "baked" });
       scene.add(makeParseResult([triangle()]), { castShadow: true });
       const shadows = host.querySelectorAll(".polycss-shadow");
@@ -1904,12 +1904,10 @@ describe("createPolyScene", () => {
       // SVG positioning is a translate3d at the ground plane (no var(--shadow-proj)).
       expect(shadow.style.transform).toMatch(/^translate3d\(/);
       expect(shadow.style.transform).not.toContain("var(--shadow-proj)");
-      // One path per caster polygon, grouped under a  so
-      // overlapping outlines collapse to one silhouette before alpha applies.
-      const group = shadow.querySelector("g");
-      expect(group).not.toBeNull();
-      expect(group!.getAttribute("opacity")).toBe("0.2500");
-      expect(group!.querySelectorAll("path").length).toBe(1);
+      // Single merged silhouette path, opacity baked into the path itself.
+      const paths = shadow.querySelectorAll("path");
+      expect(paths.length).toBe(1);
+      expect(paths[0]!.getAttribute("opacity")).toBe("0.2500");
     });
 
     it("baked mode skips shadow leaves for polygons facing away from the light", () => {
diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts
index 085ce975..79b96e44 100644
--- a/packages/polycss/src/api/createPolyScene.ts
+++ b/packages/polycss/src/api/createPolyScene.ts
@@ -42,6 +42,7 @@ import {
   cameraCullNormalKey,
   cameraCullVisibleSignature,
   computeSceneBbox,
+  convexHull2D,
   findOverlappingPolygonDuplicates,
   inverseRotateVec3,
   isAxisAlignedSurfaceNormal,
@@ -1291,8 +1292,12 @@ export function createPolyScene(
     b: number,
     opacity: number,
   ): void {
-    const polyProjections: Array> = [];
-    let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+    // Project every light-facing caster polygon's vertices to the ground,
+    // collected into one flat point cloud. The convex hull of that cloud
+    // is the mesh's silhouette outline — one merged shape instead of
+    // many overlapping per-polygon rectangles. Convex meshes (cubes,
+    // hero shapes) collapse cleanly; concave meshes over-approximate.
+    const points: Array<[number, number]> = [];
     // Iterate all rendered polys (not camera-filtered) — a casting polygon
     // hidden from the camera can still project a visible shadow onto the
     // ground. The light-facing filter below does the real culling.
@@ -1304,7 +1309,6 @@ export function createPolyScene(
       const polygon = entry.polygons[item.polygonIndex];
       if (!polygon) continue;
 
-      const projected: Array<[number, number]> = [];
       for (const v of polygon.vertices) {
         // World → CSS-3D: swap X and Y, scale by BASE_TILE. Matches the
         // axis convention used by plan.matrix / --shadow-proj so the
@@ -1314,17 +1318,21 @@ export function createPolyScene(
           v[0] * DEFAULT_TILE,
           v[2] * DEFAULT_TILE,
         ];
-        const p = projectCssVertexToGround(cssVertex, lightDir, groundCssZ);
-        projected.push(p);
-        if (p[0] < minX) minX = p[0];
-        if (p[1] < minY) minY = p[1];
-        if (p[0] > maxX) maxX = p[0];
-        if (p[1] > maxY) maxY = p[1];
+        points.push(projectCssVertexToGround(cssVertex, lightDir, groundCssZ));
       }
-      polyProjections.push(projected);
     }
 
-    if (polyProjections.length === 0) return;
+    if (points.length < 3) return;
+    const hull = convexHull2D(points);
+    if (hull.length < 3) return;
+
+    let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+    for (const [x, y] of hull) {
+      if (x < minX) minX = x;
+      if (y < minY) minY = y;
+      if (x > maxX) maxX = x;
+      if (y > maxY) maxY = y;
+    }
     const width = maxX - minX;
     const height = maxY - minY;
     if (!(width > 0) || !(height > 0)) return;
@@ -1337,7 +1345,7 @@ export function createPolyScene(
     svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
     // CSS-Z places the SVG plane at the ground in the mesh's local frame;
     // the mesh wrapper's own transform is applied above this. X/Y origin
-    // shifts the SVG so its (0,0) lines up with the projected bbox corner.
+    // shifts the SVG so its (0,0) lines up with the silhouette bbox corner.
     svg.setAttribute(
       "style",
       `position:absolute;top:0;left:0;display:block;overflow:visible;` +
@@ -1345,15 +1353,11 @@ export function createPolyScene(
       `transform:translate3d(${minX.toFixed(3)}px,${minY.toFixed(3)}px,${groundCssZ.toFixed(3)}px)`,
     );
 
-    const group = doc.createElementNS(svgNS, "g");
-    group.setAttribute("fill", `rgb(${r},${g},${b})`);
-    group.setAttribute("opacity", opacity.toFixed(4));
-    for (const verts of polyProjections) {
-      const path = doc.createElementNS(svgNS, "path");
-      path.setAttribute("d", pointsToSvgPath(verts, minX, minY));
-      group.appendChild(path);
-    }
-    svg.appendChild(group);
+    const path = doc.createElementNS(svgNS, "path");
+    path.setAttribute("d", pointsToSvgPath(hull, minX, minY));
+    path.setAttribute("fill", `rgb(${r},${g},${b})`);
+    path.setAttribute("opacity", opacity.toFixed(4));
+    svg.appendChild(path);
 
     entry.shadowSvg = svg;
     const firstChild = entry.wrapper.firstChild;
diff --git a/packages/react/src/scene/PolyMesh.castShadow.test.tsx b/packages/react/src/scene/PolyMesh.castShadow.test.tsx
index 28be34d6..4d7ffb48 100644
--- a/packages/react/src/scene/PolyMesh.castShadow.test.tsx
+++ b/packages/react/src/scene/PolyMesh.castShadow.test.tsx
@@ -115,9 +115,9 @@ describe("PolyMesh — castShadow", () => {
     expect(container.querySelectorAll(".polycss-shadow").length).toBe(2);
   });
 
-  it("castShadow in baked mode emits a single  shadow per mesh containing one  per caster polygon", () => {
-    // Baked mode builds a per-mesh  with CPU-projected outlines so
-    // overlapping leaves composite as one silhouette (no alpha stacking).
+  it("castShadow in baked mode emits a single  shadow per mesh with one merged silhouette ", () => {
+    // Baked mode builds a per-mesh  with the convex hull of every
+    // caster polygon's projected vertices — one merged silhouette.
     // The default light has positive Z, so the +Z-facing triangle is a caster.
     const { container } = renderScene(
       { textureLighting: "baked" },
@@ -130,10 +130,9 @@ describe("PolyMesh — castShadow", () => {
     expect(shadow.classList.contains("polycss-shadow-svg")).toBe(true);
     expect(shadow.style.transform).toMatch(/^translate3d\(/);
     expect(shadow.style.transform).not.toContain("var(--shadow-proj)");
-    const group = shadow.querySelector("g");
-    expect(group).not.toBeNull();
-    expect(group!.getAttribute("opacity")).toBe("0.2500");
-    expect(group!.querySelectorAll("path").length).toBe(1);
+    const paths = shadow.querySelectorAll("path");
+    expect(paths.length).toBe(1);
+    expect(paths[0]!.getAttribute("opacity")).toBe("0.2500");
   });
 
   it("shadow leaves are  elements", () => {
diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx
index 91c89dcf..a584bc28 100644
--- a/packages/react/src/scene/PolyMesh.tsx
+++ b/packages/react/src/scene/PolyMesh.tsx
@@ -34,6 +34,7 @@ import type {
 import {
   BASE_TILE,
   computeSceneBbox,
+  convexHull2D,
   DEFAULT_SEAM_BLEED,
   findOverlappingPolygonDuplicates,
   inverseRotateVec3,
@@ -664,7 +665,12 @@ export const PolyMesh = forwardRef(function PolyM
     return leaves;
   }, [castShadow, effectiveTextureLighting, renderPolygon, polygons, atlasPlans, sceneCtx?.shadow]);
 
-  // Baked-mode SVG shadow: single per-mesh element.
+  // Baked-mode SVG shadow: single per-mesh  with one merged
+  // silhouette  (convex hull of every caster polygon's projected
+  // vertices). Avoids the alpha-stacking that overlapping per-polygon
+  // paths would otherwise produce; over-approximates concave meshes
+  // (silhouette fills internal cavities) but is correct for the common
+  // convex/low-poly hero case.
   const sceneShadow = sceneCtx?.shadow;
   const shadowSvgNode = useMemo(() => {
     if (!castShadow || renderPolygon) return null;
@@ -679,31 +685,33 @@ export const PolyMesh = forwardRef(function PolyM
       overlapFraction: 0.4,
     });
 
-    const projections: Array> = [];
-    let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+    const points: Array<[number, number]> = [];
     for (let i = 0; i < polygons.length; i++) {
       const polygon = polygons[i]!;
       if (shadowDedupDrop.has(i)) continue;
       const plan = atlasPlans[i];
       if (!plan) continue;
       if (!isBakedShadowCaster(plan.normal, lightDir)) continue;
-      const projected: Array<[number, number]> = [];
       for (const v of polygon.vertices) {
         const cssVertex: Vec3 = [
           v[1] * BASE_TILE,
           v[0] * BASE_TILE,
           v[2] * BASE_TILE,
         ];
-        const p = projectCssVertexToGround(cssVertex, lightDir, bakedShadowGroundCssZ);
-        projected.push(p);
-        if (p[0] < minX) minX = p[0];
-        if (p[1] < minY) minY = p[1];
-        if (p[0] > maxX) maxX = p[0];
-        if (p[1] > maxY) maxY = p[1];
+        points.push(projectCssVertexToGround(cssVertex, lightDir, bakedShadowGroundCssZ));
       }
-      projections.push(projected);
     }
-    if (projections.length === 0) return null;
+    if (points.length < 3) return null;
+    const hull = convexHull2D(points);
+    if (hull.length < 3) return null;
+
+    let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+    for (const [x, y] of hull) {
+      if (x < minX) minX = x;
+      if (y < minY) minY = y;
+      if (x > maxX) maxX = x;
+      if (y > maxY) maxY = y;
+    }
     const width = maxX - minX;
     const height = maxY - minY;
     if (!(width > 0) || !(height > 0)) return null;
@@ -712,14 +720,11 @@ export const PolyMesh = forwardRef(function PolyM
     const shadowOpacity = sceneShadow?.opacity ?? 0.25;
     const parsed = parseHexColor(shadowColor)?.rgb ?? [0, 0, 0];
 
-    const paths = projections.map((verts, idx) => {
-      let d = `M${(verts[0]![0] - minX).toFixed(3)},${(verts[0]![1] - minY).toFixed(3)}`;
-      for (let j = 1; j < verts.length; j++) {
-        d += `L${(verts[j]![0] - minX).toFixed(3)},${(verts[j]![1] - minY).toFixed(3)}`;
-      }
-      d += "Z";
-      return ;
-    });
+    let d = `M${(hull[0]![0] - minX).toFixed(3)},${(hull[0]![1] - minY).toFixed(3)}`;
+    for (let j = 1; j < hull.length; j++) {
+      d += `L${(hull[j]![0] - minX).toFixed(3)},${(hull[j]![1] - minY).toFixed(3)}`;
+    }
+    d += "Z";
 
     return (
       (function PolyM
           transform: `translate3d(${minX.toFixed(3)}px,${minY.toFixed(3)}px,${bakedShadowGroundCssZ.toFixed(3)}px)`,
         }}
       >
-        
-          {paths}
-        
+        
       
     );
   }, [castShadow, renderPolygon, effectiveTextureLighting, polygons, atlasPlans, sceneDirectionalLight, bakedShadowGroundCssZ, sceneShadow]);
diff --git a/packages/vue/src/scene/PolyMesh.castShadow.test.ts b/packages/vue/src/scene/PolyMesh.castShadow.test.ts
index e158062f..ce5c1a72 100644
--- a/packages/vue/src/scene/PolyMesh.castShadow.test.ts
+++ b/packages/vue/src/scene/PolyMesh.castShadow.test.ts
@@ -94,10 +94,9 @@ describe("PolyMesh (Vue) — castShadow", () => {
     expect(container.querySelectorAll(".polycss-shadow").length).toBe(2);
   });
 
-  it("castShadow:true in baked mode emits a single  shadow per mesh containing one  per caster polygon", async () => {
-    // Baked mode builds a per-mesh  with CPU-projected outlines so
-    // overlapping leaves composite as one silhouette (no alpha stacking).
-    // The default light has positive Z so the +Z-facing triangle is a caster.
+  it("castShadow:true in baked mode emits a single  shadow per mesh with one merged silhouette ", async () => {
+    // Baked mode builds a per-mesh  with the convex hull of every
+    // caster polygon's projected vertices — one merged silhouette.
     // nextTick lets the scene's watchEffect derive groundCssZ from the
     // child's registration before the shadow nodes recompute.
     const { container } = mount(
@@ -113,10 +112,9 @@ describe("PolyMesh (Vue) — castShadow", () => {
     expect(shadow.classList.contains("polycss-shadow-svg")).toBe(true);
     expect(shadow.style.transform).toMatch(/^translate3d\(/);
     expect(shadow.style.transform).not.toContain("var(--shadow-proj)");
-    const group = shadow.querySelector("g");
-    expect(group).not.toBeNull();
-    expect(group!.getAttribute("opacity")).toBe("0.2500");
-    expect(group!.querySelectorAll("path").length).toBe(1);
+    const paths = shadow.querySelectorAll("path");
+    expect(paths.length).toBe(1);
+    expect(paths[0]!.getAttribute("opacity")).toBe("0.2500");
   });
 
   it("shadow leaves are always  with class polycss-shadow", () => {
diff --git a/packages/vue/src/scene/PolyMesh.ts b/packages/vue/src/scene/PolyMesh.ts
index bfacd2ef..4d7d2a36 100644
--- a/packages/vue/src/scene/PolyMesh.ts
+++ b/packages/vue/src/scene/PolyMesh.ts
@@ -22,6 +22,7 @@ import type { MeshResolution, Polygon, PolyTextureLightingMode, Vec3 } from "@la
 import {
   BASE_TILE,
   computeSceneBbox,
+  convexHull2D,
   DEFAULT_SEAM_BLEED,
   inverseRotateVec3,
   findOverlappingPolygonDuplicates,
@@ -360,10 +361,10 @@ export const PolyMesh = defineComponent({
       });
     });
 
-    // Baked-mode SVG shadow — single per-mesh  with one  per
-    // caster polygon. Overlapping outlines composite as one silhouette
-    // inside the  before alpha is applied, sidestepping
-    // the `opacity + preserve-3d` flatten trap.
+    // Baked-mode SVG shadow — single per-mesh  with one merged
+    // silhouette  (convex hull of every caster polygon's projected
+    // vertices). Correct for convex/low-poly meshes; over-approximates
+    // concave ones by filling internal cavities.
     const shadowSvg = computed(() => {
       if (!props.castShadow) return null;
       if (atlasTextureLighting.value === "dynamic") return null;
@@ -379,8 +380,7 @@ export const PolyMesh = defineComponent({
         overlapFraction: 0.4,
       });
 
-      const projections: Array> = [];
-      let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+      const points: Array<[number, number]> = [];
       const polys = polygons.value;
       const plans = textureAtlasPlans.value;
       for (let i = 0; i < polys.length; i++) {
@@ -389,23 +389,26 @@ export const PolyMesh = defineComponent({
         if (!plan) continue;
         if (!isBakedShadowCaster(plan.normal, lightDir)) continue;
         const polygon = polys[i]!;
-        const projected: Array<[number, number]> = [];
         for (const v of polygon.vertices) {
           const cssVertex: Vec3 = [
             v[1] * BASE_TILE,
             v[0] * BASE_TILE,
             v[2] * BASE_TILE,
           ];
-          const p = projectCssVertexToGround(cssVertex, lightDir, groundCssZ);
-          projected.push(p);
-          if (p[0] < minX) minX = p[0];
-          if (p[1] < minY) minY = p[1];
-          if (p[0] > maxX) maxX = p[0];
-          if (p[1] > maxY) maxY = p[1];
+          points.push(projectCssVertexToGround(cssVertex, lightDir, groundCssZ));
         }
-        projections.push(projected);
       }
-      if (projections.length === 0) return null;
+      if (points.length < 3) return null;
+      const hull = convexHull2D(points);
+      if (hull.length < 3) return null;
+
+      let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+      for (const [x, y] of hull) {
+        if (x < minX) minX = x;
+        if (y < minY) minY = y;
+        if (x > maxX) maxX = x;
+        if (y > maxY) maxY = y;
+      }
       const width = maxX - minX;
       const height = maxY - minY;
       if (!(width > 0) || !(height > 0)) return null;
@@ -415,14 +418,11 @@ export const PolyMesh = defineComponent({
       const shadowOpacity = shadowOpts?.opacity ?? 0.25;
       const parsed = parseHexColor(shadowColor)?.rgb ?? [0, 0, 0];
 
-      const pathNodes = projections.map((verts, idx) => {
-        let d = `M${(verts[0]![0] - minX).toFixed(3)},${(verts[0]![1] - minY).toFixed(3)}`;
-        for (let j = 1; j < verts.length; j++) {
-          d += `L${(verts[j]![0] - minX).toFixed(3)},${(verts[j]![1] - minY).toFixed(3)}`;
-        }
-        d += "Z";
-        return h("path", { key: idx, d });
-      });
+      let d = `M${(hull[0]![0] - minX).toFixed(3)},${(hull[0]![1] - minY).toFixed(3)}`;
+      for (let j = 1; j < hull.length; j++) {
+        d += `L${(hull[j]![0] - minX).toFixed(3)},${(hull[j]![1] - minY).toFixed(3)}`;
+      }
+      d += "Z";
 
       return h(
         "svg",
@@ -443,14 +443,11 @@ export const PolyMesh = defineComponent({
           } as CSSProperties,
         },
         [
-          h(
-            "g",
-            {
-              fill: `rgb(${parsed[0]},${parsed[1]},${parsed[2]})`,
-              opacity: shadowOpacity.toFixed(4),
-            },
-            pathNodes,
-          ),
+          h("path", {
+            d,
+            fill: `rgb(${parsed[0]},${parsed[1]},${parsed[2]})`,
+            opacity: shadowOpacity.toFixed(4),
+          }),
         ],
       );
     });

From 70c7712b0ae0f29c7ec6d6d68680bd6151af715e Mon Sep 17 00:00:00 2001
From: Juan Cruz Fortunatti 
Date: Sat, 23 May 2026 15:02:27 +0200
Subject: [PATCH 13/28] Revert "feat: merge baked shadows into single
 convex-hull silhouette per mesh"

This reverts commit 0fb2166f6d7821431bd9ed0aab02aa23403e6d95.
---
 packages/core/src/index.ts                    |  1 -
 packages/core/src/shadow/projection.test.ts   | 51 ----------------
 packages/core/src/shadow/projection.ts        | 50 ----------------
 .../polycss/src/api/createPolyScene.test.ts   | 18 +++---
 packages/polycss/src/api/createPolyScene.ts   | 46 +++++++--------
 .../src/scene/PolyMesh.castShadow.test.tsx    | 13 ++--
 packages/react/src/scene/PolyMesh.tsx         | 53 ++++++++---------
 .../vue/src/scene/PolyMesh.castShadow.test.ts | 14 +++--
 packages/vue/src/scene/PolyMesh.ts            | 59 ++++++++++---------
 9 files changed, 100 insertions(+), 205 deletions(-)

diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 0b68e3de..6036beda 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -146,7 +146,6 @@ export {
   BAKED_SHADOW_MIN_UP,
   BAKED_SHADOW_Z_SQUASH,
   buildBakedShadowProjectionMatrix,
-  convexHull2D,
   isBakedShadowCaster,
   projectCssVertexToGround,
 } from "./shadow/projection";
diff --git a/packages/core/src/shadow/projection.test.ts b/packages/core/src/shadow/projection.test.ts
index aad154f9..b9ea887a 100644
--- a/packages/core/src/shadow/projection.test.ts
+++ b/packages/core/src/shadow/projection.test.ts
@@ -3,7 +3,6 @@ import {
   BAKED_SHADOW_MIN_UP,
   BAKED_SHADOW_Z_SQUASH,
   buildBakedShadowProjectionMatrix,
-  convexHull2D,
   isBakedShadowCaster,
   projectCssVertexToGround,
 } from "./projection";
@@ -119,53 +118,3 @@ describe("projectCssVertexToGround", () => {
     expect(Math.abs(x - 100)).toBeGreaterThan(100);
   });
 });
-
-describe("convexHull2D", () => {
-  it("returns input unchanged for ≤ 1 points", () => {
-    expect(convexHull2D([])).toEqual([]);
-    expect(convexHull2D([[5, 7]])).toEqual([[5, 7]]);
-  });
-
-  it("returns the unit square's 4 corners for a dense interior cloud", () => {
-    const pts: Array<[number, number]> = [
-      [0, 0], [1, 0], [1, 1], [0, 1],
-      [0.5, 0.5], [0.25, 0.75], [0.8, 0.2], [0.3, 0.3],
-    ];
-    const hull = convexHull2D(pts);
-    expect(hull.length).toBe(4);
-    // Must contain all 4 corners (in some CCW rotation).
-    const set = new Set(hull.map((p) => p.join(",")));
-    expect(set.has("0,0")).toBe(true);
-    expect(set.has("1,0")).toBe(true);
-    expect(set.has("1,1")).toBe(true);
-    expect(set.has("0,1")).toBe(true);
-  });
-
-  it("drops collinear-edge points", () => {
-    // Square with an extra collinear point on the bottom edge. The hull
-    // should still be the 4 corners.
-    const hull = convexHull2D([[0, 0], [0.5, 0], [1, 0], [1, 1], [0, 1]]);
-    expect(hull.length).toBe(4);
-    const set = new Set(hull.map((p) => p.join(",")));
-    expect(set.has("0.5,0")).toBe(false);
-  });
-
-  it("handles a tilted parallelogram", () => {
-    // The convex hull of a sheared square (typical baked-shadow output)
-    // should still be 4 corners.
-    const hull = convexHull2D([[0, 0], [10, 5], [12, 9], [2, 4]]);
-    expect(hull.length).toBe(4);
-  });
-
-  it("returns the vertices CCW", () => {
-    const hull = convexHull2D([[0, 0], [1, 0], [1, 1], [0, 1]]);
-    // Sum of signed cross products of consecutive edges → positive for CCW.
-    let signedArea = 0;
-    for (let i = 0; i < hull.length; i++) {
-      const a = hull[i]!;
-      const b = hull[(i + 1) % hull.length]!;
-      signedArea += a[0] * b[1] - b[0] * a[1];
-    }
-    expect(signedArea).toBeGreaterThan(0);
-  });
-});
diff --git a/packages/core/src/shadow/projection.ts b/packages/core/src/shadow/projection.ts
index f844d05c..ed0fae56 100644
--- a/packages/core/src/shadow/projection.ts
+++ b/packages/core/src/shadow/projection.ts
@@ -82,56 +82,6 @@ export function isBakedShadowCaster(
   return normal[0] * lx + normal[1] * ly + normal[2] * lz > 0;
 }
 
-/**
- * Computes the 2D convex hull of a set of points using Andrew's monotone
- * chain (O(n log n)). Returns the hull vertices in counter-clockwise order
- * with no repeated endpoints. Used to merge a mesh's per-polygon shadow
- * projections into one silhouette outline — convex meshes (cubes, spheres,
- * low-poly hero shapes) collapse cleanly; concave shapes over-approximate.
- *
- * Collinear points on the hull edge are dropped. Input may contain
- * duplicates and is left unmodified.
- */
-export function convexHull2D(
-  points: ReadonlyArray,
-): Array<[number, number]> {
-  const n = points.length;
-  if (n <= 1) return points.map((p) => [p[0], p[1]]);
-  // Stable sort by x then y so collinear-on-edge points sort consistently.
-  const sorted = points.map((p) => [p[0], p[1]] as [number, number]);
-  sorted.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
-  const cross = (
-    o: [number, number],
-    a: [number, number],
-    b: [number, number],
-  ): number => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
-  const lower: Array<[number, number]> = [];
-  for (const p of sorted) {
-    while (
-      lower.length >= 2 &&
-      cross(lower[lower.length - 2]!, lower[lower.length - 1]!, p) <= 0
-    ) {
-      lower.pop();
-    }
-    lower.push(p);
-  }
-  const upper: Array<[number, number]> = [];
-  for (let i = sorted.length - 1; i >= 0; i--) {
-    const p = sorted[i]!;
-    while (
-      upper.length >= 2 &&
-      cross(upper[upper.length - 2]!, upper[upper.length - 1]!, p) <= 0
-    ) {
-      upper.pop();
-    }
-    upper.push(p);
-  }
-  // Drop the last point of each chain (it's the first of the other).
-  lower.pop();
-  upper.pop();
-  return lower.concat(upper);
-}
-
 /**
  * Projects a single CSS-3D vertex onto the shadow ground plane, returning
  * the resulting 2D point in CSS coordinates. Mirrors the per-element
diff --git a/packages/polycss/src/api/createPolyScene.test.ts b/packages/polycss/src/api/createPolyScene.test.ts
index a6e1a2c4..8feb9259 100644
--- a/packages/polycss/src/api/createPolyScene.test.ts
+++ b/packages/polycss/src/api/createPolyScene.test.ts
@@ -1890,10 +1890,10 @@ describe("createPolyScene", () => {
       expect(host.querySelectorAll(".polycss-shadow").length).toBe(2);
     });
 
-    it("castShadow:true in baked mode emits a single  shadow per mesh with one merged silhouette ", () => {
-      // Baked mode builds a per-mesh  with the convex hull of every
-      // caster polygon's projected vertices — one merged silhouette
-      // instead of overlapping rectangles.
+    it("castShadow:true in baked mode emits a single  shadow per mesh containing one  per caster polygon", () => {
+      // Baked mode builds a per-mesh  with CPU-projected outlines so
+      // overlapping leaves composite as one silhouette (no alpha stacking).
+      // The default light has +Z so the +Z-facing triangle is a caster.
       scene = makeScene(host, { textureLighting: "baked" });
       scene.add(makeParseResult([triangle()]), { castShadow: true });
       const shadows = host.querySelectorAll(".polycss-shadow");
@@ -1904,10 +1904,12 @@ describe("createPolyScene", () => {
       // SVG positioning is a translate3d at the ground plane (no var(--shadow-proj)).
       expect(shadow.style.transform).toMatch(/^translate3d\(/);
       expect(shadow.style.transform).not.toContain("var(--shadow-proj)");
-      // Single merged silhouette path, opacity baked into the path itself.
-      const paths = shadow.querySelectorAll("path");
-      expect(paths.length).toBe(1);
-      expect(paths[0]!.getAttribute("opacity")).toBe("0.2500");
+      // One path per caster polygon, grouped under a  so
+      // overlapping outlines collapse to one silhouette before alpha applies.
+      const group = shadow.querySelector("g");
+      expect(group).not.toBeNull();
+      expect(group!.getAttribute("opacity")).toBe("0.2500");
+      expect(group!.querySelectorAll("path").length).toBe(1);
     });
 
     it("baked mode skips shadow leaves for polygons facing away from the light", () => {
diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts
index 79b96e44..085ce975 100644
--- a/packages/polycss/src/api/createPolyScene.ts
+++ b/packages/polycss/src/api/createPolyScene.ts
@@ -42,7 +42,6 @@ import {
   cameraCullNormalKey,
   cameraCullVisibleSignature,
   computeSceneBbox,
-  convexHull2D,
   findOverlappingPolygonDuplicates,
   inverseRotateVec3,
   isAxisAlignedSurfaceNormal,
@@ -1292,12 +1291,8 @@ export function createPolyScene(
     b: number,
     opacity: number,
   ): void {
-    // Project every light-facing caster polygon's vertices to the ground,
-    // collected into one flat point cloud. The convex hull of that cloud
-    // is the mesh's silhouette outline — one merged shape instead of
-    // many overlapping per-polygon rectangles. Convex meshes (cubes,
-    // hero shapes) collapse cleanly; concave meshes over-approximate.
-    const points: Array<[number, number]> = [];
+    const polyProjections: Array> = [];
+    let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
     // Iterate all rendered polys (not camera-filtered) — a casting polygon
     // hidden from the camera can still project a visible shadow onto the
     // ground. The light-facing filter below does the real culling.
@@ -1309,6 +1304,7 @@ export function createPolyScene(
       const polygon = entry.polygons[item.polygonIndex];
       if (!polygon) continue;
 
+      const projected: Array<[number, number]> = [];
       for (const v of polygon.vertices) {
         // World → CSS-3D: swap X and Y, scale by BASE_TILE. Matches the
         // axis convention used by plan.matrix / --shadow-proj so the
@@ -1318,21 +1314,17 @@ export function createPolyScene(
           v[0] * DEFAULT_TILE,
           v[2] * DEFAULT_TILE,
         ];
-        points.push(projectCssVertexToGround(cssVertex, lightDir, groundCssZ));
+        const p = projectCssVertexToGround(cssVertex, lightDir, groundCssZ);
+        projected.push(p);
+        if (p[0] < minX) minX = p[0];
+        if (p[1] < minY) minY = p[1];
+        if (p[0] > maxX) maxX = p[0];
+        if (p[1] > maxY) maxY = p[1];
       }
+      polyProjections.push(projected);
     }
 
-    if (points.length < 3) return;
-    const hull = convexHull2D(points);
-    if (hull.length < 3) return;
-
-    let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
-    for (const [x, y] of hull) {
-      if (x < minX) minX = x;
-      if (y < minY) minY = y;
-      if (x > maxX) maxX = x;
-      if (y > maxY) maxY = y;
-    }
+    if (polyProjections.length === 0) return;
     const width = maxX - minX;
     const height = maxY - minY;
     if (!(width > 0) || !(height > 0)) return;
@@ -1345,7 +1337,7 @@ export function createPolyScene(
     svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
     // CSS-Z places the SVG plane at the ground in the mesh's local frame;
     // the mesh wrapper's own transform is applied above this. X/Y origin
-    // shifts the SVG so its (0,0) lines up with the silhouette bbox corner.
+    // shifts the SVG so its (0,0) lines up with the projected bbox corner.
     svg.setAttribute(
       "style",
       `position:absolute;top:0;left:0;display:block;overflow:visible;` +
@@ -1353,11 +1345,15 @@ export function createPolyScene(
       `transform:translate3d(${minX.toFixed(3)}px,${minY.toFixed(3)}px,${groundCssZ.toFixed(3)}px)`,
     );
 
-    const path = doc.createElementNS(svgNS, "path");
-    path.setAttribute("d", pointsToSvgPath(hull, minX, minY));
-    path.setAttribute("fill", `rgb(${r},${g},${b})`);
-    path.setAttribute("opacity", opacity.toFixed(4));
-    svg.appendChild(path);
+    const group = doc.createElementNS(svgNS, "g");
+    group.setAttribute("fill", `rgb(${r},${g},${b})`);
+    group.setAttribute("opacity", opacity.toFixed(4));
+    for (const verts of polyProjections) {
+      const path = doc.createElementNS(svgNS, "path");
+      path.setAttribute("d", pointsToSvgPath(verts, minX, minY));
+      group.appendChild(path);
+    }
+    svg.appendChild(group);
 
     entry.shadowSvg = svg;
     const firstChild = entry.wrapper.firstChild;
diff --git a/packages/react/src/scene/PolyMesh.castShadow.test.tsx b/packages/react/src/scene/PolyMesh.castShadow.test.tsx
index 4d7ffb48..28be34d6 100644
--- a/packages/react/src/scene/PolyMesh.castShadow.test.tsx
+++ b/packages/react/src/scene/PolyMesh.castShadow.test.tsx
@@ -115,9 +115,9 @@ describe("PolyMesh — castShadow", () => {
     expect(container.querySelectorAll(".polycss-shadow").length).toBe(2);
   });
 
-  it("castShadow in baked mode emits a single  shadow per mesh with one merged silhouette ", () => {
-    // Baked mode builds a per-mesh  with the convex hull of every
-    // caster polygon's projected vertices — one merged silhouette.
+  it("castShadow in baked mode emits a single  shadow per mesh containing one  per caster polygon", () => {
+    // Baked mode builds a per-mesh  with CPU-projected outlines so
+    // overlapping leaves composite as one silhouette (no alpha stacking).
     // The default light has positive Z, so the +Z-facing triangle is a caster.
     const { container } = renderScene(
       { textureLighting: "baked" },
@@ -130,9 +130,10 @@ describe("PolyMesh — castShadow", () => {
     expect(shadow.classList.contains("polycss-shadow-svg")).toBe(true);
     expect(shadow.style.transform).toMatch(/^translate3d\(/);
     expect(shadow.style.transform).not.toContain("var(--shadow-proj)");
-    const paths = shadow.querySelectorAll("path");
-    expect(paths.length).toBe(1);
-    expect(paths[0]!.getAttribute("opacity")).toBe("0.2500");
+    const group = shadow.querySelector("g");
+    expect(group).not.toBeNull();
+    expect(group!.getAttribute("opacity")).toBe("0.2500");
+    expect(group!.querySelectorAll("path").length).toBe(1);
   });
 
   it("shadow leaves are  elements", () => {
diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx
index a584bc28..91c89dcf 100644
--- a/packages/react/src/scene/PolyMesh.tsx
+++ b/packages/react/src/scene/PolyMesh.tsx
@@ -34,7 +34,6 @@ import type {
 import {
   BASE_TILE,
   computeSceneBbox,
-  convexHull2D,
   DEFAULT_SEAM_BLEED,
   findOverlappingPolygonDuplicates,
   inverseRotateVec3,
@@ -665,12 +664,7 @@ export const PolyMesh = forwardRef(function PolyM
     return leaves;
   }, [castShadow, effectiveTextureLighting, renderPolygon, polygons, atlasPlans, sceneCtx?.shadow]);
 
-  // Baked-mode SVG shadow: single per-mesh  with one merged
-  // silhouette  (convex hull of every caster polygon's projected
-  // vertices). Avoids the alpha-stacking that overlapping per-polygon
-  // paths would otherwise produce; over-approximates concave meshes
-  // (silhouette fills internal cavities) but is correct for the common
-  // convex/low-poly hero case.
+  // Baked-mode SVG shadow: single per-mesh element.
   const sceneShadow = sceneCtx?.shadow;
   const shadowSvgNode = useMemo(() => {
     if (!castShadow || renderPolygon) return null;
@@ -685,33 +679,31 @@ export const PolyMesh = forwardRef(function PolyM
       overlapFraction: 0.4,
     });
 
-    const points: Array<[number, number]> = [];
+    const projections: Array> = [];
+    let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
     for (let i = 0; i < polygons.length; i++) {
       const polygon = polygons[i]!;
       if (shadowDedupDrop.has(i)) continue;
       const plan = atlasPlans[i];
       if (!plan) continue;
       if (!isBakedShadowCaster(plan.normal, lightDir)) continue;
+      const projected: Array<[number, number]> = [];
       for (const v of polygon.vertices) {
         const cssVertex: Vec3 = [
           v[1] * BASE_TILE,
           v[0] * BASE_TILE,
           v[2] * BASE_TILE,
         ];
-        points.push(projectCssVertexToGround(cssVertex, lightDir, bakedShadowGroundCssZ));
+        const p = projectCssVertexToGround(cssVertex, lightDir, bakedShadowGroundCssZ);
+        projected.push(p);
+        if (p[0] < minX) minX = p[0];
+        if (p[1] < minY) minY = p[1];
+        if (p[0] > maxX) maxX = p[0];
+        if (p[1] > maxY) maxY = p[1];
       }
+      projections.push(projected);
     }
-    if (points.length < 3) return null;
-    const hull = convexHull2D(points);
-    if (hull.length < 3) return null;
-
-    let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
-    for (const [x, y] of hull) {
-      if (x < minX) minX = x;
-      if (y < minY) minY = y;
-      if (x > maxX) maxX = x;
-      if (y > maxY) maxY = y;
-    }
+    if (projections.length === 0) return null;
     const width = maxX - minX;
     const height = maxY - minY;
     if (!(width > 0) || !(height > 0)) return null;
@@ -720,11 +712,14 @@ export const PolyMesh = forwardRef(function PolyM
     const shadowOpacity = sceneShadow?.opacity ?? 0.25;
     const parsed = parseHexColor(shadowColor)?.rgb ?? [0, 0, 0];
 
-    let d = `M${(hull[0]![0] - minX).toFixed(3)},${(hull[0]![1] - minY).toFixed(3)}`;
-    for (let j = 1; j < hull.length; j++) {
-      d += `L${(hull[j]![0] - minX).toFixed(3)},${(hull[j]![1] - minY).toFixed(3)}`;
-    }
-    d += "Z";
+    const paths = projections.map((verts, idx) => {
+      let d = `M${(verts[0]![0] - minX).toFixed(3)},${(verts[0]![1] - minY).toFixed(3)}`;
+      for (let j = 1; j < verts.length; j++) {
+        d += `L${(verts[j]![0] - minX).toFixed(3)},${(verts[j]![1] - minY).toFixed(3)}`;
+      }
+      d += "Z";
+      return ;
+    });
 
     return (
       (function PolyM
           transform: `translate3d(${minX.toFixed(3)}px,${minY.toFixed(3)}px,${bakedShadowGroundCssZ.toFixed(3)}px)`,
         }}
       >
-        
+        
+          {paths}
+        
       
     );
   }, [castShadow, renderPolygon, effectiveTextureLighting, polygons, atlasPlans, sceneDirectionalLight, bakedShadowGroundCssZ, sceneShadow]);
diff --git a/packages/vue/src/scene/PolyMesh.castShadow.test.ts b/packages/vue/src/scene/PolyMesh.castShadow.test.ts
index ce5c1a72..e158062f 100644
--- a/packages/vue/src/scene/PolyMesh.castShadow.test.ts
+++ b/packages/vue/src/scene/PolyMesh.castShadow.test.ts
@@ -94,9 +94,10 @@ describe("PolyMesh (Vue) — castShadow", () => {
     expect(container.querySelectorAll(".polycss-shadow").length).toBe(2);
   });
 
-  it("castShadow:true in baked mode emits a single  shadow per mesh with one merged silhouette ", async () => {
-    // Baked mode builds a per-mesh  with the convex hull of every
-    // caster polygon's projected vertices — one merged silhouette.
+  it("castShadow:true in baked mode emits a single  shadow per mesh containing one  per caster polygon", async () => {
+    // Baked mode builds a per-mesh  with CPU-projected outlines so
+    // overlapping leaves composite as one silhouette (no alpha stacking).
+    // The default light has positive Z so the +Z-facing triangle is a caster.
     // nextTick lets the scene's watchEffect derive groundCssZ from the
     // child's registration before the shadow nodes recompute.
     const { container } = mount(
@@ -112,9 +113,10 @@ describe("PolyMesh (Vue) — castShadow", () => {
     expect(shadow.classList.contains("polycss-shadow-svg")).toBe(true);
     expect(shadow.style.transform).toMatch(/^translate3d\(/);
     expect(shadow.style.transform).not.toContain("var(--shadow-proj)");
-    const paths = shadow.querySelectorAll("path");
-    expect(paths.length).toBe(1);
-    expect(paths[0]!.getAttribute("opacity")).toBe("0.2500");
+    const group = shadow.querySelector("g");
+    expect(group).not.toBeNull();
+    expect(group!.getAttribute("opacity")).toBe("0.2500");
+    expect(group!.querySelectorAll("path").length).toBe(1);
   });
 
   it("shadow leaves are always  with class polycss-shadow", () => {
diff --git a/packages/vue/src/scene/PolyMesh.ts b/packages/vue/src/scene/PolyMesh.ts
index 4d7d2a36..bfacd2ef 100644
--- a/packages/vue/src/scene/PolyMesh.ts
+++ b/packages/vue/src/scene/PolyMesh.ts
@@ -22,7 +22,6 @@ import type { MeshResolution, Polygon, PolyTextureLightingMode, Vec3 } from "@la
 import {
   BASE_TILE,
   computeSceneBbox,
-  convexHull2D,
   DEFAULT_SEAM_BLEED,
   inverseRotateVec3,
   findOverlappingPolygonDuplicates,
@@ -361,10 +360,10 @@ export const PolyMesh = defineComponent({
       });
     });
 
-    // Baked-mode SVG shadow — single per-mesh  with one merged
-    // silhouette  (convex hull of every caster polygon's projected
-    // vertices). Correct for convex/low-poly meshes; over-approximates
-    // concave ones by filling internal cavities.
+    // Baked-mode SVG shadow — single per-mesh  with one  per
+    // caster polygon. Overlapping outlines composite as one silhouette
+    // inside the  before alpha is applied, sidestepping
+    // the `opacity + preserve-3d` flatten trap.
     const shadowSvg = computed(() => {
       if (!props.castShadow) return null;
       if (atlasTextureLighting.value === "dynamic") return null;
@@ -380,7 +379,8 @@ export const PolyMesh = defineComponent({
         overlapFraction: 0.4,
       });
 
-      const points: Array<[number, number]> = [];
+      const projections: Array> = [];
+      let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
       const polys = polygons.value;
       const plans = textureAtlasPlans.value;
       for (let i = 0; i < polys.length; i++) {
@@ -389,26 +389,23 @@ export const PolyMesh = defineComponent({
         if (!plan) continue;
         if (!isBakedShadowCaster(plan.normal, lightDir)) continue;
         const polygon = polys[i]!;
+        const projected: Array<[number, number]> = [];
         for (const v of polygon.vertices) {
           const cssVertex: Vec3 = [
             v[1] * BASE_TILE,
             v[0] * BASE_TILE,
             v[2] * BASE_TILE,
           ];
-          points.push(projectCssVertexToGround(cssVertex, lightDir, groundCssZ));
+          const p = projectCssVertexToGround(cssVertex, lightDir, groundCssZ);
+          projected.push(p);
+          if (p[0] < minX) minX = p[0];
+          if (p[1] < minY) minY = p[1];
+          if (p[0] > maxX) maxX = p[0];
+          if (p[1] > maxY) maxY = p[1];
         }
+        projections.push(projected);
       }
-      if (points.length < 3) return null;
-      const hull = convexHull2D(points);
-      if (hull.length < 3) return null;
-
-      let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
-      for (const [x, y] of hull) {
-        if (x < minX) minX = x;
-        if (y < minY) minY = y;
-        if (x > maxX) maxX = x;
-        if (y > maxY) maxY = y;
-      }
+      if (projections.length === 0) return null;
       const width = maxX - minX;
       const height = maxY - minY;
       if (!(width > 0) || !(height > 0)) return null;
@@ -418,11 +415,14 @@ export const PolyMesh = defineComponent({
       const shadowOpacity = shadowOpts?.opacity ?? 0.25;
       const parsed = parseHexColor(shadowColor)?.rgb ?? [0, 0, 0];
 
-      let d = `M${(hull[0]![0] - minX).toFixed(3)},${(hull[0]![1] - minY).toFixed(3)}`;
-      for (let j = 1; j < hull.length; j++) {
-        d += `L${(hull[j]![0] - minX).toFixed(3)},${(hull[j]![1] - minY).toFixed(3)}`;
-      }
-      d += "Z";
+      const pathNodes = projections.map((verts, idx) => {
+        let d = `M${(verts[0]![0] - minX).toFixed(3)},${(verts[0]![1] - minY).toFixed(3)}`;
+        for (let j = 1; j < verts.length; j++) {
+          d += `L${(verts[j]![0] - minX).toFixed(3)},${(verts[j]![1] - minY).toFixed(3)}`;
+        }
+        d += "Z";
+        return h("path", { key: idx, d });
+      });
 
       return h(
         "svg",
@@ -443,11 +443,14 @@ export const PolyMesh = defineComponent({
           } as CSSProperties,
         },
         [
-          h("path", {
-            d,
-            fill: `rgb(${parsed[0]},${parsed[1]},${parsed[2]})`,
-            opacity: shadowOpacity.toFixed(4),
-          }),
+          h(
+            "g",
+            {
+              fill: `rgb(${parsed[0]},${parsed[1]},${parsed[2]})`,
+              opacity: shadowOpacity.toFixed(4),
+            },
+            pathNodes,
+          ),
         ],
       );
     });

From 80e4ab3ce68a459abb2b5fc9368342cf5ecdb19b Mon Sep 17 00:00:00 2001
From: Juan Cruz Fortunatti 
Date: Sat, 23 May 2026 15:04:35 +0200
Subject: [PATCH 14/28] chore(bench): dump shadow SVG outerHTML for diagnostics

---
 bench/baked-shadow.html | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/bench/baked-shadow.html b/bench/baked-shadow.html
index 66d67eb5..8ba602cd 100644
--- a/bench/baked-shadow.html
+++ b/bench/baked-shadow.html
@@ -99,6 +99,12 @@
           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,
@@ -109,6 +115,7 @@
         leafCount: leaves.length,
         shadowCount: shadows.length,
         sample,
+        svgInfo,
       };
     }
 

From 25934f266bbac67426c1a5013bb7b45b7f183357 Mon Sep 17 00:00:00 2001
From: Juan Cruz Fortunatti 
Date: Sat, 23 May 2026 15:13:44 +0200
Subject: [PATCH 15/28] feat: combine shadow polygons into single compound SVG
 path per mesh

---
 packages/core/src/index.ts                    |  2 +
 packages/core/src/shadow/projection.test.ts   | 37 ++++++++++++++++
 packages/core/src/shadow/projection.ts        | 32 ++++++++++++++
 .../polycss/src/api/createPolyScene.test.ts   | 27 +++++++-----
 packages/polycss/src/api/createPolyScene.ts   | 44 ++++++++++---------
 .../src/scene/PolyMesh.castShadow.test.tsx    | 21 +++++----
 packages/react/src/scene/PolyMesh.tsx         | 29 ++++++++----
 .../vue/src/scene/PolyMesh.castShadow.test.ts | 21 +++++----
 packages/vue/src/scene/PolyMesh.ts            | 34 ++++++++------
 9 files changed, 177 insertions(+), 70 deletions(-)

diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 6036beda..016162b0 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -146,7 +146,9 @@ export {
   BAKED_SHADOW_MIN_UP,
   BAKED_SHADOW_Z_SQUASH,
   buildBakedShadowProjectionMatrix,
+  ensureCcw2D,
   isBakedShadowCaster,
+  polygonSignedArea2D,
   projectCssVertexToGround,
 } from "./shadow/projection";
 
diff --git a/packages/core/src/shadow/projection.test.ts b/packages/core/src/shadow/projection.test.ts
index b9ea887a..5fc10fe2 100644
--- a/packages/core/src/shadow/projection.test.ts
+++ b/packages/core/src/shadow/projection.test.ts
@@ -3,7 +3,9 @@ import {
   BAKED_SHADOW_MIN_UP,
   BAKED_SHADOW_Z_SQUASH,
   buildBakedShadowProjectionMatrix,
+  ensureCcw2D,
   isBakedShadowCaster,
+  polygonSignedArea2D,
   projectCssVertexToGround,
 } from "./projection";
 
@@ -118,3 +120,38 @@ describe("projectCssVertexToGround", () => {
     expect(Math.abs(x - 100)).toBeGreaterThan(100);
   });
 });
+
+describe("polygonSignedArea2D", () => {
+  it("returns +1 for a unit square in CCW order", () => {
+    expect(polygonSignedArea2D([[0, 0], [1, 0], [1, 1], [0, 1]])).toBeCloseTo(1, 9);
+  });
+
+  it("returns -1 for a unit square in CW order", () => {
+    expect(polygonSignedArea2D([[0, 0], [0, 1], [1, 1], [1, 0]])).toBeCloseTo(-1, 9);
+  });
+
+  it("returns 0.5 for the standard CCW right triangle", () => {
+    expect(polygonSignedArea2D([[0, 0], [1, 0], [0, 1]])).toBeCloseTo(0.5, 9);
+  });
+});
+
+describe("ensureCcw2D", () => {
+  it("leaves CCW input unchanged", () => {
+    const input: Array<[number, number]> = [[0, 0], [1, 0], [1, 1], [0, 1]];
+    expect(ensureCcw2D(input)).toEqual(input);
+  });
+
+  it("reverses CW input to CCW", () => {
+    const input: Array<[number, number]> = [[0, 0], [0, 1], [1, 1], [1, 0]];
+    const out = ensureCcw2D(input);
+    expect(polygonSignedArea2D(out)).toBeGreaterThan(0);
+    expect(out).toEqual([[1, 0], [1, 1], [0, 1], [0, 0]]);
+  });
+
+  it("does not mutate input", () => {
+    const input: Array<[number, number]> = [[0, 0], [0, 1], [1, 1], [1, 0]];
+    const snap = JSON.stringify(input);
+    ensureCcw2D(input);
+    expect(JSON.stringify(input)).toBe(snap);
+  });
+});
diff --git a/packages/core/src/shadow/projection.ts b/packages/core/src/shadow/projection.ts
index ed0fae56..9e861296 100644
--- a/packages/core/src/shadow/projection.ts
+++ b/packages/core/src/shadow/projection.ts
@@ -82,6 +82,38 @@ export function isBakedShadowCaster(
   return normal[0] * lx + normal[1] * ly + normal[2] * lz > 0;
 }
 
+/**
+ * Signed area of a 2D polygon (positive for CCW vertex order, negative
+ * for CW). Used by `ensureCcw2D` to normalize winding before concatenating
+ * polygons into a compound SVG path under `fill-rule="nonzero"`: mixed
+ * CCW/CW subpaths would cancel each other's winding in the overlap
+ * region and paint an unintended hole.
+ */
+export function polygonSignedArea2D(
+  vertices: ReadonlyArray,
+): number {
+  let a = 0;
+  const n = vertices.length;
+  for (let i = 0; i < n; i++) {
+    const p = vertices[i]!;
+    const q = vertices[(i + 1) % n]!;
+    a += p[0] * q[1] - q[0] * p[1];
+  }
+  return a / 2;
+}
+
+/**
+ * Returns the polygon's vertices in CCW order, reversing if necessary.
+ * Operates on a copy — input is left unmodified.
+ */
+export function ensureCcw2D(
+  vertices: ReadonlyArray,
+): Array<[number, number]> {
+  const copy = vertices.map((v) => [v[0], v[1]] as [number, number]);
+  if (polygonSignedArea2D(copy) < 0) copy.reverse();
+  return copy;
+}
+
 /**
  * Projects a single CSS-3D vertex onto the shadow ground plane, returning
  * the resulting 2D point in CSS coordinates. Mirrors the per-element
diff --git a/packages/polycss/src/api/createPolyScene.test.ts b/packages/polycss/src/api/createPolyScene.test.ts
index 8feb9259..25c76b42 100644
--- a/packages/polycss/src/api/createPolyScene.test.ts
+++ b/packages/polycss/src/api/createPolyScene.test.ts
@@ -1890,10 +1890,12 @@ describe("createPolyScene", () => {
       expect(host.querySelectorAll(".polycss-shadow").length).toBe(2);
     });
 
-    it("castShadow:true in baked mode emits a single  shadow per mesh containing one  per caster polygon", () => {
-      // Baked mode builds a per-mesh  with CPU-projected outlines so
-      // overlapping leaves composite as one silhouette (no alpha stacking).
-      // The default light has +Z so the +Z-facing triangle is a caster.
+    it("castShadow:true in baked mode emits a single  shadow per mesh with one compound ", () => {
+      // Baked mode concatenates every casting polygon's projected outline
+      // into ONE compound `d` (M…L…Z subpaths) rendered under
+      // fill-rule=nonzero, so overlapping CCW outlines composite as one
+      // filled silhouette without alpha stacking while gaps remain holes.
+      // One  per mesh regardless of polygon count.
       scene = makeScene(host, { textureLighting: "baked" });
       scene.add(makeParseResult([triangle()]), { castShadow: true });
       const shadows = host.querySelectorAll(".polycss-shadow");
@@ -1901,15 +1903,18 @@ describe("createPolyScene", () => {
       const shadow = shadows[0] as SVGSVGElement;
       expect(shadow.tagName.toLowerCase()).toBe("svg");
       expect(shadow.classList.contains("polycss-shadow-svg")).toBe(true);
-      // SVG positioning is a translate3d at the ground plane (no var(--shadow-proj)).
       expect(shadow.style.transform).toMatch(/^translate3d\(/);
       expect(shadow.style.transform).not.toContain("var(--shadow-proj)");
-      // One path per caster polygon, grouped under a  so
-      // overlapping outlines collapse to one silhouette before alpha applies.
-      const group = shadow.querySelector("g");
-      expect(group).not.toBeNull();
-      expect(group!.getAttribute("opacity")).toBe("0.2500");
-      expect(group!.querySelectorAll("path").length).toBe(1);
+      const paths = shadow.querySelectorAll("path");
+      expect(paths.length).toBe(1);
+      const path = paths[0]!;
+      expect(path.getAttribute("opacity")).toBe("0.2500");
+      expect(path.getAttribute("fill-rule")).toBe("nonzero");
+      const d = path.getAttribute("d") || "";
+      // Triangle (3 verts) → one M, two Ls, one Z.
+      expect((d.match(/M/g) || []).length).toBe(1);
+      expect((d.match(/L/g) || []).length).toBe(2);
+      expect((d.match(/Z/g) || []).length).toBe(1);
     });
 
     it("baked mode skips shadow leaves for polygons facing away from the light", () => {
diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts
index 085ce975..6589c4ce 100644
--- a/packages/polycss/src/api/createPolyScene.ts
+++ b/packages/polycss/src/api/createPolyScene.ts
@@ -42,6 +42,7 @@ import {
   cameraCullNormalKey,
   cameraCullVisibleSignature,
   computeSceneBbox,
+  ensureCcw2D,
   findOverlappingPolygonDuplicates,
   inverseRotateVec3,
   isAxisAlignedSurfaceNormal,
@@ -357,17 +358,6 @@ function strategiesEqual(
   return true;
 }
 
-function pointsToSvgPath(points: Array<[number, number]>, originX: number, originY: number): string {
-  if (points.length === 0) return "";
-  const [x0, y0] = points[0]!;
-  let d = `M${(x0 - originX).toFixed(3)},${(y0 - originY).toFixed(3)}`;
-  for (let i = 1; i < points.length; i++) {
-    const [x, y] = points[i]!;
-    d += `L${(x - originX).toFixed(3)},${(y - originY).toFixed(3)}`;
-  }
-  return d + "Z";
-}
-
 function vec3Equal(a: Vec3 | undefined, b: Vec3 | undefined): boolean {
   if (a === b) return true;
   if (!a || !b) return false;
@@ -1329,6 +1319,23 @@ export function createPolyScene(
     const height = maxY - minY;
     if (!(width > 0) || !(height > 0)) return;
 
+    // Concatenate every projected polygon into ONE compound `d` string —
+    // each subpath as its own `M…L…Z` block. Under `fill-rule="nonzero"`
+    // overlapping CCW subpaths composite as one filled region without
+    // alpha stacking, AND gaps between subpaths remain as gaps (the
+    // shadow inherits the silhouette's holes for free). We normalize
+    // each projected polygon to CCW so any back-projected (CW) ones
+    // don't cancel adjacent winding and punch unwanted holes.
+    let d = "";
+    for (const verts of polyProjections) {
+      const ccw = ensureCcw2D(verts);
+      d += `M${(ccw[0]![0] - minX).toFixed(3)},${(ccw[0]![1] - minY).toFixed(3)}`;
+      for (let i = 1; i < ccw.length; i++) {
+        d += `L${(ccw[i]![0] - minX).toFixed(3)},${(ccw[i]![1] - minY).toFixed(3)}`;
+      }
+      d += "Z";
+    }
+
     const svgNS = "http://www.w3.org/2000/svg";
     const svg = doc.createElementNS(svgNS, "svg");
     svg.setAttribute("class", "polycss-shadow polycss-shadow-svg");
@@ -1345,15 +1352,12 @@ export function createPolyScene(
       `transform:translate3d(${minX.toFixed(3)}px,${minY.toFixed(3)}px,${groundCssZ.toFixed(3)}px)`,
     );
 
-    const group = doc.createElementNS(svgNS, "g");
-    group.setAttribute("fill", `rgb(${r},${g},${b})`);
-    group.setAttribute("opacity", opacity.toFixed(4));
-    for (const verts of polyProjections) {
-      const path = doc.createElementNS(svgNS, "path");
-      path.setAttribute("d", pointsToSvgPath(verts, minX, minY));
-      group.appendChild(path);
-    }
-    svg.appendChild(group);
+    const path = doc.createElementNS(svgNS, "path");
+    path.setAttribute("d", d);
+    path.setAttribute("fill", `rgb(${r},${g},${b})`);
+    path.setAttribute("fill-rule", "nonzero");
+    path.setAttribute("opacity", opacity.toFixed(4));
+    svg.appendChild(path);
 
     entry.shadowSvg = svg;
     const firstChild = entry.wrapper.firstChild;
diff --git a/packages/react/src/scene/PolyMesh.castShadow.test.tsx b/packages/react/src/scene/PolyMesh.castShadow.test.tsx
index 28be34d6..6f43ba02 100644
--- a/packages/react/src/scene/PolyMesh.castShadow.test.tsx
+++ b/packages/react/src/scene/PolyMesh.castShadow.test.tsx
@@ -115,10 +115,10 @@ describe("PolyMesh — castShadow", () => {
     expect(container.querySelectorAll(".polycss-shadow").length).toBe(2);
   });
 
-  it("castShadow in baked mode emits a single  shadow per mesh containing one  per caster polygon", () => {
-    // Baked mode builds a per-mesh  with CPU-projected outlines so
-    // overlapping leaves composite as one silhouette (no alpha stacking).
-    // The default light has positive Z, so the +Z-facing triangle is a caster.
+  it("castShadow in baked mode emits a single  shadow per mesh with one compound ", () => {
+    // Baked mode concatenates every casting polygon's projected outline
+    // into ONE compound `d` (M…L…Z subpaths) rendered under
+    // fill-rule=nonzero — one  per mesh regardless of polygon count.
     const { container } = renderScene(
       { textureLighting: "baked" },
       { polygons: [TRIANGLE], castShadow: true },
@@ -130,10 +130,15 @@ describe("PolyMesh — castShadow", () => {
     expect(shadow.classList.contains("polycss-shadow-svg")).toBe(true);
     expect(shadow.style.transform).toMatch(/^translate3d\(/);
     expect(shadow.style.transform).not.toContain("var(--shadow-proj)");
-    const group = shadow.querySelector("g");
-    expect(group).not.toBeNull();
-    expect(group!.getAttribute("opacity")).toBe("0.2500");
-    expect(group!.querySelectorAll("path").length).toBe(1);
+    const paths = shadow.querySelectorAll("path");
+    expect(paths.length).toBe(1);
+    const path = paths[0]!;
+    expect(path.getAttribute("opacity")).toBe("0.2500");
+    expect(path.getAttribute("fill-rule")).toBe("nonzero");
+    const d = path.getAttribute("d") || "";
+    expect((d.match(/M/g) || []).length).toBe(1);
+    expect((d.match(/L/g) || []).length).toBe(2);
+    expect((d.match(/Z/g) || []).length).toBe(1);
   });
 
   it("shadow leaves are  elements", () => {
diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx
index 91c89dcf..d97b9a33 100644
--- a/packages/react/src/scene/PolyMesh.tsx
+++ b/packages/react/src/scene/PolyMesh.tsx
@@ -35,6 +35,7 @@ import {
   BASE_TILE,
   computeSceneBbox,
   DEFAULT_SEAM_BLEED,
+  ensureCcw2D,
   findOverlappingPolygonDuplicates,
   inverseRotateVec3,
   isBakedShadowCaster,
@@ -712,14 +713,21 @@ export const PolyMesh = forwardRef(function PolyM
     const shadowOpacity = sceneShadow?.opacity ?? 0.25;
     const parsed = parseHexColor(shadowColor)?.rgb ?? [0, 0, 0];
 
-    const paths = projections.map((verts, idx) => {
-      let d = `M${(verts[0]![0] - minX).toFixed(3)},${(verts[0]![1] - minY).toFixed(3)}`;
-      for (let j = 1; j < verts.length; j++) {
-        d += `L${(verts[j]![0] - minX).toFixed(3)},${(verts[j]![1] - minY).toFixed(3)}`;
+    // Concatenate every projection into ONE compound `d` string. Each
+    // polygon becomes its own M…L…Z subpath, normalized to CCW so all
+    // windings agree and fill-rule=nonzero paints overlapping outlines
+    // as one filled silhouette without alpha stacking. Gaps between
+    // subpaths remain as gaps (the shadow preserves the silhouette's
+    // holes for free).
+    let d = "";
+    for (const verts of projections) {
+      const ccw = ensureCcw2D(verts);
+      d += `M${(ccw[0]![0] - minX).toFixed(3)},${(ccw[0]![1] - minY).toFixed(3)}`;
+      for (let j = 1; j < ccw.length; j++) {
+        d += `L${(ccw[j]![0] - minX).toFixed(3)},${(ccw[j]![1] - minY).toFixed(3)}`;
       }
       d += "Z";
-      return ;
-    });
+    }
 
     return (
       (function PolyM
           transform: `translate3d(${minX.toFixed(3)}px,${minY.toFixed(3)}px,${bakedShadowGroundCssZ.toFixed(3)}px)`,
         }}
       >
-        
-          {paths}
-        
+        
       
     );
   }, [castShadow, renderPolygon, effectiveTextureLighting, polygons, atlasPlans, sceneDirectionalLight, bakedShadowGroundCssZ, sceneShadow]);
diff --git a/packages/vue/src/scene/PolyMesh.castShadow.test.ts b/packages/vue/src/scene/PolyMesh.castShadow.test.ts
index e158062f..3d9e1cb0 100644
--- a/packages/vue/src/scene/PolyMesh.castShadow.test.ts
+++ b/packages/vue/src/scene/PolyMesh.castShadow.test.ts
@@ -94,10 +94,10 @@ describe("PolyMesh (Vue) — castShadow", () => {
     expect(container.querySelectorAll(".polycss-shadow").length).toBe(2);
   });
 
-  it("castShadow:true in baked mode emits a single  shadow per mesh containing one  per caster polygon", async () => {
-    // Baked mode builds a per-mesh  with CPU-projected outlines so
-    // overlapping leaves composite as one silhouette (no alpha stacking).
-    // The default light has positive Z so the +Z-facing triangle is a caster.
+  it("castShadow:true in baked mode emits a single  shadow per mesh with one compound ", async () => {
+    // Baked mode concatenates every casting polygon's projected outline
+    // into ONE compound `d` (M…L…Z subpaths) rendered under
+    // fill-rule=nonzero. One  per mesh regardless of polygon count.
     // nextTick lets the scene's watchEffect derive groundCssZ from the
     // child's registration before the shadow nodes recompute.
     const { container } = mount(
@@ -113,10 +113,15 @@ describe("PolyMesh (Vue) — castShadow", () => {
     expect(shadow.classList.contains("polycss-shadow-svg")).toBe(true);
     expect(shadow.style.transform).toMatch(/^translate3d\(/);
     expect(shadow.style.transform).not.toContain("var(--shadow-proj)");
-    const group = shadow.querySelector("g");
-    expect(group).not.toBeNull();
-    expect(group!.getAttribute("opacity")).toBe("0.2500");
-    expect(group!.querySelectorAll("path").length).toBe(1);
+    const paths = shadow.querySelectorAll("path");
+    expect(paths.length).toBe(1);
+    const path = paths[0]!;
+    expect(path.getAttribute("opacity")).toBe("0.2500");
+    expect(path.getAttribute("fill-rule")).toBe("nonzero");
+    const d = path.getAttribute("d") || "";
+    expect((d.match(/M/g) || []).length).toBe(1);
+    expect((d.match(/L/g) || []).length).toBe(2);
+    expect((d.match(/Z/g) || []).length).toBe(1);
   });
 
   it("shadow leaves are always  with class polycss-shadow", () => {
diff --git a/packages/vue/src/scene/PolyMesh.ts b/packages/vue/src/scene/PolyMesh.ts
index bfacd2ef..ef64fc53 100644
--- a/packages/vue/src/scene/PolyMesh.ts
+++ b/packages/vue/src/scene/PolyMesh.ts
@@ -23,6 +23,7 @@ import {
   BASE_TILE,
   computeSceneBbox,
   DEFAULT_SEAM_BLEED,
+  ensureCcw2D,
   inverseRotateVec3,
   findOverlappingPolygonDuplicates,
   isBakedShadowCaster,
@@ -415,14 +416,21 @@ export const PolyMesh = defineComponent({
       const shadowOpacity = shadowOpts?.opacity ?? 0.25;
       const parsed = parseHexColor(shadowColor)?.rgb ?? [0, 0, 0];
 
-      const pathNodes = projections.map((verts, idx) => {
-        let d = `M${(verts[0]![0] - minX).toFixed(3)},${(verts[0]![1] - minY).toFixed(3)}`;
-        for (let j = 1; j < verts.length; j++) {
-          d += `L${(verts[j]![0] - minX).toFixed(3)},${(verts[j]![1] - minY).toFixed(3)}`;
+      // Concatenate every projection into ONE compound `d` string. Each
+      // polygon becomes its own M…L…Z subpath, normalized to CCW so all
+      // windings agree and fill-rule=nonzero paints overlapping outlines
+      // as one filled silhouette without alpha stacking. Gaps between
+      // subpaths remain as holes — the shadow inherits the silhouette's
+      // holes for free.
+      let d = "";
+      for (const verts of projections) {
+        const ccw = ensureCcw2D(verts);
+        d += `M${(ccw[0]![0] - minX).toFixed(3)},${(ccw[0]![1] - minY).toFixed(3)}`;
+        for (let j = 1; j < ccw.length; j++) {
+          d += `L${(ccw[j]![0] - minX).toFixed(3)},${(ccw[j]![1] - minY).toFixed(3)}`;
         }
         d += "Z";
-        return h("path", { key: idx, d });
-      });
+      }
 
       return h(
         "svg",
@@ -443,14 +451,12 @@ export const PolyMesh = defineComponent({
           } as CSSProperties,
         },
         [
-          h(
-            "g",
-            {
-              fill: `rgb(${parsed[0]},${parsed[1]},${parsed[2]})`,
-              opacity: shadowOpacity.toFixed(4),
-            },
-            pathNodes,
-          ),
+          h("path", {
+            d,
+            fill: `rgb(${parsed[0]},${parsed[1]},${parsed[2]})`,
+            "fill-rule": "nonzero",
+            opacity: shadowOpacity.toFixed(4),
+          }),
         ],
       );
     });

From 69c5c013653ddcf3e3b4f0bb7305e8cfb393d3f3 Mon Sep 17 00:00:00 2001
From: Juan Cruz Fortunatti 
Date: Sat, 23 May 2026 15:26:32 +0200
Subject: [PATCH 16/28] feat: unify dynamic + baked shadows on per-mesh SVG
 path

---
 .../polycss/src/api/createPolyScene.test.ts   |  79 +++-----
 packages/polycss/src/api/createPolyScene.ts   | 183 +++++-------------
 .../src/scene/PolyMesh.castShadow.test.tsx    |  59 ++----
 packages/react/src/scene/PolyMesh.tsx         |  95 +--------
 .../vue/src/scene/PolyMesh.castShadow.test.ts |  61 ++----
 packages/vue/src/scene/PolyMesh.ts            |  74 +------
 6 files changed, 128 insertions(+), 423 deletions(-)

diff --git a/packages/polycss/src/api/createPolyScene.test.ts b/packages/polycss/src/api/createPolyScene.test.ts
index 25c76b42..ce4c8612 100644
--- a/packages/polycss/src/api/createPolyScene.test.ts
+++ b/packages/polycss/src/api/createPolyScene.test.ts
@@ -1877,17 +1877,18 @@ describe("createPolyScene", () => {
       expect(host.querySelectorAll(".polycss-shadow").length).toBe(0);
     });
 
-    it("castShadow:true in dynamic mode emits shadow leaves, one per non-textured polygon", () => {
-      scene = makeScene(host, dynOpts);
-      // Use spatially distinct triangles so the loose shadow-dedup pass
-      // doesn't fold them into one shadow (two triangles at the same
-      // location WOULD be deduped, which is the intended behavior).
+    it("castShadow:true in dynamic mode emits a single  shadow per mesh (same path as baked)", () => {
+      // Dynamic mode now uses the same per-mesh compound SVG path as baked
+      // mode — one  per casting mesh regardless of polygon count.
       const distinctTri: Polygon = {
         vertices: [[10, 10, 5], [11, 10, 5], [10, 11, 5]],
         color: "#00ff00",
       };
+      scene = makeScene(host, dynOpts);
       scene.add(makeParseResult([triangle(), distinctTri]), { castShadow: true, merge: false });
-      expect(host.querySelectorAll(".polycss-shadow").length).toBe(2);
+      const shadows = host.querySelectorAll(".polycss-shadow");
+      expect(shadows.length).toBe(1);
+      expect(shadows[0]!.tagName.toLowerCase()).toBe("svg");
     });
 
     it("castShadow:true in baked mode emits a single  shadow per mesh with one compound ", () => {
@@ -1937,17 +1938,13 @@ describe("createPolyScene", () => {
       }
     });
 
-    it("shadow leaves are always  with border-shape regardless of caster tag", () => {
-      scene = makeScene(host, dynOpts);
-      // Mix shapes at distinct 3D positions (otherwise the loose-tolerance
-      // shadow dedup pass folds them into one shadow). Each emits a 
-      // shadow — a dedicated single-letter render strategy in the tag
-      // taxonomy alongside ///, kept clear of the dynamic-
-      // mode Lambert color rule.
+    it("shadow elements are always  with class polycss-shadow regardless of caster tag or mode", () => {
+      // Both lighting modes use the same per-mesh  shadow now.
       const distinctTri: Polygon = {
         vertices: [[10, 10, 5], [11, 10, 5], [10, 11, 5]],
         color: "#00ff00",
       };
+      scene = makeScene(host, dynOpts);
       scene.add(makeParseResult([triangle(), distinctTri]), {
         castShadow: true,
         merge: false,
@@ -1955,34 +1952,20 @@ describe("createPolyScene", () => {
       const shadows = Array.from(host.querySelectorAll(".polycss-shadow"));
       expect(shadows.length).toBeGreaterThan(0);
       for (const el of shadows) {
-        expect((el as HTMLElement).tagName.toLowerCase()).toBe("q");
-        expect((el as HTMLElement).style.getPropertyValue("border-shape")).not.toBe("");
+        expect(el.tagName.toLowerCase()).toBe("svg");
+        expect(el.classList.contains("polycss-shadow-svg")).toBe(true);
       }
     });
 
-    it("shadow leaves transform contains var(--shadow-proj) followed by matrix3d", () => {
-      scene = makeScene(host, dynOpts);
-      scene.add(makeParseResult([triangle()]), { castShadow: true });
-      const shadow = host.querySelector(".polycss-shadow") as HTMLElement;
-      expect(shadow).not.toBeNull();
-      expect(shadow.style.transform).toMatch(/^var\(--shadow-proj\)\s+matrix3d\(/);
-    });
-
-    it("adding a casting mesh sets --shadow-ground-cssz on the scene element", () => {
+    it("adding a casting mesh in dynamic mode does NOT need --shadow-ground-cssz on the scene", () => {
+      // Dynamic mode no longer uses --shadow-ground-cssz / --shadow-proj —
+      // the projection is CPU-baked into the per-mesh SVG path same as in
+      // baked mode.
       scene = makeScene(host, dynOpts);
       scene.add(makeParseResult([triangle()]), { castShadow: true });
       const sceneEl = getSceneEl(host);
-      const groundVar = sceneEl.style.getPropertyValue("--shadow-ground-cssz");
-      expect(groundVar).not.toBe("");
-    });
-
-    it("removing the casting mesh clears --shadow-ground-cssz", () => {
-      scene = makeScene(host, dynOpts);
-      const handle = scene.add(makeParseResult([triangle()]), { castShadow: true });
-      const sceneEl = getSceneEl(host);
-      expect(sceneEl.style.getPropertyValue("--shadow-ground-cssz")).not.toBe("");
-      handle.remove();
       expect(sceneEl.style.getPropertyValue("--shadow-ground-cssz")).toBe("");
+      expect(host.querySelectorAll(".polycss-shadow").length).toBe(1);
     });
 
     it("toggling castShadow via setTransform adds/removes shadow leaves", () => {
@@ -1995,29 +1978,29 @@ describe("createPolyScene", () => {
       expect(host.querySelectorAll(".polycss-shadow").length).toBe(0);
     });
 
-    it("switching from dynamic to baked rebuilds shadow as a translated ", () => {
+    it("switching from dynamic to baked keeps the shadow as a translated ", () => {
       scene = makeScene(host, dynOpts);
       scene.add(makeParseResult([triangle()]), { castShadow: true });
-      const dynamicShadow = host.querySelector(".polycss-shadow") as HTMLElement;
-      expect(dynamicShadow.tagName.toLowerCase()).toBe("q");
-      expect(dynamicShadow.style.transform).toContain("var(--shadow-proj)");
+      const before = host.querySelector(".polycss-shadow") as SVGSVGElement;
+      expect(before.tagName.toLowerCase()).toBe("svg");
+      expect(before.style.transform).toMatch(/^translate3d\(/);
       scene.setOptions({ textureLighting: "baked" });
-      const bakedShadow = host.querySelector(".polycss-shadow") as SVGSVGElement;
-      expect(bakedShadow).not.toBeNull();
-      expect(bakedShadow.tagName.toLowerCase()).toBe("svg");
-      expect(bakedShadow.style.transform).not.toContain("var(--shadow-proj)");
-      expect(bakedShadow.style.transform).toMatch(/^translate3d\(/);
+      const after = host.querySelector(".polycss-shadow") as SVGSVGElement;
+      expect(after).not.toBeNull();
+      expect(after.tagName.toLowerCase()).toBe("svg");
+      expect(after.style.transform).toMatch(/^translate3d\(/);
     });
 
-    it("switching from baked back to dynamic re-emits shadow leaves using var(--shadow-proj)", () => {
+    it("switching from baked back to dynamic keeps the shadow as a translated ", () => {
       scene = makeScene(host, { textureLighting: "baked" });
       scene.add(makeParseResult([triangle()]), { castShadow: true });
-      const bakedShadow = host.querySelector(".polycss-shadow") as HTMLElement;
-      expect(bakedShadow.style.transform).not.toContain("var(--shadow-proj)");
+      const before = host.querySelector(".polycss-shadow") as SVGSVGElement;
+      expect(before.tagName.toLowerCase()).toBe("svg");
       scene.setOptions({ ...dynOpts });
-      const dynamicShadow = host.querySelector(".polycss-shadow") as HTMLElement;
+      const dynamicShadow = host.querySelector(".polycss-shadow") as SVGSVGElement;
       expect(dynamicShadow).not.toBeNull();
-      expect(dynamicShadow.style.transform).toContain("var(--shadow-proj)");
+      expect(dynamicShadow.tagName.toLowerCase()).toBe("svg");
+      expect(dynamicShadow.style.transform).toMatch(/^translate3d\(/);
     });
 
     it("textured polygons (s) ALSO emit shadow leaves", () => {
diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts
index 6589c4ce..6e0e5026 100644
--- a/packages/polycss/src/api/createPolyScene.ts
+++ b/packages/polycss/src/api/createPolyScene.ts
@@ -1144,33 +1144,27 @@ export function createPolyScene(
     }
   }
 
-  // Emits shadow leaves for all rendered polys in the entry.
+  // Emits the per-mesh shadow ``. Same path for both lighting modes:
+  // every casting polygon is projected to the ground on the CPU and
+  // concatenated into a single compound `` (M…L…Z subpaths) under
+  // fill-rule=nonzero. Overlapping outlines composite as one filled
+  // silhouette without alpha stacking; gaps between subpaths remain as
+  // gaps (silhouette holes are preserved); back-facing polys are dropped
+  // up front. One SVG element per mesh regardless of polygon count.
   //
-  // Dynamic mode emits one `` per casting polygon whose transform
-  // chains `var(--shadow-proj)` so the projection follows the live light
-  // vars on the scene root (zero JS at light-change time). A CSS opacity
-  // calc on the scene root hides back-facing polys at paint time.
-  //
-  // Baked mode emits a single `` per mesh containing one ``
-  // per casting polygon (projected to ground on the CPU). The shared
-  // `` collapses overlapping outlines into one solid silhouette
-  // before applying the alpha — no per-leaf alpha accumulation at
-  // polygon intersections, and no `opacity + preserve-3d` flatten trap
-  // because SVG content is internally 2D. Back-facing polys are dropped
-  // up front.
-  //
-  // Shadow elements are inserted BEFORE the first non-shadow child so
-  // they sit below casters in DOM order, which keeps them behind the
-  // casters when both are coplanar in 3D (painter-order tie-breaking
-  // favors earlier nodes).
+  // Trade-off vs. the old dynamic-mode per-`` CSS path: live light
+  // updates now require a JS re-projection pass (`setOptions` triggers
+  // re-emit when directionalLight.direction changes) instead of being
+  // free CSS variable updates. The visual upside (no alpha stacking,
+  // preserved holes, fewer DOM nodes) is worth the JS cost for typical
+  // scenes — huge meshes during light-slider drag can profile if needed.
   function emitShadowLeaves(entry: MeshEntry): void {
     clearShadowLeaves(entry);
     if (!entry.castShadow) return;
-    const isDynamic = currentOptions.textureLighting === "dynamic";
-    // Baked mode needs a ground plane to project onto. If none has been
-    // computed yet (no caster meshes), bail and wait for the next
+    // Need a ground plane to project onto. If none has been computed
+    // yet (no caster meshes), bail and wait for the next
     // recomputeShadowGround pass to drive emission.
-    if (!isDynamic && currentGroundCssZ === null) return;
+    if (currentGroundCssZ === null) return;
 
     const shadowColor = currentOptions.shadow?.color ?? "#000000";
     const shadowOpacity = currentOptions.shadow?.opacity ?? 0.25;
@@ -1185,8 +1179,7 @@ export function createPolyScene(
     // plane-offset drift, catching back-to-back doubled faces and minor
     // importer artifacts without false-positively dropping legitimate
     // inner/outer wall pairs that cast genuinely distinct shadows.
-    // Light-independent — runs once per mesh-polygon change, never per
-    // camera tick or light slider tick.
+    // Light-independent — runs once per mesh-polygon change.
     const shadowDedupDrop = findOverlappingPolygonDuplicates(entry.polygons, {
       normalTolerance: 0.1,
       distanceTolerance: 0.5,
@@ -1196,82 +1189,24 @@ export function createPolyScene(
     const lightDir = currentOptions.directionalLight?.direction
       ?? ([0.4, -0.7, 0.59] as Vec3);
 
-    if (!isDynamic) {
-      emitBakedShadowSvg(
-        entry,
-        shadowDedupDrop,
-        lightDir,
-        currentGroundCssZ ?? 0,
-        r, g, b,
-        shadowOpacity,
-      );
-      return;
-    }
-
-    const shadowColorCss = `rgba(${r},${g},${b},${shadowOpacity})`;
-    const fragment = doc.createDocumentFragment();
-    // Iterate all rendered polys (not camera-filtered) — a polygon on the
-    // lit side of the mesh that's currently camera-culled still casts a
-    // valid shadow on the ground.
-    for (const item of entry.rendered) {
-      // Atlas () polygons cast shadows too — the shadow only needs
-      // the polygon's OUTLINE (border-shape) and a flat dark color, not
-      // the texture content. So fully textured meshes like the Frog Guy
-      // get proper shadows just like solid-color meshes.
-      // Skip polygons identified as shadow-duplicates of another caster.
-      if (shadowDedupDrop.has(item.polygonIndex)) continue;
-      const plan = item.plan;
-      if (!plan) continue;
-
-      // Read the original matrix3d from the plan (not from the element
-      // style string) so we never parse strings.
-      const origMatrix = `matrix3d(${plan.matrix})`;
-
-      // Shadow leaves emit as  — a dedicated single-letter element
-      // that lives alongside /// in the tag-as-strategy
-      // taxonomy. Using its own tag means we don't have to thread
-      // `:not(.polycss-shadow)` exclusions through every dynamic-mode
-      // color rule (regular polygon leaves get relit by Lambert; shadow
-      // leaves shouldn't). Rendering rides the  + border-shape path
-      // mirrored from 's border-color: currentColor mechanism.
-      // clip-path is forbidden by repo policy (4000+ clip-paths inside
-      // preserve-3d = ~15 s/frame on Chromium).
-      const shadowEl = doc.createElement("q");
-      shadowEl.className = "polycss-shadow";
-      shadowEl.style.transform = `var(--shadow-proj) ${origMatrix}`;
-      shadowEl.style.color = shadowColorCss;
-      shadowEl.style.width = `${plan.canvasW}px`;
-      shadowEl.style.height = `${plan.canvasH}px`;
-      shadowEl.style.setProperty("border-shape", cssBorderShapeForPlan(plan));
-      // Dynamic mode pins the caster's normal so the per-element opacity
-      // calc can Lambert-gate back-facing polys.
-      shadowEl.style.setProperty("--pnx", plan.normal[0].toFixed(4));
-      shadowEl.style.setProperty("--pny", plan.normal[1].toFixed(4));
-      shadowEl.style.setProperty("--pnz", plan.normal[2].toFixed(4));
-
-      fragment.appendChild(shadowEl);
-      entry.shadowRendered.push(shadowEl);
-    }
-
-    // Insert all shadow leaves BEFORE the first normal polygon child so
-    // they appear below casters in DOM order. appendChild would put them
-    // after; insertBefore(fragment, firstChild) puts them at the front.
-    const firstChild = entry.wrapper.firstChild;
-    if (firstChild) {
-      entry.wrapper.insertBefore(fragment, firstChild);
-    } else {
-      entry.wrapper.appendChild(fragment);
-    }
+    emitShadowSvg(
+      entry,
+      shadowDedupDrop,
+      lightDir,
+      currentGroundCssZ,
+      r, g, b,
+      shadowOpacity,
+    );
   }
 
-  // Builds a single per-mesh  for baked-mode shadows. Projects every
-  // casting polygon to the ground plane on the CPU, then drops one 
-  // per polygon into a shared  so overlapping
-  // outlines composite as one silhouette (no alpha accumulation at
-  // intersections). SVG content is internally 2D so this sidesteps the
-  // `opacity + transform-style: preserve-3d` flatten trap that breaks
-  // CSS-only shadow grouping in a 3D scene.
-  function emitBakedShadowSvg(
+  // Builds a single per-mesh  for the mesh's shadow. Projects every
+  // casting polygon to the ground on the CPU, concatenates the outlines
+  // into one compound  under fill-rule=nonzero so
+  // overlapping CCW subpaths composite as one filled silhouette (no alpha
+  // accumulation at intersections). SVG content is internally 2D so this
+  // sidesteps the `opacity + transform-style: preserve-3d` flatten trap
+  // that breaks CSS-only shadow grouping in a 3D scene.
+  function emitShadowSvg(
     entry: MeshEntry,
     dedupDrop: Set,
     lightDir: Vec3,
@@ -1442,10 +1377,8 @@ export function createPolyScene(
   // plane slightly above the bbox floor to prevent z-fighting with
   // receiver polygons.
   //
-  // Dynamic mode writes the result to `--shadow-ground-cssz` and lets the
-  // CSS `--shadow-proj` calc expression rebuild the matrix on the GPU side.
-  // Baked mode caches it in `currentGroundCssZ` and re-emits all casting
-  // entries' shadow leaves so the inline matrix3d transforms refresh.
+  // The ground value is folded into each mesh's SVG shadow path on the
+  // CPU, so a change requires re-emission of every caster's shadow.
   function recomputeShadowGround(): void {
     let minWorldZ = Infinity;
     for (const m of meshes) {
@@ -1458,13 +1391,12 @@ export function createPolyScene(
       }
     }
     if (!Number.isFinite(minWorldZ)) {
-      sceneEl.style.removeProperty("--shadow-ground-cssz");
       const hadGround = currentGroundCssZ !== null;
       currentGroundCssZ = null;
-      // No casters left: drop any baked shadow leaves still mounted.
-      if (hadGround && currentOptions.textureLighting !== "dynamic") {
+      // No casters left: drop any shadow elements still mounted.
+      if (hadGround) {
         for (const entry of meshes) {
-          if (entry.shadowRendered.length) clearShadowLeaves(entry);
+          if (entry.shadowSvg) clearShadowLeaves(entry);
         }
       }
       return;
@@ -1474,19 +1406,9 @@ export function createPolyScene(
     // (not subtracted) so the shadow plane sits slightly *above* the model
     // bbox floor — putting it on top of a receiver mesh placed at minZ
     // rather than below it, where the receiver would occlude the shadow.
-    // Stored as a unitless number (not px) because matrix3d() calc() entries
-    // must be dimensionless — see styles.ts @property --shadow-ground-cssz.
     const groundCssZ = (minWorldZ + lift) * DEFAULT_TILE;
     const prevGround = currentGroundCssZ;
     currentGroundCssZ = groundCssZ;
-    if (currentOptions.textureLighting === "dynamic") {
-      sceneEl.style.setProperty("--shadow-ground-cssz", groundCssZ.toFixed(3));
-      return;
-    }
-    // Baked mode: the ground value is folded into each leaf's inline
-    // matrix3d, so a change requires re-emission of every caster's shadows.
-    // Strip the dynamic-only CSS var in case lighting just toggled.
-    sceneEl.style.removeProperty("--shadow-ground-cssz");
     if (prevGround !== groundCssZ) {
       for (const entry of meshes) {
         if (entry.castShadow) emitShadowLeaves(entry);
@@ -2033,13 +1955,12 @@ export function createPolyScene(
       for (const entry of meshes) renderEntry(entry);
     }
     if (prevAutoCenter !== nextAutoCenter) recomputeAutoCenter();
-    // Shadow emission depends on lighting mode, light direction, and the
-    // shadow appearance options. Dynamic mode handles light + shadow-color
-    // changes purely through CSS vars updated above; the cases that need
-    // explicit re-emission are:
-    //  - lighting mode toggled (different transform / DOM shape)
-    //  - light direction changed in baked mode (matrix is CPU-baked)
-    //  - shadow color/opacity/lift changed in baked mode (color is inline)
+    // Shadows now use the same per-mesh SVG path in both lighting modes,
+    // so any of these changes require explicit re-emission:
+    //  - lighting mode toggled (the regular leaves change)
+    //  - light direction changed (projection is CPU-baked into each path)
+    //  - shadow color/opacity/lift changed (color/opacity are inline on the
+    //    ; lift shifts the ground plane and rebuilds geometry)
     const textureLightingChanged = partial.textureLighting !== undefined &&
       prevTextureLighting !== currentOptions.textureLighting;
     const nextLightDir = currentOptions.directionalLight?.direction;
@@ -2048,9 +1969,7 @@ export function createPolyScene(
     const nextShadow = currentOptions.shadow;
     const shadowAppearanceChanged = partial.shadow !== undefined
       && !shadowOptsEqual(prevShadow, nextShadow);
-    const isBaked = currentOptions.textureLighting !== "dynamic";
-    const bakedShadowResetNeeded = isBaked
-      && (lightDirChanged || shadowAppearanceChanged);
+    const shadowReemitNeeded = lightDirChanged || shadowAppearanceChanged;
     if (textureLightingChanged) {
       for (const entry of meshes) {
         if (!strategiesChanged && !seamBleedChanged && (entry.voxelSource || entry.voxelRenderer)) {
@@ -2060,17 +1979,7 @@ export function createPolyScene(
         }
       }
       recomputeShadowGround();
-    } else if (bakedShadowResetNeeded) {
-      // Light direction or shadow appearance changed in baked mode —
-      // re-emit all casters' shadow leaves with the new inline matrix /
-      // color. Cheap: DOM-only, no atlas re-rasterise.
-      for (const entry of meshes) {
-        if (entry.castShadow) emitShadowLeaves(entry);
-      }
-    } else if (shadowAppearanceChanged && !isBaked) {
-      // Dynamic mode: shadow color/opacity is per-leaf inline, so a
-      // change still needs re-emission. Lift affects --shadow-ground-cssz
-      // which recomputeShadowGround handles below.
+    } else if (shadowReemitNeeded) {
       for (const entry of meshes) {
         if (entry.castShadow) emitShadowLeaves(entry);
       }
diff --git a/packages/react/src/scene/PolyMesh.castShadow.test.tsx b/packages/react/src/scene/PolyMesh.castShadow.test.tsx
index 6f43ba02..a9ed79cd 100644
--- a/packages/react/src/scene/PolyMesh.castShadow.test.tsx
+++ b/packages/react/src/scene/PolyMesh.castShadow.test.tsx
@@ -107,12 +107,14 @@ describe("PolyMesh — castShadow", () => {
     expect(container.querySelectorAll(".polycss-shadow").length).toBe(0);
   });
 
-  it("castShadow in dynamic mode emits shadow leaves, one per non-duplicate polygon", () => {
+  it("castShadow in dynamic mode emits a single  shadow per mesh (same path as baked)", () => {
     const { container } = renderScene(DYN_SCENE_PROPS, {
       polygons: [TRIANGLE, DISTINCT_TRIANGLE],
       castShadow: true,
     });
-    expect(container.querySelectorAll(".polycss-shadow").length).toBe(2);
+    const shadows = container.querySelectorAll(".polycss-shadow");
+    expect(shadows.length).toBe(1);
+    expect(shadows[0]!.tagName.toLowerCase()).toBe("svg");
   });
 
   it("castShadow in baked mode emits a single  shadow per mesh with one compound ", () => {
@@ -141,7 +143,7 @@ describe("PolyMesh — castShadow", () => {
     expect((d.match(/Z/g) || []).length).toBe(1);
   });
 
-  it("shadow leaves are  elements", () => {
+  it("shadow elements are  elements in either lighting mode", () => {
     const { container } = renderScene(DYN_SCENE_PROPS, {
       polygons: [TRIANGLE],
       castShadow: true,
@@ -149,41 +151,11 @@ describe("PolyMesh — castShadow", () => {
     const shadows = container.querySelectorAll(".polycss-shadow");
     expect(shadows.length).toBeGreaterThan(0);
     for (const el of Array.from(shadows)) {
-      expect(el.tagName.toLowerCase()).toBe("q");
+      expect(el.tagName.toLowerCase()).toBe("svg");
+      expect(el.classList.contains("polycss-shadow-svg")).toBe(true);
     }
   });
 
-  it("shadow leaves have border-shape set", () => {
-    const { container } = renderScene(DYN_SCENE_PROPS, {
-      polygons: [TRIANGLE],
-      castShadow: true,
-    });
-    const shadows = container.querySelectorAll(".polycss-shadow");
-    expect(shadows.length).toBeGreaterThan(0);
-    for (const el of Array.from(shadows)) {
-      expect((el as HTMLElement).style.getPropertyValue("border-shape")).not.toBe("");
-    }
-  });
-
-  it("shadow leaf transform starts with var(--shadow-proj) then matrix3d", () => {
-    const { container } = renderScene(DYN_SCENE_PROPS, {
-      polygons: [TRIANGLE],
-      castShadow: true,
-    });
-    const shadow = container.querySelector(".polycss-shadow") as HTMLElement;
-    expect(shadow).not.toBeNull();
-    expect(shadow.style.transform).toMatch(/^var\(--shadow-proj\)\s+matrix3d\(/);
-  });
-
-  it("adding a casting mesh sets --shadow-ground-cssz on the scene element", () => {
-    const { container } = renderScene(DYN_SCENE_PROPS, {
-      polygons: [TRIANGLE],
-      castShadow: true,
-    });
-    const sceneEl = container.querySelector(".polycss-scene") as HTMLElement;
-    expect(sceneEl.style.getPropertyValue("--shadow-ground-cssz")).not.toBe("");
-  });
-
   it("toggling castShadow via prop updates adds/removes shadow leaves", () => {
     const { container, root } = renderScene(DYN_SCENE_PROPS, {
       polygons: [TRIANGLE],
@@ -198,21 +170,20 @@ describe("PolyMesh — castShadow", () => {
     expect(container.querySelectorAll(".polycss-shadow").length).toBe(0);
   });
 
-  it("switching scene from dynamic to baked replaces per-polygon  with one  shadow", () => {
+  it("switching scene lighting mode keeps the per-mesh  shadow", () => {
     const { container, root } = renderScene(DYN_SCENE_PROPS, {
       polygons: [TRIANGLE],
       castShadow: true,
     });
-    const dynamicShadow = container.querySelector(".polycss-shadow") as HTMLElement;
-    expect(dynamicShadow.tagName.toLowerCase()).toBe("q");
-    expect(dynamicShadow.style.transform).toContain("var(--shadow-proj)");
+    const before = container.querySelector(".polycss-shadow") as SVGSVGElement;
+    expect(before.tagName.toLowerCase()).toBe("svg");
+    expect(before.style.transform).toMatch(/^translate3d\(/);
 
     rerender(root, { textureLighting: "baked" }, { polygons: [TRIANGLE], castShadow: true });
-    const bakedShadow = container.querySelector(".polycss-shadow") as SVGSVGElement;
-    expect(bakedShadow).not.toBeNull();
-    expect(bakedShadow.tagName.toLowerCase()).toBe("svg");
-    expect(bakedShadow.style.transform).not.toContain("var(--shadow-proj)");
-    expect(bakedShadow.style.transform).toMatch(/^translate3d\(/);
+    const after = container.querySelector(".polycss-shadow") as SVGSVGElement;
+    expect(after).not.toBeNull();
+    expect(after.tagName.toLowerCase()).toBe("svg");
+    expect(after.style.transform).toMatch(/^translate3d\(/);
   });
 
   it("textured polygons (s) ALSO emit shadow leaves (Frog Guy regression)", () => {
diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx
index d97b9a33..24c5f4d3 100644
--- a/packages/react/src/scene/PolyMesh.tsx
+++ b/packages/react/src/scene/PolyMesh.tsx
@@ -619,57 +619,17 @@ export const PolyMesh = forwardRef(function PolyM
     };
   }, [sceneRegisterShadowCaster, castShadow, polygons]);
 
-  // Build shadow leaf elements. Two paths:
-  //   - Dynamic: one `` per casting polygon, transform chains
-  //     `var(--shadow-proj) matrix3d(...)` so the projection follows
-  //     live light updates on the scene root; CSS opacity calc gates
-  //     back-facing polys.
-  //   - Baked: a single `` per mesh containing one `` per
-  //     caster polygon (projected to ground on the CPU), grouped under
-  //     `` so overlapping outlines composite as one
-  //     silhouette before alpha is applied. SVG content is internally
-  //     2D so this sidesteps the `opacity + preserve-3d` flatten trap.
-  //     Back-facing polys are dropped up front.
+  // Per-mesh shadow `` — same path for both lighting modes. Every
+  // casting polygon is projected to the ground on the CPU and
+  // concatenated into one compound  under
+  // fill-rule=nonzero, so overlapping CCW outlines composite as one
+  // filled silhouette without alpha stacking; gaps between subpaths
+  // remain as gaps (the shadow preserves the silhouette's holes for
+  // free); back-facing polys are dropped up front.
   const bakedShadowGroundCssZ = sceneCtx?.groundCssZ ?? null;
-  const shadowLeaves = useMemo(() => {
-    if (!castShadow || renderPolygon) return [];
-    const isDynamic = effectiveTextureLighting === "dynamic";
-    if (!isDynamic) return [];
-
-    const shadowColor = sceneCtx?.shadow?.color ?? "#000000";
-    const shadowOpacity = sceneCtx?.shadow?.opacity ?? 0.25;
-    const parsed = parseHexColor(shadowColor)?.rgb ?? [0, 0, 0];
-    const shadowColorCss = `rgba(${parsed[0]},${parsed[1]},${parsed[2]},${shadowOpacity})`;
-
-    const shadowDedupDrop = findOverlappingPolygonDuplicates(polygons, {
-      normalTolerance: 0.1,
-      distanceTolerance: 0.5,
-      overlapFraction: 0.4,
-    });
-
-    const leaves: React.ReactNode[] = [];
-    for (const plan of atlasPlans) {
-      if (!plan) continue;
-      if (shadowDedupDrop.has(plan.index)) continue;
-
-      const borderShape = cssBorderShapeForPlan(plan);
-      leaves.push(
-        
-      );
-    }
-    return leaves;
-  }, [castShadow, effectiveTextureLighting, renderPolygon, polygons, atlasPlans, sceneCtx?.shadow]);
-
-  // Baked-mode SVG shadow: single per-mesh element.
   const sceneShadow = sceneCtx?.shadow;
   const shadowSvgNode = useMemo(() => {
     if (!castShadow || renderPolygon) return null;
-    if (effectiveTextureLighting === "dynamic") return null;
     if (bakedShadowGroundCssZ === null) return null;
 
     const lightDir = sceneDirectionalLight?.direction
@@ -755,7 +715,7 @@ export const PolyMesh = forwardRef(function PolyM
         />
       
     );
-  }, [castShadow, renderPolygon, effectiveTextureLighting, polygons, atlasPlans, sceneDirectionalLight, bakedShadowGroundCssZ, sceneShadow]);
+  }, [castShadow, renderPolygon, polygons, atlasPlans, sceneDirectionalLight, bakedShadowGroundCssZ, sceneShadow]);
 
   setPolygonsImplRef.current = (nextPolygons: Polygon[]) => {
     const nextRenderedPolygons = autoCenter ? recenterPolygons(nextPolygons) : nextPolygons;
@@ -879,7 +839,6 @@ export const PolyMesh = forwardRef(function PolyM
       {...wrapperHandlers}
     >
       {shadowSvgNode}
-      {shadowLeaves}
       {renderedPolygons}
       {staticChildren}
     
@@ -901,41 +860,3 @@ function RenderPropPolygon({
   return <>{children(polygon, index)};
 }
 
-// Dynamic-mode shadow leaf — one  per caster polygon, transform
-// chains `var(--shadow-proj) matrix3d(...)` so the projection follows
-// the live light vars on the scene root. --pnx/y/z is pinned so the CSS
-// opacity gate in styles.ts hides back-facing polys at paint time.
-// Baked mode uses a single per-mesh  instead (see shadowSvgNode in
-// PolyMeshInner above).
-// Uses a ref callback for border-shape (non-standard CSS property, must
-// be set via setProperty).
-function ShadowLeaf({
-  plan,
-  shadowColorCss,
-  borderShape,
-}: {
-  plan: TextureAtlasPlan;
-  shadowColorCss: string;
-  borderShape: string;
-}) {
-  const setRef = useCallback((el: HTMLElement | null) => {
-    if (!el) return;
-    el.style.setProperty("border-shape", borderShape);
-  }, [borderShape]);
-
-  return (
-    
-  );
-}
diff --git a/packages/vue/src/scene/PolyMesh.castShadow.test.ts b/packages/vue/src/scene/PolyMesh.castShadow.test.ts
index 3d9e1cb0..cfb76220 100644
--- a/packages/vue/src/scene/PolyMesh.castShadow.test.ts
+++ b/packages/vue/src/scene/PolyMesh.castShadow.test.ts
@@ -86,12 +86,16 @@ describe("PolyMesh (Vue) — castShadow", () => {
     expect(container.querySelectorAll(".polycss-shadow").length).toBe(0);
   });
 
-  it("castShadow:true in dynamic mode emits shadow leaves, one per non-duplicate polygon", () => {
+  it("castShadow:true in dynamic mode emits a single  shadow per mesh (same path as baked)", async () => {
     const { container } = mount(DYNAMIC_SCENE_PROPS, {
       polygons: [TRIANGLE, DISTINCT_TRIANGLE],
       castShadow: true,
     });
-    expect(container.querySelectorAll(".polycss-shadow").length).toBe(2);
+    await nextTick();
+    await nextTick();
+    const shadows = container.querySelectorAll(".polycss-shadow");
+    expect(shadows.length).toBe(1);
+    expect(shadows[0]!.tagName.toLowerCase()).toBe("svg");
   });
 
   it("castShadow:true in baked mode emits a single  shadow per mesh with one compound ", async () => {
@@ -124,41 +128,21 @@ describe("PolyMesh (Vue) — castShadow", () => {
     expect((d.match(/Z/g) || []).length).toBe(1);
   });
 
-  it("shadow leaves are always  with class polycss-shadow", () => {
+  it("shadow elements are always  with class polycss-shadow regardless of mode", async () => {
     const { container } = mount(DYNAMIC_SCENE_PROPS, {
       polygons: [TRIANGLE, DISTINCT_TRIANGLE],
       castShadow: true,
     });
+    await nextTick();
+    await nextTick();
     const shadows = Array.from(container.querySelectorAll(".polycss-shadow"));
     expect(shadows.length).toBeGreaterThan(0);
     for (const el of shadows) {
-      expect(el.tagName.toLowerCase()).toBe("q");
-      expect(el.classList.contains("polycss-shadow")).toBe(true);
-    }
-  });
-
-  it("shadow leaves have border-shape set", () => {
-    const { container } = mount(DYNAMIC_SCENE_PROPS, {
-      polygons: [TRIANGLE, DISTINCT_TRIANGLE],
-      castShadow: true,
-    });
-    const shadows = Array.from(container.querySelectorAll(".polycss-shadow")) as HTMLElement[];
-    expect(shadows.length).toBeGreaterThan(0);
-    for (const el of shadows) {
-      expect(el.style.getPropertyValue("border-shape")).not.toBe("");
+      expect(el.tagName.toLowerCase()).toBe("svg");
+      expect(el.classList.contains("polycss-shadow-svg")).toBe(true);
     }
   });
 
-  it("shadow leaves transform contains var(--shadow-proj) followed by matrix3d", () => {
-    const { container } = mount(DYNAMIC_SCENE_PROPS, {
-      polygons: [TRIANGLE],
-      castShadow: true,
-    });
-    const shadow = container.querySelector(".polycss-shadow") as HTMLElement;
-    expect(shadow).not.toBeNull();
-    expect(shadow.style.transform).toMatch(/^var\(--shadow-proj\)\s+matrix3d\(/);
-  });
-
   it("adding a casting mesh sets --shadow-ground-cssz on the scene element", async () => {
     const { container } = mount(DYNAMIC_SCENE_PROPS, {
       polygons: [TRIANGLE],
@@ -212,11 +196,13 @@ describe("PolyMesh (Vue) — castShadow", () => {
     expect(container.querySelectorAll(".polycss-shadow").length).toBe(0);
   });
 
-  it("textured polygons (s) ALSO emit shadow leaves", () => {
+  it("textured polygons (s) ALSO emit shadow leaves", async () => {
     const { container } = mount(DYNAMIC_SCENE_PROPS, {
       polygons: [TEXTURED_TRIANGLE],
       castShadow: true,
     });
+    await nextTick();
+    await nextTick();
     expect(container.querySelectorAll(".polycss-shadow").length).toBe(1);
   });
 
@@ -236,24 +222,15 @@ describe("PolyMesh (Vue) — castShadow", () => {
     expect(sceneEl.style.getPropertyValue("--clz")).toBe("");
   });
 
-  it("shadow leaves have --pnx/--pny/--pnz inline for Lambert gate", () => {
-    const { container } = mount(DYNAMIC_SCENE_PROPS, {
-      polygons: [TRIANGLE],
-      castShadow: true,
-    });
-    const shadow = container.querySelector(".polycss-shadow") as HTMLElement;
-    expect(shadow).not.toBeNull();
-    expect(shadow.style.getPropertyValue("--pnx")).not.toBe("");
-    expect(shadow.style.getPropertyValue("--pny")).not.toBe("");
-    expect(shadow.style.getPropertyValue("--pnz")).not.toBe("");
-  });
-
-  it("duplicate coincident polygons emit only one shadow leaf", () => {
-    // Two triangles at the same position should be deduped to one shadow leaf.
+  it("duplicate coincident polygons collapse into the same per-mesh shadow ", async () => {
+    // Two triangles at the same position both contribute to the same
+    // mesh's compound SVG path (the loose dedup pass drops the second).
     const { container } = mount(DYNAMIC_SCENE_PROPS, {
       polygons: [TRIANGLE, { ...TRIANGLE }],
       castShadow: true,
     });
+    await nextTick();
+    await nextTick();
     expect(container.querySelectorAll(".polycss-shadow").length).toBe(1);
   });
 
diff --git a/packages/vue/src/scene/PolyMesh.ts b/packages/vue/src/scene/PolyMesh.ts
index ef64fc53..3db54aa6 100644
--- a/packages/vue/src/scene/PolyMesh.ts
+++ b/packages/vue/src/scene/PolyMesh.ts
@@ -307,67 +307,13 @@ export const PolyMesh = defineComponent({
     );
     const defaultPaintVars = computed(() => solidPaintVars(solidPaintDefaults.value));
 
-    // Dynamic-mode shadow leaves — one  per casting polygon whose
-    // transform chains var(--shadow-proj) so shadows reflow as the light
-    // moves. --pnx/y/z drive the CSS opacity gate that hides back-facing
-    // polys. Baked mode uses the per-mesh  below instead.
-    const shadowNodes = computed>(() => {
-      if (!props.castShadow) return [];
-      if (atlasTextureLighting.value !== "dynamic") return [];
-
-      const ctx = sceneCtx?.value;
-      const shadowOpts = ctx?.shadow;
-      const shadowColor = shadowOpts?.color ?? "#000000";
-      const shadowOpacity = shadowOpts?.opacity ?? 0.25;
-      const parsed = parseHexColor(shadowColor)?.rgb ?? [0, 0, 0];
-      const shadowColorCss = `rgba(${parsed[0]},${parsed[1]},${parsed[2]},${shadowOpacity})`;
-
-      const plans = textureAtlasPlans.value;
-      if (plans.length === 0) return [];
-
-      const dedupDrop = findOverlappingPolygonDuplicates(polygons.value, {
-        normalTolerance: 0.1,
-        distanceTolerance: 0.5,
-        overlapFraction: 0.4,
-      });
-
-      return plans.map((plan, index) => {
-        if (!plan) return null;
-        if (dedupDrop.has(index)) return null;
-        const origMatrix = `matrix3d(${plan.matrix})`;
-        const borderShape = cssBorderShapeForPlan(plan);
-        const style: CSSProperties = {
-          transform: `var(--shadow-proj) ${origMatrix}`,
-          color: shadowColorCss,
-          width: `${plan.canvasW}px`,
-          height: `${plan.canvasH}px`,
-          "--pnx": plan.normal[0].toFixed(4),
-          "--pny": plan.normal[1].toFixed(4),
-          "--pnz": plan.normal[2].toFixed(4),
-        };
-
-        const applyShadowBorderShape = (vnode: VNode) => {
-          const el = vnode.el as HTMLElement | null;
-          if (!el) return;
-          el.style.setProperty("border-shape", borderShape);
-        };
-
-        return h("q", {
-          class: "polycss-shadow",
-          style,
-          onVnodeMounted: applyShadowBorderShape,
-          onVnodeUpdated: applyShadowBorderShape,
-        });
-      });
-    });
-
-    // Baked-mode SVG shadow — single per-mesh  with one  per
-    // caster polygon. Overlapping outlines composite as one silhouette
-    // inside the  before alpha is applied, sidestepping
-    // the `opacity + preserve-3d` flatten trap.
+    // Per-mesh SVG shadow — same path for both lighting modes. Every
+    // casting polygon is projected to the ground on the CPU and
+    // concatenated into one compound  under
+    // fill-rule=nonzero so overlapping CCW outlines composite as one
+    // filled silhouette without alpha stacking; gaps remain as gaps.
     const shadowSvg = computed(() => {
       if (!props.castShadow) return null;
-      if (atlasTextureLighting.value === "dynamic") return null;
       const ctx = sceneCtx?.value;
       const groundCssZ = ctx?.groundCssZ ?? null;
       if (groundCssZ === null) return null;
@@ -787,11 +733,9 @@ export const PolyMesh = defineComponent({
       // Static default slot children (e.g. additional  children)
       const defaultChildren = slots.default?.() ?? [];
 
-      // Shadow elements go before polygon nodes so they sit below casters
-      // in DOM order — painter-order tie-breaking favors earlier nodes when
-      // both are coplanar in 3D. Dynamic mode emits per-polygon ; baked
-      // mode emits a single  per mesh (see shadowSvg above).
-      const shadows = shadowNodes.value;
+      // Shadow goes before polygon nodes so it sits below casters in DOM
+      // order — painter-order tie-breaking favors earlier nodes when both
+      // are coplanar in 3D. Single  per mesh (see shadowSvg above).
       const svgNode = shadowSvg.value;
       const shadowChildren: VNode[] = svgNode ? [svgNode] : [];
 
@@ -805,7 +749,7 @@ export const PolyMesh = defineComponent({
           ...handlers,
           ...extraAttrs,
         },
-        [...shadowChildren, ...shadows, ...polyNodes, ...defaultChildren]
+        [...shadowChildren, ...polyNodes, ...defaultChildren]
       );
     };
   },

From 393dce5601e17a69a9f6a02edbc79145b8e4fd8c Mon Sep 17 00:00:00 2001
From: Juan Cruz Fortunatti 
Date: Sat, 23 May 2026 15:37:08 +0200
Subject: [PATCH 17/28] fix: cover sub-pixel shadow seams with same-color
 hairline stroke

---
 bench/bat-shadow-diagnose.mjs               | 151 ++++++++++++++++++++
 packages/polycss/src/api/createPolyScene.ts |  10 +-
 packages/react/src/scene/PolyMesh.tsx       |   3 +
 packages/vue/src/scene/PolyMesh.ts          |   3 +
 4 files changed, 166 insertions(+), 1 deletion(-)
 create mode 100644 bench/bat-shadow-diagnose.mjs

diff --git a/bench/bat-shadow-diagnose.mjs b/bench/bat-shadow-diagnose.mjs
new file mode 100644
index 00000000..22204ba8
--- /dev/null
+++ b/bench/bat-shadow-diagnose.mjs
@@ -0,0 +1,151 @@
+#!/usr/bin/env node
+/**
+ * Visits the gallery at the user-provided model URL, enables castShadow,
+ * and dumps:
+ *   - the shadow SVG outerHTML (truncated)
+ *   - the subpath count + winding signs for each M…L…Z block
+ *   - a screenshot of the result
+ *
+ * Goal: figure out why the bat model gets holes in its shadow even after
+ * the per-polygon CCW normalization. Suspects: degenerate (near-zero
+ * area) projections, self-intersecting non-convex merged polys.
+ */
+import { chromium } from "playwright";
+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 URL_STR = optStr("url", "http://localhost:4321/gallery?model=922117102");
+const HEADED = hasFlag("headed");
+
+const outDir = resolve(repoRoot, "bench/results/bat-shadow");
+await mkdir(outDir, { recursive: true });
+
+const browser = await chromium.launch({
+  headless: !HEADED,
+  args: chromiumArgsWithGpuDefault([], { softwareBackend: false }),
+});
+
+try {
+  const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } });
+  const page = await ctx.newPage();
+  page.on("pageerror", (err) => console.log(`[pageerror] ${err.message}`));
+
+  await page.goto(URL_STR, { waitUntil: "networkidle", timeout: 30000 });
+  await page.waitForFunction(
+    () => !!document.querySelector(".polycss-mesh"),
+    { timeout: 30000 },
+  );
+  await page.waitForTimeout(1500);
+
+  // Toggle Cast shadow + Show ground via the tweakpane labels.
+  await page.evaluate(() => {
+    const clickToggle = (labelText) => {
+      const all = Array.from(document.querySelectorAll("div, span, label"));
+      const labelEl = all.find((el) => (el.textContent || "").trim() === labelText);
+      if (!labelEl) return false;
+      let parent = labelEl.parentElement;
+      for (let i = 0; i < 8 && parent; i++) {
+        const cb = parent.querySelector('input[type="checkbox"]');
+        if (cb) {
+          if (!cb.checked) cb.click();
+          return true;
+        }
+        parent = parent.parentElement;
+      }
+      return false;
+    };
+    clickToggle("Cast shadow");
+    clickToggle("Show ground");
+  });
+  await page.waitForTimeout(1000);
+
+  // Save the raw shadow SVG outerHTML so we can render it standalone on a
+  // white background — the gallery's dark background hides any actual holes.
+  const rawSvg = await page.evaluate(() => {
+    const svg = document.querySelector("svg.polycss-shadow");
+    return svg ? svg.outerHTML : null;
+  });
+  if (rawSvg) {
+    await writeFile(resolve(outDir, "shadow-extracted.html"),
+      `
+       
+ ${rawSvg.replace(/transform:[^"]*"/, 'transform:translate(0,0)"')} +
`); + } + + // Snapshot the shadow SVG and analyze its compound path. + const snapshot = await page.evaluate(() => { + const svg = document.querySelector("svg.polycss-shadow"); + if (!svg) return { found: false }; + const paths = svg.querySelectorAll("path"); + const all = Array.from(paths).map((path) => { + const d = path.getAttribute("d") || ""; + // Split d into M…Z subpaths. + const subpaths = d.split("Z").filter((s) => s.trim().length > 0); + const analyzed = subpaths.map((sub) => { + const cleaned = sub.replace(/^M/, ""); + // Each token is "x,y" separated by "L". + const tokens = cleaned.split("L"); + const verts = tokens.map((t) => t.split(",").map(Number)); + // Signed area (positive = CCW in math; negative = CW). + let a = 0; + for (let i = 0; i < verts.length; i++) { + const p = verts[i]; + const q = verts[(i + 1) % verts.length]; + a += p[0] * q[1] - q[0] * p[1]; + } + return { + n: verts.length, + signedArea: a / 2, + }; + }); + // Per-subpath winding summary. + const ccw = analyzed.filter((s) => s.signedArea > 0).length; + const cw = analyzed.filter((s) => s.signedArea < 0).length; + const zero = analyzed.filter((s) => Math.abs(s.signedArea) < 1e-6).length; + const minArea = analyzed.length > 0 ? Math.min(...analyzed.map((s) => Math.abs(s.signedArea))) : 0; + const maxArea = analyzed.length > 0 ? Math.max(...analyzed.map((s) => Math.abs(s.signedArea))) : 0; + return { + subpathCount: subpaths.length, + ccw, cw, zero, + minArea, + maxArea, + fillRule: path.getAttribute("fill-rule"), + opacity: path.getAttribute("opacity"), + dLength: d.length, + }; + }); + return { + found: true, + svgClass: svg.getAttribute("class"), + svgWidth: svg.getAttribute("width"), + svgHeight: svg.getAttribute("height"), + svgTransform: svg.style.transform.slice(0, 100), + pathCount: paths.length, + paths: all, + }; + }); + + console.log(JSON.stringify(snapshot, null, 2)); + + const shotPath = resolve(outDir, "shadow.png"); + await page.screenshot({ path: shotPath, fullPage: false }); + console.log(`Screenshot: ${shotPath}`); + await writeFile(resolve(outDir, "report.json"), JSON.stringify(snapshot, null, 2)); +} finally { + await browser.close(); +} diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index 6e0e5026..94f4874c 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -1289,8 +1289,16 @@ export function createPolyScene( const path = doc.createElementNS(svgNS, "path"); path.setAttribute("d", d); - path.setAttribute("fill", `rgb(${r},${g},${b})`); + const fillColor = `rgb(${r},${g},${b})`; + path.setAttribute("fill", fillColor); path.setAttribute("fill-rule", "nonzero"); + // Hairline stroke in the same color as the fill: covers the + // sub-pixel cracks between adjacent projected polygons (their shared + // edges don't rasterize exactly under nonzero fill, leaving visible + // 1-px slivers when zoomed out). Round joins smooth sharp corners. + path.setAttribute("stroke", fillColor); + path.setAttribute("stroke-width", "2"); + path.setAttribute("stroke-linejoin", "round"); path.setAttribute("opacity", opacity.toFixed(4)); svg.appendChild(path); diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx index 24c5f4d3..7fd287b8 100644 --- a/packages/react/src/scene/PolyMesh.tsx +++ b/packages/react/src/scene/PolyMesh.tsx @@ -711,6 +711,9 @@ export const PolyMesh = forwardRef(function PolyM d={d} fill={`rgb(${parsed[0]},${parsed[1]},${parsed[2]})`} fillRule="nonzero" + stroke={`rgb(${parsed[0]},${parsed[1]},${parsed[2]})`} + strokeWidth="2" + strokeLinejoin="round" opacity={shadowOpacity.toFixed(4)} /> diff --git a/packages/vue/src/scene/PolyMesh.ts b/packages/vue/src/scene/PolyMesh.ts index 3db54aa6..2fa81da2 100644 --- a/packages/vue/src/scene/PolyMesh.ts +++ b/packages/vue/src/scene/PolyMesh.ts @@ -401,6 +401,9 @@ export const PolyMesh = defineComponent({ d, fill: `rgb(${parsed[0]},${parsed[1]},${parsed[2]})`, "fill-rule": "nonzero", + stroke: `rgb(${parsed[0]},${parsed[1]},${parsed[2]})`, + "stroke-width": "2", + "stroke-linejoin": "round", opacity: shadowOpacity.toFixed(4), }), ], From ccc977778826fee65e46bf929cbdf6865ecc0703 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 23 May 2026 15:44:30 +0200 Subject: [PATCH 18/28] fix(shadow): project all polygons (no Lambert cull) so thin meshes get full silhouettes --- packages/polycss/src/api/createPolyScene.test.ts | 15 +++++++++------ packages/polycss/src/api/createPolyScene.ts | 12 +++++++----- packages/react/src/scene/PolyMesh.tsx | 6 ++++-- packages/vue/src/scene/PolyMesh.ts | 4 ++-- 4 files changed, 22 insertions(+), 15 deletions(-) diff --git a/packages/polycss/src/api/createPolyScene.test.ts b/packages/polycss/src/api/createPolyScene.test.ts index ce4c8612..da0ec7de 100644 --- a/packages/polycss/src/api/createPolyScene.test.ts +++ b/packages/polycss/src/api/createPolyScene.test.ts @@ -1918,14 +1918,17 @@ describe("createPolyScene", () => { expect((d.match(/Z/g) || []).length).toBe(1); }); - it("baked mode skips shadow leaves for polygons facing away from the light", () => { - // backTriangle wound CW from +Z → surface normal is -Z. Default light - // has +Z component, so the triangle is on the lit-AWAY side and - // should NOT emit a shadow leaf (its projection would land inside - // any other caster's silhouette anyway). + it("baked mode projects every polygon (no Lambert cull) so thin/open meshes don't get silhouette holes", () => { + // backTriangle has its surface normal pointing AWAY from the + // default light. We deliberately do NOT cull these by Lambert + // facing in the SVG path — a thin mesh (cloth, bat wings) needs + // both sides projected, or its silhouette gets visible holes + // where the back-facing piece would have contributed. With SVG + // fill-rule=nonzero merging overlap into one solid silhouette, + // including the back-facing polys is geometrically correct. scene = makeScene(host, { textureLighting: "baked" }); scene.add(makeParseResult([backTriangle()]), { castShadow: true }); - expect(host.querySelectorAll(".polycss-shadow").length).toBe(0); + expect(host.querySelectorAll(".polycss-shadow").length).toBe(1); }); it("shadow leaves have the polycss-shadow class", () => { diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index 94f4874c..21975e3e 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -46,7 +46,6 @@ import { findOverlappingPolygonDuplicates, inverseRotateVec3, isAxisAlignedSurfaceNormal, - isBakedShadowCaster, isVoxelCameraCullableNormalGroups, normalFacesCamera, optimizeMeshPolygons, @@ -1218,14 +1217,17 @@ export function createPolyScene( ): void { const polyProjections: Array> = []; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - // Iterate all rendered polys (not camera-filtered) — a casting polygon - // hidden from the camera can still project a visible shadow onto the - // ground. The light-facing filter below does the real culling. + // Iterate all rendered polys. We deliberately do NOT cull by Lambert + // facing here: closed convex meshes have their lit-side polys alone + // tile the silhouette, but thin/open meshes (a bat with spread + // wings, cloth, a single quad) have both sides contributing to the + // outline — culling one side leaves real holes in the shadow. The + // extra projections cost is cheap, and SVG's nonzero rule merges + // any overlap into a single silhouette. for (const item of entry.rendered) { if (dedupDrop.has(item.polygonIndex)) continue; const plan = item.plan; if (!plan) continue; - if (!isBakedShadowCaster(plan.normal, lightDir)) continue; const polygon = entry.polygons[item.polygonIndex]; if (!polygon) continue; diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx index 7fd287b8..874703e0 100644 --- a/packages/react/src/scene/PolyMesh.tsx +++ b/packages/react/src/scene/PolyMesh.tsx @@ -38,7 +38,6 @@ import { ensureCcw2D, findOverlappingPolygonDuplicates, inverseRotateVec3, - isBakedShadowCaster, parseHexColor, projectCssVertexToGround, } from "@layoutit/polycss-core"; @@ -642,12 +641,15 @@ export const PolyMesh = forwardRef(function PolyM const projections: Array> = []; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + // Iterate every casting polygon — no Lambert cull. Closed convex + // meshes don't need the back side, but thin/open meshes (bat wings, + // cloth, single quad) need both sides projected or the silhouette + // gets real holes. for (let i = 0; i < polygons.length; i++) { const polygon = polygons[i]!; if (shadowDedupDrop.has(i)) continue; const plan = atlasPlans[i]; if (!plan) continue; - if (!isBakedShadowCaster(plan.normal, lightDir)) continue; const projected: Array<[number, number]> = []; for (const v of polygon.vertices) { const cssVertex: Vec3 = [ diff --git a/packages/vue/src/scene/PolyMesh.ts b/packages/vue/src/scene/PolyMesh.ts index 2fa81da2..76a93381 100644 --- a/packages/vue/src/scene/PolyMesh.ts +++ b/packages/vue/src/scene/PolyMesh.ts @@ -26,7 +26,6 @@ import { ensureCcw2D, inverseRotateVec3, findOverlappingPolygonDuplicates, - isBakedShadowCaster, parseHexColor, projectCssVertexToGround, } from "@layoutit/polycss-core"; @@ -330,11 +329,12 @@ export const PolyMesh = defineComponent({ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; const polys = polygons.value; const plans = textureAtlasPlans.value; + // No Lambert cull — thin/open meshes (bat wings, cloth, single + // quad) need both sides projected or the silhouette gets holes. for (let i = 0; i < polys.length; i++) { if (dedupDrop.has(i)) continue; const plan = plans[i]; if (!plan) continue; - if (!isBakedShadowCaster(plan.normal, lightDir)) continue; const polygon = polys[i]!; const projected: Array<[number, number]> = []; for (const v of polygon.vertices) { From 8d6dbf4c90408b899a65e9a41b630d72b19134f8 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 23 May 2026 15:49:38 +0200 Subject: [PATCH 19/28] perf(shadow): cap SVG dimensions + GPU-promote to stop low-elevation flicker --- packages/polycss/src/api/createPolyScene.ts | 39 ++++++++++++++++----- packages/react/src/scene/PolyMesh.tsx | 32 +++++++++++++---- packages/vue/src/scene/PolyMesh.ts | 31 ++++++++++++---- 3 files changed, 82 insertions(+), 20 deletions(-) diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index 21975e3e..bc3f6e7c 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -1252,8 +1252,28 @@ export function createPolyScene( } if (polyProjections.length === 0) return; - const width = maxX - minX; - const height = maxY - minY; + // Cap the SVG's intrinsic dimensions. Low-elevation lights shear + // projected polygons across the ground so far that the bbox can + // exceed tens of thousands of pixels each side, which forces the + // browser to rasterize a >100M-pixel backing store on every repaint + // (visible as scene-wide flicker when the camera or light moves). + // We clamp the bbox around its center and let SVG's default + // overflow:hidden clip any path data that lands outside — at this + // size the clipped region is far off-screen anyway. + const SHADOW_MAX_DIM = 8000; + let bx0 = minX, by0 = minY, bx1 = maxX, by1 = maxY; + if (bx1 - bx0 > SHADOW_MAX_DIM) { + const cx = (bx0 + bx1) / 2; + bx0 = cx - SHADOW_MAX_DIM / 2; + bx1 = cx + SHADOW_MAX_DIM / 2; + } + if (by1 - by0 > SHADOW_MAX_DIM) { + const cy = (by0 + by1) / 2; + by0 = cy - SHADOW_MAX_DIM / 2; + by1 = cy + SHADOW_MAX_DIM / 2; + } + const width = bx1 - bx0; + const height = by1 - by0; if (!(width > 0) || !(height > 0)) return; // Concatenate every projected polygon into ONE compound `d` string — @@ -1266,9 +1286,9 @@ export function createPolyScene( let d = ""; for (const verts of polyProjections) { const ccw = ensureCcw2D(verts); - d += `M${(ccw[0]![0] - minX).toFixed(3)},${(ccw[0]![1] - minY).toFixed(3)}`; + d += `M${(ccw[0]![0] - bx0).toFixed(3)},${(ccw[0]![1] - by0).toFixed(3)}`; for (let i = 1; i < ccw.length; i++) { - d += `L${(ccw[i]![0] - minX).toFixed(3)},${(ccw[i]![1] - minY).toFixed(3)}`; + d += `L${(ccw[i]![0] - bx0).toFixed(3)},${(ccw[i]![1] - by0).toFixed(3)}`; } d += "Z"; } @@ -1281,12 +1301,15 @@ export function createPolyScene( svg.setAttribute("viewBox", `0 0 ${width} ${height}`); // CSS-Z places the SVG plane at the ground in the mesh's local frame; // the mesh wrapper's own transform is applied above this. X/Y origin - // shifts the SVG so its (0,0) lines up with the projected bbox corner. + // shifts the SVG so its (0,0) lines up with the (clamped) bbox corner. + // overflow:hidden + will-change:transform: bound the backing store + // and keep the SVG on its own GPU layer so scene repaints don't + // re-rasterize the whole sheet. svg.setAttribute( "style", - `position:absolute;top:0;left:0;display:block;overflow:visible;` + - `transform-origin:0 0;pointer-events:none;` + - `transform:translate3d(${minX.toFixed(3)}px,${minY.toFixed(3)}px,${groundCssZ.toFixed(3)}px)`, + `position:absolute;top:0;left:0;display:block;overflow:hidden;` + + `transform-origin:0 0;pointer-events:none;will-change:transform;` + + `transform:translate3d(${bx0.toFixed(3)}px,${by0.toFixed(3)}px,${groundCssZ.toFixed(3)}px)`, ); const path = doc.createElementNS(svgNS, "path"); diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx index 874703e0..6fc18dd8 100644 --- a/packages/react/src/scene/PolyMesh.tsx +++ b/packages/react/src/scene/PolyMesh.tsx @@ -667,8 +667,27 @@ export const PolyMesh = forwardRef(function PolyM projections.push(projected); } if (projections.length === 0) return null; - const width = maxX - minX; - const height = maxY - minY; + // Cap the SVG's intrinsic dimensions. Low-elevation lights shear + // projected polygons across the ground so far that the bbox can + // exceed tens of thousands of pixels each side, which forces the + // browser to rasterize a >100M-pixel backing store on every repaint + // (visible as scene-wide flicker when the camera or light moves). + // overflow:hidden clips paths that land outside the (already + // off-screen) cap. + const SHADOW_MAX_DIM = 8000; + let bx0 = minX, by0 = minY, bx1 = maxX, by1 = maxY; + if (bx1 - bx0 > SHADOW_MAX_DIM) { + const cx = (bx0 + bx1) / 2; + bx0 = cx - SHADOW_MAX_DIM / 2; + bx1 = cx + SHADOW_MAX_DIM / 2; + } + if (by1 - by0 > SHADOW_MAX_DIM) { + const cy = (by0 + by1) / 2; + by0 = cy - SHADOW_MAX_DIM / 2; + by1 = cy + SHADOW_MAX_DIM / 2; + } + const width = bx1 - bx0; + const height = by1 - by0; if (!(width > 0) || !(height > 0)) return null; const shadowColor = sceneShadow?.color ?? "#000000"; @@ -684,9 +703,9 @@ export const PolyMesh = forwardRef(function PolyM let d = ""; for (const verts of projections) { const ccw = ensureCcw2D(verts); - d += `M${(ccw[0]![0] - minX).toFixed(3)},${(ccw[0]![1] - minY).toFixed(3)}`; + d += `M${(ccw[0]![0] - bx0).toFixed(3)},${(ccw[0]![1] - by0).toFixed(3)}`; for (let j = 1; j < ccw.length; j++) { - d += `L${(ccw[j]![0] - minX).toFixed(3)},${(ccw[j]![1] - minY).toFixed(3)}`; + d += `L${(ccw[j]![0] - bx0).toFixed(3)},${(ccw[j]![1] - by0).toFixed(3)}`; } d += "Z"; } @@ -703,10 +722,11 @@ export const PolyMesh = forwardRef(function PolyM top: 0, left: 0, display: "block", - overflow: "visible", + overflow: "hidden", transformOrigin: "0 0", pointerEvents: "none", - transform: `translate3d(${minX.toFixed(3)}px,${minY.toFixed(3)}px,${bakedShadowGroundCssZ.toFixed(3)}px)`, + willChange: "transform", + transform: `translate3d(${bx0.toFixed(3)}px,${by0.toFixed(3)}px,${bakedShadowGroundCssZ.toFixed(3)}px)`, }} > 100M-pixel backing store on every + // repaint (visible as scene-wide flicker when the camera or light + // moves). overflow:hidden clips paths that land outside the cap. + const SHADOW_MAX_DIM = 8000; + let bx0 = minX, by0 = minY, bx1 = maxX, by1 = maxY; + if (bx1 - bx0 > SHADOW_MAX_DIM) { + const cx = (bx0 + bx1) / 2; + bx0 = cx - SHADOW_MAX_DIM / 2; + bx1 = cx + SHADOW_MAX_DIM / 2; + } + if (by1 - by0 > SHADOW_MAX_DIM) { + const cy = (by0 + by1) / 2; + by0 = cy - SHADOW_MAX_DIM / 2; + by1 = cy + SHADOW_MAX_DIM / 2; + } + const width = bx1 - bx0; + const height = by1 - by0; if (!(width > 0) || !(height > 0)) return null; const shadowOpts = ctx?.shadow; @@ -371,9 +389,9 @@ export const PolyMesh = defineComponent({ let d = ""; for (const verts of projections) { const ccw = ensureCcw2D(verts); - d += `M${(ccw[0]![0] - minX).toFixed(3)},${(ccw[0]![1] - minY).toFixed(3)}`; + d += `M${(ccw[0]![0] - bx0).toFixed(3)},${(ccw[0]![1] - by0).toFixed(3)}`; for (let j = 1; j < ccw.length; j++) { - d += `L${(ccw[j]![0] - minX).toFixed(3)},${(ccw[j]![1] - minY).toFixed(3)}`; + d += `L${(ccw[j]![0] - bx0).toFixed(3)},${(ccw[j]![1] - by0).toFixed(3)}`; } d += "Z"; } @@ -390,10 +408,11 @@ export const PolyMesh = defineComponent({ top: "0", left: "0", display: "block", - overflow: "visible", + overflow: "hidden", transformOrigin: "0 0", pointerEvents: "none", - transform: `translate3d(${minX.toFixed(3)}px,${minY.toFixed(3)}px,${groundCssZ.toFixed(3)}px)`, + willChange: "transform", + transform: `translate3d(${bx0.toFixed(3)}px,${by0.toFixed(3)}px,${groundCssZ.toFixed(3)}px)`, } as CSSProperties, }, [ From 24cb021b59423da25ed3c0f840a000d572f4856b Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 23 May 2026 15:53:59 +0200 Subject: [PATCH 20/28] perf(shadow): anchor SVG cap to mesh footprint so shadow under mesh is preserved --- packages/polycss/src/api/createPolyScene.ts | 42 +++++++++++---------- packages/react/src/scene/PolyMesh.tsx | 41 ++++++++++---------- packages/vue/src/scene/PolyMesh.ts | 37 +++++++++--------- 3 files changed, 63 insertions(+), 57 deletions(-) diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index bc3f6e7c..a5b7ca06 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -1217,6 +1217,10 @@ export function createPolyScene( ): void { const polyProjections: Array> = []; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + // Footprint = the mesh's straight-down (no-shear) silhouette bbox, + // i.e. the XY of every vertex. Used by the cap below as the anchor + // the shadow must always fully contain. + let fpMinX = Infinity, fpMinY = Infinity, fpMaxX = -Infinity, fpMaxY = -Infinity; // Iterate all rendered polys. We deliberately do NOT cull by Lambert // facing here: closed convex meshes have their lit-side polys alone // tile the silhouette, but thin/open meshes (a bat with spread @@ -1241,6 +1245,10 @@ export function createPolyScene( v[0] * DEFAULT_TILE, v[2] * DEFAULT_TILE, ]; + if (cssVertex[0] < fpMinX) fpMinX = cssVertex[0]; + if (cssVertex[1] < fpMinY) fpMinY = cssVertex[1]; + if (cssVertex[0] > fpMaxX) fpMaxX = cssVertex[0]; + if (cssVertex[1] > fpMaxY) fpMaxY = cssVertex[1]; const p = projectCssVertexToGround(cssVertex, lightDir, groundCssZ); projected.push(p); if (p[0] < minX) minX = p[0]; @@ -1252,26 +1260,20 @@ export function createPolyScene( } if (polyProjections.length === 0) return; - // Cap the SVG's intrinsic dimensions. Low-elevation lights shear - // projected polygons across the ground so far that the bbox can - // exceed tens of thousands of pixels each side, which forces the - // browser to rasterize a >100M-pixel backing store on every repaint - // (visible as scene-wide flicker when the camera or light moves). - // We clamp the bbox around its center and let SVG's default - // overflow:hidden clip any path data that lands outside — at this - // size the clipped region is far off-screen anyway. - const SHADOW_MAX_DIM = 8000; - let bx0 = minX, by0 = minY, bx1 = maxX, by1 = maxY; - if (bx1 - bx0 > SHADOW_MAX_DIM) { - const cx = (bx0 + bx1) / 2; - bx0 = cx - SHADOW_MAX_DIM / 2; - bx1 = cx + SHADOW_MAX_DIM / 2; - } - if (by1 - by0 > SHADOW_MAX_DIM) { - const cy = (by0 + by1) / 2; - by0 = cy - SHADOW_MAX_DIM / 2; - by1 = cy + SHADOW_MAX_DIM / 2; - } + // Cap how far the shadow can extend BEYOND THE MESH FOOTPRINT. + // Low-elevation lights shear projections across the ground so far + // that the bbox can exceed tens of thousands of pixels each side, + // which forces the browser to rasterize a >100M-pixel backing store + // on every repaint (visible as scene-wide flicker when the camera + // or light moves). The footprint (no-shear silhouette) must stay + // fully inside the SVG so the shadow under/next to the mesh is + // preserved — we only truncate the sheared end that's off-screen + // anyway. SVG overflow:hidden does the actual clipping. + const SHADOW_MAX_EXTEND = 4000; + const bx0 = Math.max(minX, fpMinX - SHADOW_MAX_EXTEND); + const by0 = Math.max(minY, fpMinY - SHADOW_MAX_EXTEND); + const bx1 = Math.min(maxX, fpMaxX + SHADOW_MAX_EXTEND); + const by1 = Math.min(maxY, fpMaxY + SHADOW_MAX_EXTEND); const width = bx1 - bx0; const height = by1 - by0; if (!(width > 0) || !(height > 0)) return; diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx index 6fc18dd8..bf3ba1e4 100644 --- a/packages/react/src/scene/PolyMesh.tsx +++ b/packages/react/src/scene/PolyMesh.tsx @@ -641,6 +641,10 @@ export const PolyMesh = forwardRef(function PolyM const projections: Array> = []; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + // Footprint = the mesh's straight-down (no-shear) silhouette bbox, + // used by the cap below as the anchor the shadow must always fully + // contain. + let fpMinX = Infinity, fpMinY = Infinity, fpMaxX = -Infinity, fpMaxY = -Infinity; // Iterate every casting polygon — no Lambert cull. Closed convex // meshes don't need the back side, but thin/open meshes (bat wings, // cloth, single quad) need both sides projected or the silhouette @@ -657,6 +661,10 @@ export const PolyMesh = forwardRef(function PolyM v[0] * BASE_TILE, v[2] * BASE_TILE, ]; + if (cssVertex[0] < fpMinX) fpMinX = cssVertex[0]; + if (cssVertex[1] < fpMinY) fpMinY = cssVertex[1]; + if (cssVertex[0] > fpMaxX) fpMaxX = cssVertex[0]; + if (cssVertex[1] > fpMaxY) fpMaxY = cssVertex[1]; const p = projectCssVertexToGround(cssVertex, lightDir, bakedShadowGroundCssZ); projected.push(p); if (p[0] < minX) minX = p[0]; @@ -667,25 +675,20 @@ export const PolyMesh = forwardRef(function PolyM projections.push(projected); } if (projections.length === 0) return null; - // Cap the SVG's intrinsic dimensions. Low-elevation lights shear - // projected polygons across the ground so far that the bbox can - // exceed tens of thousands of pixels each side, which forces the - // browser to rasterize a >100M-pixel backing store on every repaint - // (visible as scene-wide flicker when the camera or light moves). - // overflow:hidden clips paths that land outside the (already - // off-screen) cap. - const SHADOW_MAX_DIM = 8000; - let bx0 = minX, by0 = minY, bx1 = maxX, by1 = maxY; - if (bx1 - bx0 > SHADOW_MAX_DIM) { - const cx = (bx0 + bx1) / 2; - bx0 = cx - SHADOW_MAX_DIM / 2; - bx1 = cx + SHADOW_MAX_DIM / 2; - } - if (by1 - by0 > SHADOW_MAX_DIM) { - const cy = (by0 + by1) / 2; - by0 = cy - SHADOW_MAX_DIM / 2; - by1 = cy + SHADOW_MAX_DIM / 2; - } + // Cap how far the shadow can extend BEYOND THE MESH FOOTPRINT. + // Low-elevation lights shear projections across the ground so far + // that the bbox can exceed tens of thousands of pixels each side, + // which forces the browser to rasterize a >100M-pixel backing store + // on every repaint (visible as scene-wide flicker when the camera + // or light moves). The footprint (no-shear silhouette) stays fully + // inside the SVG so the shadow under/next to the mesh is preserved + // — we only truncate the sheared end that's off-screen anyway. + // overflow:hidden does the actual clipping. + const SHADOW_MAX_EXTEND = 4000; + const bx0 = Math.max(minX, fpMinX - SHADOW_MAX_EXTEND); + const by0 = Math.max(minY, fpMinY - SHADOW_MAX_EXTEND); + const bx1 = Math.min(maxX, fpMaxX + SHADOW_MAX_EXTEND); + const by1 = Math.min(maxY, fpMaxY + SHADOW_MAX_EXTEND); const width = bx1 - bx0; const height = by1 - by0; if (!(width > 0) || !(height > 0)) return null; diff --git a/packages/vue/src/scene/PolyMesh.ts b/packages/vue/src/scene/PolyMesh.ts index e81ca24f..d687e7c3 100644 --- a/packages/vue/src/scene/PolyMesh.ts +++ b/packages/vue/src/scene/PolyMesh.ts @@ -327,10 +327,13 @@ export const PolyMesh = defineComponent({ const projections: Array> = []; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + let fpMinX = Infinity, fpMinY = Infinity, fpMaxX = -Infinity, fpMaxY = -Infinity; const polys = polygons.value; const plans = textureAtlasPlans.value; // No Lambert cull — thin/open meshes (bat wings, cloth, single // quad) need both sides projected or the silhouette gets holes. + // We also track the footprint (no-shear XY bbox) so the cap below + // keeps the area near the mesh fully inside the SVG. for (let i = 0; i < polys.length; i++) { if (dedupDrop.has(i)) continue; const plan = plans[i]; @@ -343,6 +346,10 @@ export const PolyMesh = defineComponent({ v[0] * BASE_TILE, v[2] * BASE_TILE, ]; + if (cssVertex[0] < fpMinX) fpMinX = cssVertex[0]; + if (cssVertex[1] < fpMinY) fpMinY = cssVertex[1]; + if (cssVertex[0] > fpMaxX) fpMaxX = cssVertex[0]; + if (cssVertex[1] > fpMaxY) fpMaxY = cssVertex[1]; const p = projectCssVertexToGround(cssVertex, lightDir, groundCssZ); projected.push(p); if (p[0] < minX) minX = p[0]; @@ -353,24 +360,18 @@ export const PolyMesh = defineComponent({ projections.push(projected); } if (projections.length === 0) return null; - // Cap the SVG's intrinsic dimensions. Low-elevation lights shear - // projected polygons across the ground so far that the bbox can - // exceed tens of thousands of pixels each side, which forces the - // browser to rasterize a >100M-pixel backing store on every - // repaint (visible as scene-wide flicker when the camera or light - // moves). overflow:hidden clips paths that land outside the cap. - const SHADOW_MAX_DIM = 8000; - let bx0 = minX, by0 = minY, bx1 = maxX, by1 = maxY; - if (bx1 - bx0 > SHADOW_MAX_DIM) { - const cx = (bx0 + bx1) / 2; - bx0 = cx - SHADOW_MAX_DIM / 2; - bx1 = cx + SHADOW_MAX_DIM / 2; - } - if (by1 - by0 > SHADOW_MAX_DIM) { - const cy = (by0 + by1) / 2; - by0 = cy - SHADOW_MAX_DIM / 2; - by1 = cy + SHADOW_MAX_DIM / 2; - } + // Cap how far the shadow can extend BEYOND THE MESH FOOTPRINT. + // Low-elevation lights shear projections across the ground so far + // that the bbox can exceed tens of thousands of pixels each side, + // which forces the browser to rasterize a >100M-pixel backing + // store on every repaint. The footprint stays fully inside the + // SVG so the shadow under/next to the mesh is preserved; only the + // sheared end (off-screen anyway) gets clipped by overflow:hidden. + const SHADOW_MAX_EXTEND = 4000; + const bx0 = Math.max(minX, fpMinX - SHADOW_MAX_EXTEND); + const by0 = Math.max(minY, fpMinY - SHADOW_MAX_EXTEND); + const bx1 = Math.min(maxX, fpMaxX + SHADOW_MAX_EXTEND); + const by1 = Math.min(maxY, fpMaxY + SHADOW_MAX_EXTEND); const width = bx1 - bx0; const height = by1 - by0; if (!(width > 0) || !(height > 0)) return null; From cc172612222e775660eb96816de77e76880fb5ab Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 23 May 2026 15:55:32 +0200 Subject: [PATCH 21/28] perf(shadow): tighten max-extend to 2000 px beyond mesh footprint --- packages/polycss/src/api/createPolyScene.ts | 2 +- packages/react/src/scene/PolyMesh.tsx | 2 +- packages/vue/src/scene/PolyMesh.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index a5b7ca06..dba18808 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -1269,7 +1269,7 @@ export function createPolyScene( // fully inside the SVG so the shadow under/next to the mesh is // preserved — we only truncate the sheared end that's off-screen // anyway. SVG overflow:hidden does the actual clipping. - const SHADOW_MAX_EXTEND = 4000; + const SHADOW_MAX_EXTEND = 2000; const bx0 = Math.max(minX, fpMinX - SHADOW_MAX_EXTEND); const by0 = Math.max(minY, fpMinY - SHADOW_MAX_EXTEND); const bx1 = Math.min(maxX, fpMaxX + SHADOW_MAX_EXTEND); diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx index bf3ba1e4..ead27ba7 100644 --- a/packages/react/src/scene/PolyMesh.tsx +++ b/packages/react/src/scene/PolyMesh.tsx @@ -684,7 +684,7 @@ export const PolyMesh = forwardRef(function PolyM // inside the SVG so the shadow under/next to the mesh is preserved // — we only truncate the sheared end that's off-screen anyway. // overflow:hidden does the actual clipping. - const SHADOW_MAX_EXTEND = 4000; + const SHADOW_MAX_EXTEND = 2000; const bx0 = Math.max(minX, fpMinX - SHADOW_MAX_EXTEND); const by0 = Math.max(minY, fpMinY - SHADOW_MAX_EXTEND); const bx1 = Math.min(maxX, fpMaxX + SHADOW_MAX_EXTEND); diff --git a/packages/vue/src/scene/PolyMesh.ts b/packages/vue/src/scene/PolyMesh.ts index d687e7c3..407dc7c0 100644 --- a/packages/vue/src/scene/PolyMesh.ts +++ b/packages/vue/src/scene/PolyMesh.ts @@ -367,7 +367,7 @@ export const PolyMesh = defineComponent({ // store on every repaint. The footprint stays fully inside the // SVG so the shadow under/next to the mesh is preserved; only the // sheared end (off-screen anyway) gets clipped by overflow:hidden. - const SHADOW_MAX_EXTEND = 4000; + const SHADOW_MAX_EXTEND = 2000; const bx0 = Math.max(minX, fpMinX - SHADOW_MAX_EXTEND); const by0 = Math.max(minY, fpMinY - SHADOW_MAX_EXTEND); const bx1 = Math.min(maxX, fpMaxX + SHADOW_MAX_EXTEND); From 2486c8d77b6435bd9ce71f79fec349d864807eed Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 23 May 2026 16:06:56 +0200 Subject: [PATCH 22/28] feat(shadow): expose maxExtend as scene option (default 2000 px) --- packages/polycss/src/api/createPolyScene.ts | 30 +++++++++++++++------ packages/react/src/scene/PolyMesh.tsx | 13 ++++----- packages/react/src/scene/PolyScene.tsx | 2 +- packages/react/src/scene/sceneContext.ts | 7 +++++ packages/vue/src/scene/PolyMesh.ts | 13 ++++----- packages/vue/src/scene/PolyScene.ts | 2 +- packages/vue/src/scene/sceneContext.ts | 7 +++++ 7 files changed, 52 insertions(+), 22 deletions(-) diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index dba18808..00a7e57f 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -122,7 +122,7 @@ export interface PolySceneOptions { * lighting modes — dynamic mode projects via CSS vars so shadows * follow a moving light, baked mode CPU-bakes the projection into * each leaf's inline `matrix3d` and drops back-facing polys from the - * DOM entirely. Defaults: `{ color: "#000000", opacity: 0.25, lift: 0.05 }`. + * DOM entirely. Defaults: `{ color: "#000000", opacity: 0.25, lift: 0.05, maxExtend: 2000 }`. */ shadow?: { /** Shadow color as a CSS hex string. Default: `"#000000"`. */ @@ -136,6 +136,18 @@ export interface PolySceneOptions { * shadow. In world units. Default: `0.05`. */ lift?: number; + /** + * Maximum CSS pixels the shadow may extend beyond the mesh's + * footprint (the no-shear silhouette directly under the mesh). The + * footprint area is always preserved; only the sheared tail at low + * light elevations is truncated. Default: `2000`. + * + * **Trade-off:** larger values give longer shadows but the SVG + * backing store grows quadratically with this value, which can + * cause repaint flicker at extreme low-elevation angles. Pass a + * very large number (e.g. `Infinity`) to disable the cap entirely. + */ + maxExtend?: number; }; } @@ -370,7 +382,8 @@ function shadowOptsEqual( if (a === b) return true; return (a?.color ?? "#000000") === (b?.color ?? "#000000") && (a?.opacity ?? 0.25) === (b?.opacity ?? 0.25) - && (a?.lift ?? 0.05) === (b?.lift ?? 0.05); + && (a?.lift ?? 0.05) === (b?.lift ?? 0.05) + && (a?.maxExtend ?? 2000) === (b?.maxExtend ?? 2000); } function buildMeshTransform(t: PolyMeshTransform): string | undefined { @@ -1268,12 +1281,13 @@ export function createPolyScene( // or light moves). The footprint (no-shear silhouette) must stay // fully inside the SVG so the shadow under/next to the mesh is // preserved — we only truncate the sheared end that's off-screen - // anyway. SVG overflow:hidden does the actual clipping. - const SHADOW_MAX_EXTEND = 2000; - const bx0 = Math.max(minX, fpMinX - SHADOW_MAX_EXTEND); - const by0 = Math.max(minY, fpMinY - SHADOW_MAX_EXTEND); - const bx1 = Math.min(maxX, fpMaxX + SHADOW_MAX_EXTEND); - const by1 = Math.min(maxY, fpMaxY + SHADOW_MAX_EXTEND); + // anyway. SVG overflow:hidden does the actual clipping. Callers + // can disable the cap by setting shadow.maxExtend to Infinity. + const maxExtend = currentOptions.shadow?.maxExtend ?? 2000; + const bx0 = Math.max(minX, fpMinX - maxExtend); + const by0 = Math.max(minY, fpMinY - maxExtend); + const bx1 = Math.min(maxX, fpMaxX + maxExtend); + const by1 = Math.min(maxY, fpMaxY + maxExtend); const width = bx1 - bx0; const height = by1 - by0; if (!(width > 0) || !(height > 0)) return; diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx index ead27ba7..806c668e 100644 --- a/packages/react/src/scene/PolyMesh.tsx +++ b/packages/react/src/scene/PolyMesh.tsx @@ -683,12 +683,13 @@ export const PolyMesh = forwardRef(function PolyM // or light moves). The footprint (no-shear silhouette) stays fully // inside the SVG so the shadow under/next to the mesh is preserved // — we only truncate the sheared end that's off-screen anyway. - // overflow:hidden does the actual clipping. - const SHADOW_MAX_EXTEND = 2000; - const bx0 = Math.max(minX, fpMinX - SHADOW_MAX_EXTEND); - const by0 = Math.max(minY, fpMinY - SHADOW_MAX_EXTEND); - const bx1 = Math.min(maxX, fpMaxX + SHADOW_MAX_EXTEND); - const by1 = Math.min(maxY, fpMaxY + SHADOW_MAX_EXTEND); + // overflow:hidden does the actual clipping. Callers can disable + // the cap by passing shadow.maxExtend=Infinity on PolyScene. + const maxExtend = sceneShadow?.maxExtend ?? 2000; + const bx0 = Math.max(minX, fpMinX - maxExtend); + const by0 = Math.max(minY, fpMinY - maxExtend); + const bx1 = Math.min(maxX, fpMaxX + maxExtend); + const by1 = Math.min(maxY, fpMaxY + maxExtend); const width = bx1 - bx0; const height = by1 - by0; if (!(width > 0) || !(height > 0)) return null; diff --git a/packages/react/src/scene/PolyScene.tsx b/packages/react/src/scene/PolyScene.tsx index f09f1c91..76f2c050 100644 --- a/packages/react/src/scene/PolyScene.tsx +++ b/packages/react/src/scene/PolyScene.tsx @@ -83,7 +83,7 @@ export interface PolySceneProps extends TransformProps { * lighting modes — dynamic mode projects via CSS vars so shadows * follow a moving light, baked mode CPU-bakes the projection into * each leaf's inline `matrix3d` and drops back-facing polys from the - * DOM entirely. Defaults: `{ color: "#000000", opacity: 0.25, lift: 0.05 }`. + * DOM entirely. Defaults: `{ color: "#000000", opacity: 0.25, lift: 0.05, maxExtend: 2000 }`. */ shadow?: ShadowOptions; className?: string; diff --git a/packages/react/src/scene/sceneContext.ts b/packages/react/src/scene/sceneContext.ts index 9775219a..a3f6a813 100644 --- a/packages/react/src/scene/sceneContext.ts +++ b/packages/react/src/scene/sceneContext.ts @@ -18,6 +18,13 @@ export interface ShadowOptions { color?: string; opacity?: number; lift?: number; + /** + * Maximum CSS pixels the shadow may extend beyond the mesh's + * footprint. Caps the SVG backing store at low light elevations to + * prevent repaint flicker. Default: `2000`. Pass `Infinity` to + * disable the cap entirely. + */ + maxExtend?: number; } export interface PolySceneContextValue { diff --git a/packages/vue/src/scene/PolyMesh.ts b/packages/vue/src/scene/PolyMesh.ts index 407dc7c0..1dfa7e35 100644 --- a/packages/vue/src/scene/PolyMesh.ts +++ b/packages/vue/src/scene/PolyMesh.ts @@ -316,6 +316,7 @@ export const PolyMesh = defineComponent({ const ctx = sceneCtx?.value; const groundCssZ = ctx?.groundCssZ ?? null; if (groundCssZ === null) return null; + const shadowOpts = ctx?.shadow; const lightDir = ctx?.directionalLight?.direction ?? ([0.4, -0.7, 0.59] as Vec3); @@ -367,16 +368,16 @@ export const PolyMesh = defineComponent({ // store on every repaint. The footprint stays fully inside the // SVG so the shadow under/next to the mesh is preserved; only the // sheared end (off-screen anyway) gets clipped by overflow:hidden. - const SHADOW_MAX_EXTEND = 2000; - const bx0 = Math.max(minX, fpMinX - SHADOW_MAX_EXTEND); - const by0 = Math.max(minY, fpMinY - SHADOW_MAX_EXTEND); - const bx1 = Math.min(maxX, fpMaxX + SHADOW_MAX_EXTEND); - const by1 = Math.min(maxY, fpMaxY + SHADOW_MAX_EXTEND); + // Callers can disable the cap by passing shadow.maxExtend=Infinity. + const maxExtend = shadowOpts?.maxExtend ?? 2000; + const bx0 = Math.max(minX, fpMinX - maxExtend); + const by0 = Math.max(minY, fpMinY - maxExtend); + const bx1 = Math.min(maxX, fpMaxX + maxExtend); + const by1 = Math.min(maxY, fpMaxY + maxExtend); const width = bx1 - bx0; const height = by1 - by0; if (!(width > 0) || !(height > 0)) return null; - const shadowOpts = ctx?.shadow; const shadowColor = shadowOpts?.color ?? "#000000"; const shadowOpacity = shadowOpts?.opacity ?? 0.25; const parsed = parseHexColor(shadowColor)?.rgb ?? [0, 0, 0]; diff --git a/packages/vue/src/scene/PolyScene.ts b/packages/vue/src/scene/PolyScene.ts index 6b64bdd1..582c055b 100644 --- a/packages/vue/src/scene/PolyScene.ts +++ b/packages/vue/src/scene/PolyScene.ts @@ -78,7 +78,7 @@ export interface PolySceneProps { * lighting modes — dynamic mode projects via CSS vars so shadows * follow a moving light, baked mode CPU-bakes the projection into * each leaf's inline `matrix3d` and drops back-facing polys from the - * DOM entirely. Defaults: `{ color: "#000000", opacity: 0.25, lift: 0.05 }`. + * DOM entirely. Defaults: `{ color: "#000000", opacity: 0.25, lift: 0.05, maxExtend: 2000 }`. */ shadow?: PolyShadowOptions; class?: string; diff --git a/packages/vue/src/scene/sceneContext.ts b/packages/vue/src/scene/sceneContext.ts index 3f829116..174866c7 100644 --- a/packages/vue/src/scene/sceneContext.ts +++ b/packages/vue/src/scene/sceneContext.ts @@ -18,6 +18,13 @@ export interface PolyShadowOptions { color?: string; opacity?: number; lift?: number; + /** + * Maximum CSS pixels the shadow may extend beyond the mesh's + * footprint. Caps the SVG backing store at low light elevations to + * prevent repaint flicker. Default: `2000`. Pass `Infinity` to + * disable the cap entirely. + */ + maxExtend?: number; } export interface PolyShadowRegistry { From a9256f0c08b0e7724fe516e7a52c838e7a9a1cc7 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 23 May 2026 16:12:22 +0200 Subject: [PATCH 23/28] feat(website): add Shadow reach slider in Lighting dock --- .../BuilderWorkbench/components/BuilderDock.tsx | 1 + website/src/components/BuilderWorkbench/defaults.ts | 1 + .../src/components/Dock/folders/useLightingFolder.ts | 10 ++++++++++ .../components/GalleryWorkbench/GalleryWorkbench.tsx | 2 ++ website/src/components/ReactScene/ReactScene.tsx | 1 + website/src/components/VanillaScene/VanillaScene.tsx | 3 +++ website/src/components/types.ts | 3 +++ 7 files changed, 21 insertions(+) diff --git a/website/src/components/BuilderWorkbench/components/BuilderDock.tsx b/website/src/components/BuilderWorkbench/components/BuilderDock.tsx index f495b065..252b5cb4 100644 --- a/website/src/components/BuilderWorkbench/components/BuilderDock.tsx +++ b/website/src/components/BuilderWorkbench/components/BuilderDock.tsx @@ -92,6 +92,7 @@ export function BuilderDock({ /> onUpdateScene({ castShadow: value })); + useSlider( + folder, + "Shadow reach", + { min: 200, max: 4000, step: 100 }, + shadowMaxExtend, + (value) => onUpdateScene({ shadowMaxExtend: value }), + ); useToggle(folder, "Show ground", showGround, (value) => onUpdateScene({ showGround: value })); useToggle(folder, "Light helper", showLight, (value) => onUpdateScene({ showLight: value })); diff --git a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx index ce42f305..6ff51451 100644 --- a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx +++ b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx @@ -130,6 +130,7 @@ const DEFAULT_SCENE: SceneOptionsState = { target: [0, 0, 0], disableStrategies: [], castShadow: false, + shadowMaxExtend: 2000, showGround: false, fpvLook: true, fpvMove: true, @@ -867,6 +868,7 @@ export default function GalleryWorkbench() { /> {sceneOptions.selection ? ( diff --git a/website/src/components/VanillaScene/VanillaScene.tsx b/website/src/components/VanillaScene/VanillaScene.tsx index b81bf183..87fc2820 100644 --- a/website/src/components/VanillaScene/VanillaScene.tsx +++ b/website/src/components/VanillaScene/VanillaScene.tsx @@ -309,6 +309,7 @@ export function VanillaScene({ autoCenter: options.autoCenter, textureQuality: options.textureQuality, strategies: { disable: options.disableStrategies }, + shadow: { maxExtend: options.shadowMaxExtend }, }; const scene = createPolyScene(host, sceneOptions); sceneRef.current = scene; @@ -695,6 +696,7 @@ export function VanillaScene({ directionalLight, ambientLight, textureLighting: options.textureLighting, + shadow: { maxExtend: options.shadowMaxExtend }, }); }, [ options.rotX, @@ -702,6 +704,7 @@ export function VanillaScene({ options.zoom, options.target, options.textureLighting, + options.shadowMaxExtend, directionalLight, ambientLight, ]); diff --git a/website/src/components/types.ts b/website/src/components/types.ts index 1c412899..c39ef095 100644 --- a/website/src/components/types.ts +++ b/website/src/components/types.ts @@ -67,6 +67,9 @@ export interface SceneOptionsState { target: ReactVec3; disableStrategies: PolyRenderStrategy[]; castShadow: boolean; + /** Maximum CSS pixels the shadow may extend beyond the mesh footprint. + * Caps the SVG backing store at low light elevations. */ + shadowMaxExtend: number; showGround: boolean; fpvLook: boolean; fpvMove: boolean; From 0919b5068134aae8f385ee796b870aa6c68b6323 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 23 May 2026 17:08:38 +0200 Subject: [PATCH 24/28] feat(polycss): experimental per-receiver-face shadow projection --- bench/composite-shadow-diagnose.mjs | 81 +++++ bench/composite-shadow.html | 192 +++++++++++ packages/core/src/index.ts | 2 + packages/core/src/shadow/clipping.ts | 62 ++++ packages/core/src/shadow/projection.ts | 35 ++ packages/polycss/src/api/createPolyScene.ts | 359 +++++++++++++++++--- 6 files changed, 690 insertions(+), 41 deletions(-) create mode 100644 bench/composite-shadow-diagnose.mjs create mode 100644 bench/composite-shadow.html create mode 100644 packages/core/src/shadow/clipping.ts diff --git a/bench/composite-shadow-diagnose.mjs b/bench/composite-shadow-diagnose.mjs new file mode 100644 index 00000000..fc68e26a --- /dev/null +++ b/bench/composite-shadow-diagnose.mjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node +/** + * Loads bench/composite-shadow.html (caster pole on receiver cube) and + * dumps the resulting shadow SVG structure + a screenshot. Used to + * validate the experimental per-receiver-face shadow projection in + * polycss createPolyScene. + */ +import { chromium } from "playwright"; +import { spawn } from "node:child_process"; +import { mkdir } 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 PORT = 4400; +const HEADED = process.argv.includes("--headed"); + +const outDir = resolve(repoRoot, "bench/results/composite-shadow"); +await mkdir(outDir, { recursive: true }); + +const server = spawn( + "node", + ["bench/perf-serve.mjs", "--port", String(PORT)], + { cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"] }, +); +await new Promise((ok) => { + const onLine = (data) => { + if (String(data).includes("[perf-serve] index")) { + server.stdout.off("data", onLine); + ok(); + } + }; + server.stdout.on("data", onLine); +}); + +const browser = await chromium.launch({ + headless: !HEADED, + args: chromiumArgsWithGpuDefault([], { softwareBackend: false }), +}); + +try { + const ctx = await browser.newContext({ viewport: { width: 1200, height: 900 } }); + const page = await ctx.newPage(); + page.on("pageerror", (err) => console.log(`[pageerror] ${err.message}`)); + page.on("console", (msg) => { + if (msg.type() === "error") console.log(`[console.error] ${msg.text()}`); + }); + + await page.goto(`http://localhost:${PORT}/composite-shadow.html`, { + waitUntil: "networkidle", + timeout: 10000, + }); + await page.waitForTimeout(500); + + const snap = await page.evaluate(() => { + const shadows = document.querySelectorAll("svg.polycss-shadow"); + const receiverShadows = document.querySelectorAll("svg.polycss-shadow-receiver"); + return { + shadowCount: shadows.length, + receiverShadowCount: receiverShadows.length, + shadowOuter: Array.from(shadows).slice(0, 4).map((svg) => ({ + classes: svg.getAttribute("class"), + width: svg.getAttribute("width"), + height: svg.getAttribute("height"), + transform: svg.style.transform.slice(0, 120), + pathD: svg.querySelector("path")?.getAttribute("d")?.slice(0, 120), + })), + }; + }); + + console.log(JSON.stringify(snap, null, 2)); + + await page.screenshot({ path: resolve(outDir, "composite.png"), fullPage: false }); + console.log(`Screenshot: ${outDir}/composite.png`); +} finally { + await browser.close(); + server.kill(); +} diff --git a/bench/composite-shadow.html b/bench/composite-shadow.html new file mode 100644 index 00000000..13b30913 --- /dev/null +++ b/bench/composite-shadow.html @@ -0,0 +1,192 @@ + + + + + polycss composite shadow — caster on receiver + + + +
+
+ + + 60° + + + 45° + + + 0 + + + 0 + + + 0 + + + +
Drag the canvas to orbit · scroll to zoom
+
+

+
+  
+
+
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 016162b0..76155f50 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -146,11 +146,13 @@ export {
   BAKED_SHADOW_MIN_UP,
   BAKED_SHADOW_Z_SQUASH,
   buildBakedShadowProjectionMatrix,
+  convexHull2D,
   ensureCcw2D,
   isBakedShadowCaster,
   polygonSignedArea2D,
   projectCssVertexToGround,
 } from "./shadow/projection";
+export { clipPolygonToConvex2D } from "./shadow/clipping";
 
 // ── Animation ─────────────────────────────────────────────────────
 export {
diff --git a/packages/core/src/shadow/clipping.ts b/packages/core/src/shadow/clipping.ts
new file mode 100644
index 00000000..111c2641
--- /dev/null
+++ b/packages/core/src/shadow/clipping.ts
@@ -0,0 +1,62 @@
+// Sutherland-Hodgman polygon clipping (2D).
+//
+// Used by the experimental per-receiver-face shadow projection: each
+// casting polygon's projection onto a receiver face's plane gets clipped
+// to that face's outline so the shadow doesn't bleed beyond the receiver.
+// Standard textbook algorithm; assumes the clip polygon (receiver face
+// outline) is CONVEX and the subject polygon (projected shadow) is
+// arbitrary. Non-convex receiver faces would need a Greiner-Hormann
+// implementation instead.
+
+type Pt = readonly [number, number];
+
+/**
+ * Clips `subject` against the convex polygon `clip`. Both polygons are
+ * 2D, given in CCW vertex order. Returns the clipped polygon as a new
+ * array of points; an empty array means `subject` lies entirely outside.
+ */
+export function clipPolygonToConvex2D(
+  subject: ReadonlyArray,
+  clip: ReadonlyArray,
+): Array<[number, number]> {
+  if (subject.length === 0 || clip.length < 3) return [];
+  let output: Array<[number, number]> = subject.map((v) => [v[0], v[1]]);
+  const n = clip.length;
+
+  for (let i = 0; i < n; i++) {
+    if (output.length === 0) return [];
+    const input = output;
+    output = [];
+    const a = clip[i]!;
+    const b = clip[(i + 1) % n]!;
+    // Edge AB normal points "left" for a CCW clip polygon. A point is
+    // inside the clip's half-plane if the cross product is ≥ 0.
+    const inside = (p: Pt): boolean => {
+      return (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]) >= 0;
+    };
+    const intersect = (p: Pt, q: Pt): [number, number] => {
+      const x1 = a[0], y1 = a[1], x2 = b[0], y2 = b[1];
+      const x3 = p[0], y3 = p[1], x4 = q[0], y4 = q[1];
+      const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
+      // Parallel segments: fall back to the subject vertex so we don't
+      // introduce a degenerate (collinear) point.
+      if (Math.abs(denom) < 1e-12) return [p[0], p[1]];
+      const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom;
+      return [x1 + t * (x2 - x1), y1 + t * (y2 - y1)];
+    };
+    const inLen = input.length;
+    for (let j = 0; j < inLen; j++) {
+      const current = input[j]!;
+      const prev = input[(j + inLen - 1) % inLen]!;
+      const currIn = inside(current);
+      const prevIn = inside(prev);
+      if (currIn) {
+        if (!prevIn) output.push(intersect(prev, current));
+        output.push([current[0], current[1]]);
+      } else if (prevIn) {
+        output.push(intersect(prev, current));
+      }
+    }
+  }
+  return output;
+}
diff --git a/packages/core/src/shadow/projection.ts b/packages/core/src/shadow/projection.ts
index 9e861296..12296028 100644
--- a/packages/core/src/shadow/projection.ts
+++ b/packages/core/src/shadow/projection.ts
@@ -82,6 +82,41 @@ export function isBakedShadowCaster(
   return normal[0] * lx + normal[1] * ly + normal[2] * lz > 0;
 }
 
+/**
+ * 2D convex hull (Andrew's monotone chain, O(n log n)). Returns the
+ * hull vertices in CCW order. Used to compute a receiver mesh's XY
+ * footprint when subtracting it from the global ground shadow.
+ */
+export function convexHull2D(
+  points: ReadonlyArray,
+): Array<[number, number]> {
+  const n = points.length;
+  if (n <= 1) return points.map((p) => [p[0], p[1]]);
+  const sorted = points.map((p) => [p[0], p[1]] as [number, number]);
+  sorted.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
+  const cross = (
+    o: [number, number],
+    a: [number, number],
+    b: [number, number],
+  ): number => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
+  const lower: Array<[number, number]> = [];
+  for (const p of sorted) {
+    while (lower.length >= 2 &&
+      cross(lower[lower.length - 2]!, lower[lower.length - 1]!, p) <= 0) lower.pop();
+    lower.push(p);
+  }
+  const upper: Array<[number, number]> = [];
+  for (let i = sorted.length - 1; i >= 0; i--) {
+    const p = sorted[i]!;
+    while (upper.length >= 2 &&
+      cross(upper[upper.length - 2]!, upper[upper.length - 1]!, p) <= 0) upper.pop();
+    upper.push(p);
+  }
+  lower.pop();
+  upper.pop();
+  return lower.concat(upper);
+}
+
 /**
  * Signed area of a 2D polygon (positive for CCW vertex order, negative
  * for CW). Used by `ensureCcw2D` to normalize winding before concatenating
diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts
index 00a7e57f..835cca09 100644
--- a/packages/polycss/src/api/createPolyScene.ts
+++ b/packages/polycss/src/api/createPolyScene.ts
@@ -41,7 +41,9 @@ import {
   VOXEL_CAMERA_CULL_NORMAL_LIMIT,
   cameraCullNormalKey,
   cameraCullVisibleSignature,
+  clipPolygonToConvex2D,
   computeSceneBbox,
+  convexHull2D,
   ensureCcw2D,
   findOverlappingPolygonDuplicates,
   inverseRotateVec3,
@@ -183,14 +185,22 @@ export interface PolyMeshTransform {
    */
   excludeFromAutoCenter?: boolean;
   /**
-   * When `true` and the scene is in dynamic lighting mode, the renderer emits
-   * a flat shadow leaf sibling for each non-textured polygon. The shadow is
-   * projected onto the ground plane (min world-Y of all casting meshes) along
-   * the CSS-space light direction (driven by `--clx/--cly/--clz` vars). Zero
-   * JS in the render loop — the projection matrix is a CSS var that recomputes
-   * via `calc()` when the light vars change. Defaults to `false`.
+   * When `true`, this mesh casts a shadow onto the scene's shadow ground
+   * plane (and onto any meshes marked `receiveShadow: true`). The shadow
+   * emits as one per-mesh `` whose path is the union of every
+   * casting polygon's projection. Works in both lighting modes.
+   * Defaults to `false`.
    */
   castShadow?: boolean;
+  /**
+   * **(experimental)** When `true`, this mesh acts as a shadow receiver:
+   * each of its polygon faces becomes a target plane that casting meshes'
+   * shadows project onto and get clipped to. Useful for "shadow on table"
+   * scenarios. Currently only convex face outlines clip cleanly. When no
+   * receivers are present the global ground plane is used as today.
+   * Defaults to `false`.
+   */
+  receiveShadow?: boolean;
 }
 
 export interface PolyMeshHandle {
@@ -606,13 +616,11 @@ export function createPolyScene(
     /** Dynamic-mode shadow `` leaves, one per non-deduped casting
      *  polygon. Empty in baked mode (which uses `shadowSvg` instead). */
     shadowRendered: HTMLElement[];
-    /** Baked-mode shadow `` — a single per-mesh element whose ``
-     *  composites every casting polygon's projected outline into one
-     *  silhouette before applying `shadow.opacity`. Carries the same
-     *  overall visual as a stack of per-polygon `` leaves but avoids
-     *  the alpha-accumulation darkening at polygon intersections, and
-     *  reduces DOM weight to a single element per mesh. */
-    shadowSvg?: SVGSVGElement;
+    /** Shadow ``s emitted for this mesh — one for the global ground
+     *  plane plus (if any other mesh has `receiveShadow: true`) one per
+     *  receiver face that this caster's shadow lands on. Cleared as a
+     *  group on each re-emit. */
+    shadowSvgs: SVGSVGElement[];
     voxelRenderer?: PolyVoxelRenderer;
     disposeAtlas?: () => void;
     polygons: Polygon[];
@@ -622,6 +630,7 @@ export function createPolyScene(
     hasBuckets: boolean;
     excludeFromAutoCenter: boolean;
     castShadow: boolean;
+    receiveShadow: boolean;
     cameraCullGroups: CameraCullNormalGroup[];
     cameraCullSignature: string;
     lightOverrideSignature: string;
@@ -769,10 +778,10 @@ export function createPolyScene(
       if (el.parentNode) el.parentNode.removeChild(el);
     }
     entry.shadowRendered.length = 0;
-    if (entry.shadowSvg?.parentNode) {
-      entry.shadowSvg.parentNode.removeChild(entry.shadowSvg);
+    for (const svg of entry.shadowSvgs) {
+      if (svg.parentNode) svg.parentNode.removeChild(svg);
     }
-    entry.shadowSvg = undefined;
+    entry.shadowSvgs.length = 0;
   }
 
   function disposeRendered(rendered: RenderedPoly[], disposeAtlas?: () => void): void {
@@ -1209,6 +1218,15 @@ export function createPolyScene(
       r, g, b,
       shadowOpacity,
     );
+    // Per-receiver-face shadows (experimental). Each face of a mesh
+    // marked receiveShadow:true catches the caster's projected outline
+    // clipped to that face. The SVGs mount on the RECEIVER's wrapper
+    // so the receiver's mesh transform places them correctly.
+    for (const receiver of meshes) {
+      if (receiver === entry) continue;
+      if (!receiver.receiveShadow || receiver.disposed) continue;
+      emitReceiverFaceShadows(entry, receiver, shadowDedupDrop, lightDir, r, g, b, shadowOpacity);
+    }
   }
 
   // Builds a single per-mesh  for the mesh's shadow. Projects every
@@ -1230,17 +1248,11 @@ export function createPolyScene(
   ): void {
     const polyProjections: Array> = [];
     let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
-    // Footprint = the mesh's straight-down (no-shear) silhouette bbox,
-    // i.e. the XY of every vertex. Used by the cap below as the anchor
-    // the shadow must always fully contain.
     let fpMinX = Infinity, fpMinY = Infinity, fpMaxX = -Infinity, fpMaxY = -Infinity;
-    // Iterate all rendered polys. We deliberately do NOT cull by Lambert
-    // facing here: closed convex meshes have their lit-side polys alone
-    // tile the silhouette, but thin/open meshes (a bat with spread
-    // wings, cloth, a single quad) have both sides contributing to the
-    // outline — culling one side leaves real holes in the shadow. The
-    // extra projections cost is cheap, and SVG's nonzero rule merges
-    // any overlap into a single silhouette.
+    // Caster position (world units). Baked into each vertex so the
+    // shadow moves with the mesh when setTransform(position) changes.
+    // TODO: also bake rotation/scale for full mesh-transform support.
+    const cpos = entry.handle.transform.position ?? [0, 0, 0];
     for (const item of entry.rendered) {
       if (dedupDrop.has(item.polygonIndex)) continue;
       const plan = item.plan;
@@ -1253,10 +1265,13 @@ export function createPolyScene(
         // World → CSS-3D: swap X and Y, scale by BASE_TILE. Matches the
         // axis convention used by plan.matrix / --shadow-proj so the
         // projected output sits where the dynamic-mode shadow would.
+        const wx = v[0] + cpos[0];
+        const wy = v[1] + cpos[1];
+        const wz = v[2] + cpos[2];
         const cssVertex: Vec3 = [
-          v[1] * DEFAULT_TILE,
-          v[0] * DEFAULT_TILE,
-          v[2] * DEFAULT_TILE,
+          wy * DEFAULT_TILE,
+          wx * DEFAULT_TILE,
+          wz * DEFAULT_TILE,
         ];
         if (cssVertex[0] < fpMinX) fpMinX = cssVertex[0];
         if (cssVertex[1] < fpMinY) fpMinY = cssVertex[1];
@@ -1308,6 +1323,35 @@ export function createPolyScene(
       }
       d += "Z";
     }
+    // Cut the receiver footprints out of the ground shadow: each
+    // receiver mesh's convex XY hull becomes a CW hole subpath under
+    // fill-rule=nonzero. Without this the ground shadow extends
+    // straight through the receiver in 2D and visually leaks past the
+    // receiver edge in 3D (the ground plane sits below the receiver's
+    // height).
+    for (const receiver of meshes) {
+      if (receiver === entry || receiver.disposed || !receiver.receiveShadow) continue;
+      const rpos = receiver.handle.transform.position ?? [0, 0, 0];
+      const xy: Array<[number, number]> = [];
+      for (const poly of receiver.polygons) {
+        for (const v of poly.vertices) {
+          xy.push([
+            (v[1] + rpos[1]) * DEFAULT_TILE,
+            (v[0] + rpos[0]) * DEFAULT_TILE,
+          ]);
+        }
+      }
+      if (xy.length < 3) continue;
+      const hull = convexHull2D(xy);
+      if (hull.length < 3) continue;
+      // CW order = subtract under nonzero fill-rule. Reverse the CCW hull.
+      const cw = hull.slice().reverse();
+      d += `M${(cw[0]![0] - bx0).toFixed(3)},${(cw[0]![1] - by0).toFixed(3)}`;
+      for (let i = 1; i < cw.length; i++) {
+        d += `L${(cw[i]![0] - bx0).toFixed(3)},${(cw[i]![1] - by0).toFixed(3)}`;
+      }
+      d += "Z";
+    }
 
     const svgNS = "http://www.w3.org/2000/svg";
     const svg = doc.createElementNS(svgNS, "svg");
@@ -1343,12 +1387,207 @@ export function createPolyScene(
     path.setAttribute("opacity", opacity.toFixed(4));
     svg.appendChild(path);
 
-    entry.shadowSvg = svg;
-    const firstChild = entry.wrapper.firstChild;
-    if (firstChild) {
-      entry.wrapper.insertBefore(svg, firstChild);
-    } else {
-      entry.wrapper.appendChild(svg);
+    entry.shadowSvgs.push(svg);
+    // Mount on the SCENE root, not on the mesh wrapper — vertex coords
+    // are now in WORLD space (mesh position baked in above), so we
+    // can't sit inside a wrapper whose translate would double-apply.
+    const sceneFirst = sceneEl.firstChild;
+    if (sceneFirst) sceneEl.insertBefore(svg, sceneFirst);
+    else sceneEl.appendChild(svg);
+  }
+
+  // (experimental) For each face of a receiver mesh, projects the caster's
+  // polygons onto that face's plane (along the light direction), clips
+  // each projection to the face's 2D outline (Sutherland-Hodgman), and
+  // emits one SVG per face whose path is the union of all clipped
+  // shadows. The SVG mounts on the RECEIVER's wrapper with a matrix3d
+  // that orients its 2D content to the face plane in 3D.
+  //
+  // NOTE: assumes both caster and receiver have identity mesh transforms
+  // (no position/rotation/scale). Supporting transforms requires baking
+  // each mesh's wrapper transform into vertex coords before projection;
+  // deferred to follow-up.
+  function emitReceiverFaceShadows(
+    casterEntry: MeshEntry,
+    receiverEntry: MeshEntry,
+    dedupDrop: Set,
+    lightDir: Vec3,
+    r: number, g: number, b: number,
+    opacity: number,
+  ): void {
+    const llen = Math.hypot(lightDir[0], lightDir[1], lightDir[2]) || 1;
+    const Lx = lightDir[0] / llen;
+    const Ly = lightDir[1] / llen;
+    const Lz = lightDir[2] / llen;
+    const svgNS = "http://www.w3.org/2000/svg";
+    // Bake mesh positions (caster + receiver) into vertex coords. Both
+    // sets of polygon.vertices are mesh-local; the projection math
+    // needs them in the same (world) frame to be meaningful.
+    const cpos = casterEntry.handle.transform.position ?? [0, 0, 0];
+    const rpos = receiverEntry.handle.transform.position ?? [0, 0, 0];
+    const worldCss = (vert: Vec3, pos: Vec3): Vec3 => [
+      (vert[1] + pos[1]) * DEFAULT_TILE,
+      (vert[0] + pos[0]) * DEFAULT_TILE,
+      (vert[2] + pos[2]) * DEFAULT_TILE,
+    ];
+
+    for (const face of receiverEntry.polygons) {
+      if (face.vertices.length < 3) continue;
+      const v0 = face.vertices[0]!;
+      const v1 = face.vertices[1]!;
+      const v2 = face.vertices[2]!;
+      const O = worldCss(v0, rpos);
+      const w1 = worldCss(v1, rpos);
+      const w2 = worldCss(v2, rpos);
+      const e1: Vec3 = [w1[0] - O[0], w1[1] - O[1], w1[2] - O[2]];
+      const e2: Vec3 = [w2[0] - O[0], w2[1] - O[1], w2[2] - O[2]];
+      // Face normal = e2 × e1 (NOT e1 × e2). polygon.vertices are CCW
+      // in world coords; the world→CSS axis swap (Y↔X) inverts that
+      // handedness, so the cross product order must flip to recover an
+      // outward-pointing normal in CSS-3D space.
+      const nx = e2[1] * e1[2] - e2[2] * e1[1];
+      const ny = e2[2] * e1[0] - e2[0] * e1[2];
+      const nz = e2[0] * e1[1] - e2[1] * e1[0];
+      const nLen = Math.hypot(nx, ny, nz);
+      if (nLen < 1e-9) continue;
+      const n: Vec3 = [nx / nLen, ny / nLen, nz / nLen];
+      const e1Len = Math.hypot(e1[0], e1[1], e1[2]);
+      if (e1Len < 1e-9) continue;
+      const u: Vec3 = [e1[0] / e1Len, e1[1] / e1Len, e1[2] / e1Len];
+      const v: Vec3 = [
+        n[1] * u[2] - n[2] * u[1],
+        n[2] * u[0] - n[0] * u[2],
+        n[0] * u[1] - n[1] * u[0],
+      ];
+
+      // Face outline in (u, v) local coords. CCW for clip-poly contract.
+      const faceUv: Array<[number, number]> = face.vertices.map((vert) => {
+        const w = worldCss(vert, rpos);
+        const dx = w[0] - O[0];
+        const dy = w[1] - O[1];
+        const dz = w[2] - O[2];
+        return [
+          dx * u[0] + dy * u[1] + dz * u[2],
+          dx * v[0] + dy * v[1] + dz * v[2],
+        ];
+      });
+      const faceCcw = ensureCcw2D(faceUv);
+
+      // dot(L, n) — denominator for the plane-ray intersection. We also
+      // require the face's outward normal to point TOWARD the light
+      // source (Ldotn > 0): back-facing receiver faces (e.g. the bottom
+      // of a cube) can't physically receive a shadow, and projecting
+      // onto them would paint a phantom shadow under the receiver.
+      const Ldotn = Lx * n[0] + Ly * n[1] + Lz * n[2];
+      if (Ldotn <= 1e-6) continue;
+
+      const clipped: Array> = [];
+      let minU = Infinity, minV = Infinity, maxU = -Infinity, maxV = -Infinity;
+      for (const item of casterEntry.rendered) {
+        if (dedupDrop.has(item.polygonIndex)) continue;
+        const plan = item.plan;
+        if (!plan) continue;
+        const polygon = casterEntry.polygons[item.polygonIndex];
+        if (!polygon) continue;
+
+        const projected: Array<[number, number]> = [];
+        let skip = false;
+        for (const vert of polygon.vertices) {
+          const w = worldCss(vert, cpos);
+          const Vx = w[0];
+          const Vy = w[1];
+          const Vz = w[2];
+          // t such that V - t*L lies on the plane.
+          const VmOx = Vx - O[0];
+          const VmOy = Vy - O[1];
+          const VmOz = Vz - O[2];
+          const t = (VmOx * n[0] + VmOy * n[1] + VmOz * n[2]) / Ldotn;
+          if (t < -1e-6) { skip = true; break; }
+          const Px = Vx - t * Lx;
+          const Py = Vy - t * Ly;
+          const Pz = Vz - t * Lz;
+          const dx = Px - O[0];
+          const dy = Py - O[1];
+          const dz = Pz - O[2];
+          projected.push([
+            dx * u[0] + dy * u[1] + dz * u[2],
+            dx * v[0] + dy * v[1] + dz * v[2],
+          ]);
+        }
+        if (skip || projected.length < 3) continue;
+        const subjectCcw = ensureCcw2D(projected);
+        const clip = clipPolygonToConvex2D(subjectCcw, faceCcw);
+        if (clip.length < 3) continue;
+        clipped.push(clip);
+        for (const pt of clip) {
+          if (pt[0] < minU) minU = pt[0];
+          if (pt[1] < minV) minV = pt[1];
+          if (pt[0] > maxU) maxU = pt[0];
+          if (pt[1] > maxV) maxV = pt[1];
+        }
+      }
+
+      if (clipped.length === 0) continue;
+      const width = maxU - minU;
+      const height = maxV - minV;
+      if (!(width > 0) || !(height > 0)) continue;
+
+      // Compound path offset to start at (0, 0) inside the SVG.
+      let d = "";
+      for (const verts of clipped) {
+        d += `M${(verts[0]![0] - minU).toFixed(3)},${(verts[0]![1] - minV).toFixed(3)}`;
+        for (let i = 1; i < verts.length; i++) {
+          d += `L${(verts[i]![0] - minU).toFixed(3)},${(verts[i]![1] - minV).toFixed(3)}`;
+        }
+        d += "Z";
+      }
+
+      // SVG matrix3d places the 2D layout box onto the face plane in 3D:
+      //   svg(x, y) → world = O' + x*u + y*v
+      // where O' = O + minU*u + minV*v (anchor at the clipped bbox corner)
+      // plus a tiny push along the face normal so the shadow sits in
+      // front of the receiver surface (otherwise CSS Z-fighting with the
+      // receiver's own polygon can drop the shadow behind it).
+      const lift = 0.5;
+      const Ox = O[0] + minU * u[0] + minV * v[0] + lift * n[0];
+      const Oy = O[1] + minU * u[1] + minV * v[1] + lift * n[1];
+      const Oz = O[2] + minU * u[2] + minV * v[2] + lift * n[2];
+      const m = [
+        u[0], u[1], u[2], 0,
+        v[0], v[1], v[2], 0,
+        n[0], n[1], n[2], 0,
+        Ox,   Oy,   Oz,   1,
+      ];
+
+      const svg = doc.createElementNS(svgNS, "svg");
+      svg.setAttribute("class", "polycss-shadow polycss-shadow-svg polycss-shadow-receiver");
+      svg.setAttribute("width", String(width));
+      svg.setAttribute("height", String(height));
+      svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
+      svg.setAttribute(
+        "style",
+        `position:absolute;top:0;left:0;display:block;overflow:hidden;` +
+        `transform-origin:0 0;pointer-events:none;will-change:transform;` +
+        `transform:matrix3d(${m.map((x) => x.toFixed(4)).join(",")})`,
+      );
+
+      const path = doc.createElementNS(svgNS, "path");
+      path.setAttribute("d", d);
+      const fillColor = `rgb(${r},${g},${b})`;
+      path.setAttribute("fill", fillColor);
+      path.setAttribute("fill-rule", "nonzero");
+      path.setAttribute("stroke", fillColor);
+      path.setAttribute("stroke-width", "2");
+      path.setAttribute("stroke-linejoin", "round");
+      path.setAttribute("opacity", opacity.toFixed(4));
+      svg.appendChild(path);
+
+      casterEntry.shadowSvgs.push(svg);
+      // Mount on the SCENE root — coords are world space, so we can't
+      // sit inside a mesh wrapper whose translate would double-apply.
+      const first = sceneEl.firstChild;
+      if (first) sceneEl.insertBefore(svg, first);
+      else sceneEl.appendChild(svg);
     }
   }
 
@@ -1430,12 +1669,21 @@ export function createPolyScene(
   // CPU, so a change requires re-emission of every caster's shadow.
   function recomputeShadowGround(): void {
     let minWorldZ = Infinity;
+    // If any receivers exist, anchor the ground plane to the lowest
+    // receiver bottom — that's the actual scene floor. Otherwise fall
+    // back to the lowest caster bottom (legacy behavior, used when no
+    // receiver mesh is registered).
+    let hasReceiver = false;
+    for (const m of meshes) if (!m.disposed && m.receiveShadow) { hasReceiver = true; break; }
     for (const m of meshes) {
-      if (!m.disposed && m.castShadow) {
-        for (const poly of m.polygons) {
-          for (const v of poly.vertices) {
-            if (v[2] < minWorldZ) minWorldZ = v[2];
-          }
+      if (m.disposed) continue;
+      const eligible = hasReceiver ? m.receiveShadow : m.castShadow;
+      if (!eligible) continue;
+      const dz = m.handle.transform.position?.[2] ?? 0;
+      for (const poly of m.polygons) {
+        for (const v of poly.vertices) {
+          const wz = v[2] + dz;
+          if (wz < minWorldZ) minWorldZ = wz;
         }
       }
     }
@@ -1445,7 +1693,7 @@ export function createPolyScene(
       // No casters left: drop any shadow elements still mounted.
       if (hadGround) {
         for (const entry of meshes) {
-          if (entry.shadowSvg) clearShadowLeaves(entry);
+          if (entry.shadowSvgs.length) clearShadowLeaves(entry);
         }
       }
       return;
@@ -1605,6 +1853,7 @@ export function createPolyScene(
       parseResult,
       rendered: [],
       shadowRendered: [],
+      shadowSvgs: [],
       polygons: sourcePolygons,
       voxelSource: parseResult.voxelSource,
       disposed: false,
@@ -1612,6 +1861,7 @@ export function createPolyScene(
       hasBuckets: false,
       excludeFromAutoCenter: !!transformIn.excludeFromAutoCenter,
       castShadow: !!transformIn.castShadow,
+      receiveShadow: !!transformIn.receiveShadow,
       cameraCullGroups: [],
       cameraCullSignature: "",
       lightOverrideSignature: "clear",
@@ -1925,7 +2175,9 @@ export function createPolyScene(
       },
       setTransform(t: Partial) {
         const prevCastShadow = entry.castShadow;
+        const prevReceiveShadow = entry.receiveShadow;
         if (t.castShadow !== undefined) entry.castShadow = !!t.castShadow;
+        if (t.receiveShadow !== undefined) entry.receiveShadow = !!t.receiveShadow;
         transform = { ...transform, ...t };
         const css2 = buildMeshTransform(transform);
         wrapper.style.transform = css2 ?? "";
@@ -1935,6 +2187,23 @@ export function createPolyScene(
           emitShadowLeaves(entry);
           recomputeShadowGround();
         }
+        // Receiver toggled: re-emit every caster so their per-receiver
+        // shadows are added (or removed).
+        if (entry.receiveShadow !== prevReceiveShadow) {
+          for (const m of meshes) {
+            if (m.castShadow) emitShadowLeaves(m);
+          }
+        }
+        // Position change: shadow geometry depends on world-space coords,
+        // so recompute ground + re-emit every caster (this one if it
+        // casts, plus all casters if this one is a receiver they project
+        // onto).
+        if (t.position !== undefined) {
+          recomputeShadowGround();
+          for (const m of meshes) {
+            if (m.castShadow) emitShadowLeaves(m);
+          }
+        }
       },
       dispose() {
         if (entry.disposed) return;
@@ -1972,6 +2241,14 @@ export function createPolyScene(
     applyMeshLightVarOverride(entry, transform.rotation);
     recomputeAutoCenter();
     recomputeShadowGround();
+    // New receiver: existing casters need to re-emit so they project
+    // onto this receiver's faces. recomputeShadowGround only re-emits
+    // when the global ground changes.
+    if (entry.receiveShadow) {
+      for (const m of meshes) {
+        if (m !== entry && m.castShadow) emitShadowLeaves(m);
+      }
+    }
     return handle;
   }
 

From afd77b1616b099aa1817600dd5e315e4a1d887b5 Mon Sep 17 00:00:00 2001
From: Juan Cruz Fortunatti 
Date: Sun, 24 May 2026 00:11:10 +0200
Subject: [PATCH 25/28] feat(shadow): per-tri 3D-clip receiver shadow +
 raytrace bench validator
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Replaces the per-face per-caster-hull approach with per-triangle
projection that 3D-clips each caster tri against the receiver plane
half-space before projecting to the face's 2D plane. Fixes the case
where tris straddle the plane (top of a tall pole occluding an
apple): the previous algorithm dropped below-plane vertices and the
hull collapsed to a line, missing the shadow entirely.

bench/real-shadow.html runs a Möller-Trumbore raytracer alongside the
fast algorithm as ground truth, with a per-face yellow→blue coverage
overlay and TP/FP/FN diff readout. Source of truth for future
shadow-algorithm work.
---
 bench/real-shadow-shot.mjs                  | 173 +++++
 bench/real-shadow.html                      | 595 +++++++++++++++++
 packages/polycss/src/api/createPolyScene.ts | 698 +++++++++++---------
 3 files changed, 1172 insertions(+), 294 deletions(-)
 create mode 100644 bench/real-shadow-shot.mjs
 create mode 100644 bench/real-shadow.html

diff --git a/bench/real-shadow-shot.mjs b/bench/real-shadow-shot.mjs
new file mode 100644
index 00000000..ac99aaa8
--- /dev/null
+++ b/bench/real-shadow-shot.mjs
@@ -0,0 +1,173 @@
+import { chromium } from "playwright";
+import { resolve, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+import { chromiumArgsWithGpuDefault } from "/Users/apresmoi/Documents/voxcss/bench/chromium-defaults.mjs";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const browser = await chromium.launch({
+  headless: true,
+  args: chromiumArgsWithGpuDefault([], { softwareBackend: false }),
+});
+try {
+  const ctx = await browser.newContext({ viewport: { width: 1200, height: 900 } });
+  const page = await ctx.newPage();
+  page.on("pageerror", (err) => console.log(`[pageerror] ${err.message}`));
+  page.on("console", (msg) => {
+    if (msg.type() === "error") console.log(`[console.error] ${msg.text()}`);
+  });
+  await page.goto("http://localhost:4400/real-shadow.html", { waitUntil: "networkidle", timeout: 15000 });
+  await page.waitForTimeout(1500);
+  await page.waitForTimeout(300);
+  const status = await page.evaluate(() => document.getElementById("status")?.textContent ?? "");
+  console.log("status:", status);
+  // Primary screenshot (with shadows) — taken BEFORE any hiding.
+  await page.screenshot({ path: "/tmp/real-shadow.png", fullPage: false });
+  // Rotate camera 180° to see the other side
+  await page.evaluate(() => {
+    // Drag the canvas to rotate via orbit controls — fake a drag event
+    const host = document.getElementById("host");
+    const r = host.getBoundingClientRect();
+    const cx = r.x + r.width / 2;
+    const cy = r.y + r.height / 2;
+    host.dispatchEvent(new PointerEvent("pointerdown", { clientX: cx, clientY: cy, button: 0, pointerType: "mouse", bubbles: true, pointerId: 1 }));
+    host.dispatchEvent(new PointerEvent("pointermove", { clientX: cx + 600, clientY: cy, button: 0, pointerType: "mouse", bubbles: true, pointerId: 1 }));
+    host.dispatchEvent(new PointerEvent("pointerup", { clientX: cx + 600, clientY: cy, button: 0, pointerType: "mouse", bubbles: true, pointerId: 1 }));
+  });
+  await page.waitForTimeout(500);
+  await page.screenshot({ path: "/tmp/real-shadow-other-side.png", fullPage: false });
+  console.log("/tmp/real-shadow-other-side.png");
+  // Baseline screenshot with shadows hidden, for diff comparison.
+  await page.evaluate(() => {
+    document.querySelectorAll("svg.polycss-shadow").forEach((s) => s.style.display = "none");
+  });
+  await page.waitForTimeout(200);
+  await page.screenshot({ path: "/tmp/real-shadow-nopole.png", fullPage: false });
+  console.log("/tmp/real-shadow-nopole.png");
+  const meshState = await page.evaluate(() => {
+    const scene = document.querySelector(".polycss-scene");
+    const meshes = scene?.querySelectorAll(".polycss-mesh") ?? [];
+    return Array.from(meshes).map((m, i) => ({ i, transform: m.style.transform.slice(0, 60) }));
+  });
+  console.log("meshes:", JSON.stringify(meshState));
+  const debug = await page.evaluate(() => {
+    const meshes = document.querySelectorAll(".polycss-mesh");
+    const groundSvg = document.querySelector("svg.polycss-shadow:not(.polycss-shadow-receiver)");
+    const d = groundSvg?.querySelector("path")?.getAttribute("d") ?? "";
+    // Last subpath in d:
+    const subs = d.split("Z").filter(Boolean);
+    const last = subs[subs.length - 1] ?? "";
+    // Dump each mesh's transform + receiveShadow state. Read via the
+    // public handle exposed on the bench global.
+    const handles = {
+      plane: window.planeHandle,
+      apple: window.appleHandle,
+      pole: window.poleHandle,
+    };
+    const handleStates = {};
+    for (const [k, h] of Object.entries(handles)) {
+      handleStates[k] = h ? {
+        receiveShadow: h.transform?.receiveShadow,
+        castShadow: h.transform?.castShadow,
+        position: h.transform?.position,
+      } : null;
+    }
+    return {
+      meshCount: meshes.length,
+      lastSubpath: last.slice(0, 200),
+      totalSubpaths: subs.length,
+      handleStates,
+    };
+  });
+  // Also dump receiver SVG content (apple receiver surface)
+  const receiverDump = await page.evaluate(() => {
+    const recvs = Array.from(document.querySelectorAll("svg.polycss-shadow-receiver"));
+    return {
+      count: recvs.length,
+      items: recvs.map((recv) => ({
+        width: recv.getAttribute("width"),
+        height: recv.getAttribute("height"),
+        transform: recv.style.transform.slice(0, 120),
+        subpathCount: ((recv.querySelector("path")?.getAttribute("d") ?? "").match(/M/g) || []).length,
+      })),
+    };
+  });
+  const hullDbg = await page.evaluate(() => window.__hullDbg);
+  console.log("hullDbg:", JSON.stringify(hullDbg, null, 2));
+  const allBounds = await page.evaluate(() => ({ apple: window.__appleBounds, pole: window.__poleBounds }));
+  console.log("bounds:", JSON.stringify(allBounds, null, 2));
+  const handleBounds = await page.evaluate(() => {
+    const polys = window.__applePolys;
+    if (!polys) return null;
+    const verts = polys.flatMap((p) => p.vertices);
+    const b = (i) => ({ min: Math.min(...verts.map((v) => v[i])), max: Math.max(...verts.map((v) => v[i])) });
+    return { polyCount: polys.length, x: b(0), y: b(1), z: b(2) };
+  });
+  console.log("apple bounds (post scene.add):", JSON.stringify(handleBounds, null, 2));
+  console.log("receiver:", JSON.stringify(receiverDump, null, 2));
+  console.log("debug:", JSON.stringify(debug, null, 2));
+  const vcountHist = await page.evaluate(() => {
+    const scene = window.__polySnapshot; // hack — but easier: just look at mesh polygon data via handles
+    // Each polycss-mesh has its leaf elements; count vertices via the s/u/i element classes? No.
+    // Just dump rendered shadow path subpath vertex counts to histogram.
+    const groundSvg = document.querySelector("svg.polycss-shadow:not(.polycss-shadow-receiver)");
+    if (!groundSvg) return {};
+    const d = groundSvg.querySelector("path")?.getAttribute("d") ?? "";
+    const sps = d.split("Z").filter(Boolean);
+    const hist = {};
+    for (const sp of sps) {
+      const coords = sp.replace(/[ML]/g, ",").split(",").filter(Boolean);
+      const vc = coords.length / 2;
+      hist[vc] = (hist[vc] || 0) + 1;
+    }
+    return hist;
+  });
+  console.log("vertex-count histogram:", JSON.stringify(vcountHist));
+  const shadowDump = await page.evaluate(() => {
+    const groundSvg = document.querySelector("svg.polycss-shadow:not(.polycss-shadow-receiver)");
+    if (!groundSvg) return { error: "no ground svg" };
+    const path = groundSvg.querySelector("path");
+    const d = path?.getAttribute("d") ?? "";
+    // Split into subpaths and count CCW vs CW winding for each
+    const subpaths = d.split("Z").filter(Boolean);
+    const sample = subpaths.slice(0, 6).map((sp) => {
+      // Parse "Mx,yLx,yLx,y..." → vertex list
+      const coords = sp.replace(/[ML]/g, ",").split(",").filter(Boolean).map(Number);
+      const verts = [];
+      for (let i = 0; i + 1 < coords.length; i += 2) verts.push([coords[i], coords[i + 1]]);
+      // Signed area (positive = math-CCW = screen-CW in SVG)
+      let a = 0;
+      for (let i = 0; i < verts.length; i++) {
+        const p = verts[i]; const q = verts[(i + 1) % verts.length];
+        a += p[0] * q[1] - q[0] * p[1];
+      }
+      return { vcount: verts.length, signedArea: a / 2 };
+    });
+    let ccwCount = 0, cwCount = 0, degenCount = 0;
+    const cwOffenders = [];
+    for (const sp of subpaths) {
+      const coords = sp.replace(/[ML]/g, ",").split(",").filter(Boolean).map(Number);
+      const verts = [];
+      for (let i = 0; i + 1 < coords.length; i += 2) verts.push([coords[i], coords[i + 1]]);
+      let a = 0;
+      for (let i = 0; i < verts.length; i++) {
+        const p = verts[i]; const q = verts[(i + 1) % verts.length];
+        a += p[0] * q[1] - q[0] * p[1];
+      }
+      if (a > 1) ccwCount++;
+      else if (a < -1) { cwCount++; if (cwOffenders.length < 3) cwOffenders.push({ area: a / 2, vcount: verts.length, verts }); }
+      else degenCount++;
+    }
+    return {
+      svgWidth: groundSvg.getAttribute("width"),
+      svgHeight: groundSvg.getAttribute("height"),
+      svgTransform: groundSvg.style.transform,
+      subpathCount: subpaths.length,
+      ccwCount, cwCount, degenCount,
+      cwOffenders,
+    };
+  });
+  console.log("shadow:", JSON.stringify(shadowDump, null, 2));
+  console.log("/tmp/real-shadow.png");
+} finally {
+  await browser.close();
+}
diff --git a/bench/real-shadow.html b/bench/real-shadow.html
new file mode 100644
index 00000000..345f6231
--- /dev/null
+++ b/bench/real-shadow.html
@@ -0,0 +1,595 @@
+
+
+
+  
+  polycss real-mesh shadow — plane + apple + electric pole
+  
+
+
+  
+
+ + + 60° + + + 56° + + + + + + +
+
+ + + + diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index 835cca09..4b2c0be4 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -616,11 +616,6 @@ export function createPolyScene( /** Dynamic-mode shadow `` leaves, one per non-deduped casting * polygon. Empty in baked mode (which uses `shadowSvg` instead). */ shadowRendered: HTMLElement[]; - /** Shadow ``s emitted for this mesh — one for the global ground - * plane plus (if any other mesh has `receiveShadow: true`) one per - * receiver face that this caster's shadow lands on. Cleared as a - * group on each re-emit. */ - shadowSvgs: SVGSVGElement[]; voxelRenderer?: PolyVoxelRenderer; disposeAtlas?: () => void; polygons: Polygon[]; @@ -648,6 +643,20 @@ export function createPolyScene( // no casting mesh exists yet, so no shadow leaves should be emitted. let currentGroundCssZ: number | null = null; + // Scene-level shadow SVGs. One per surface (ground + each receiver + // face). Every caster's projection onto a given surface ends up in + // that surface's single SVG path, so overlapping shadows from + // different casters composite via SVG fill-rule=nonzero (one solid + // silhouette per surface) rather than stacking opacity at the DOM + // level. Rebuilt as a whole on any caster/receiver/light change. + const sceneShadowSvgs: SVGSVGElement[] = []; + function clearAllSceneShadows(): void { + for (const svg of sceneShadowSvgs) { + if (svg.parentNode) svg.parentNode.removeChild(svg); + } + sceneShadowSvgs.length = 0; + } + // Apply CSS perspective on the camera wrapper, not the scene element. // CSS `perspective` only foreshortens direct children's 3D transforms, so // the wrapper must be the perspective context for .polycss-scene to work @@ -774,14 +783,17 @@ export function createPolyScene( } function clearShadowLeaves(entry: MeshEntry): void { + // Per-entry `` leaves (dynamic-mode chain + legacy callers) still + // hang off the mesh and must be cleared individually. for (const el of entry.shadowRendered) { if (el.parentNode) el.parentNode.removeChild(el); } entry.shadowRendered.length = 0; - for (const svg of entry.shadowSvgs) { - if (svg.parentNode) svg.parentNode.removeChild(svg); - } - entry.shadowSvgs.length = 0; + // SVG shadow surfaces are scene-scoped (one per ground / receiver + // face, aggregating every caster). Any per-entry trigger that asks + // to clear leaves drops the whole scene-level set; emitSceneShadows + // will rebuild it next. + clearAllSceneShadows(); } function disposeRendered(rendered: RenderedPoly[], disposeAtlas?: () => void): void { @@ -1179,53 +1191,52 @@ export function createPolyScene( // free CSS variable updates. The visual upside (no alpha stacking, // preserved holes, fewer DOM nodes) is worth the JS cost for typical // scenes — huge meshes during light-slider drag can profile if needed. - function emitShadowLeaves(entry: MeshEntry): void { - clearShadowLeaves(entry); - if (!entry.castShadow) return; - // Need a ground plane to project onto. If none has been computed - // yet (no caster meshes), bail and wait for the next - // recomputeShadowGround pass to drive emission. - if (currentGroundCssZ === null) return; + // Per-entry trigger: callers pass the entry that changed, but emission + // is scene-wide. Drop the arg here so any change rebuilds the whole + // shadow set in one shot — every surface aggregates every caster. + function emitShadowLeaves(_entry: MeshEntry): void { + emitSceneShadows(); + } + + // Rebuilds every shadow SVG in the scene from scratch. Iterates each + // SURFACE (the global ground + every receiver face) once, then sweeps + // every caster's projection onto that surface into the same compound + // path. SVG fill-rule=nonzero collapses overlapping CCW outlines into + // one filled silhouette per surface — overlapping shadows from + // different casters don't multiply their opacity at the DOM level. + function emitSceneShadows(): void { + clearAllSceneShadows(); + const casters: MeshEntry[] = []; + for (const m of meshes) if (!m.disposed && m.castShadow) casters.push(m); + if (casters.length === 0) return; const shadowColor = currentOptions.shadow?.color ?? "#000000"; const shadowOpacity = currentOptions.shadow?.opacity ?? 0.25; const parsed = parseHexColor(shadowColor)?.rgb ?? [0, 0, 0]; const r = parsed[0], g = parsed[1], b = parsed[2]; + const lightDir = currentOptions.directionalLight?.direction + ?? ([0.4, -0.7, 0.59] as Vec3); - // Loose-tolerance dedup for shadow casting ONLY — much more permissive - // than the parse-time dedup that affects the rendered model. Multiple - // coincident or near-coincident polygons cast overlapping shadow - // leaves that visibly stack on the receiver; emitting one is enough. - // Tolerances allow ~25° off-parallel normals and ~0.5 world units of - // plane-offset drift, catching back-to-back doubled faces and minor + // Per-caster shadow dedup (independent meshes can't dedup against + // each other). Computed once per caster, reused across surfaces. + // Loose tolerances catch back-to-back doubled faces and minor // importer artifacts without false-positively dropping legitimate // inner/outer wall pairs that cast genuinely distinct shadows. - // Light-independent — runs once per mesh-polygon change. - const shadowDedupDrop = findOverlappingPolygonDuplicates(entry.polygons, { - normalTolerance: 0.1, - distanceTolerance: 0.5, - overlapFraction: 0.4, - }); - - const lightDir = currentOptions.directionalLight?.direction - ?? ([0.4, -0.7, 0.59] as Vec3); + const dedupByCaster = new Map>(); + for (const c of casters) { + dedupByCaster.set(c, findOverlappingPolygonDuplicates(c.polygons, { + normalTolerance: 0.1, + distanceTolerance: 0.5, + overlapFraction: 0.4, + })); + } - emitShadowSvg( - entry, - shadowDedupDrop, - lightDir, - currentGroundCssZ, - r, g, b, - shadowOpacity, - ); - // Per-receiver-face shadows (experimental). Each face of a mesh - // marked receiveShadow:true catches the caster's projected outline - // clipped to that face. The SVGs mount on the RECEIVER's wrapper - // so the receiver's mesh transform places them correctly. + if (currentGroundCssZ !== null) { + emitSceneGroundShadow(casters, dedupByCaster, lightDir, currentGroundCssZ, r, g, b, shadowOpacity); + } for (const receiver of meshes) { - if (receiver === entry) continue; - if (!receiver.receiveShadow || receiver.disposed) continue; - emitReceiverFaceShadows(entry, receiver, shadowDedupDrop, lightDir, r, g, b, shadowOpacity); + if (receiver.disposed || !receiver.receiveShadow) continue; + emitSceneReceiverShadows(casters, dedupByCaster, receiver, lightDir, r, g, b, shadowOpacity); } } @@ -1236,68 +1247,74 @@ export function createPolyScene( // accumulation at intersections). SVG content is internally 2D so this // sidesteps the `opacity + transform-style: preserve-3d` flatten trap // that breaks CSS-only shadow grouping in a 3D scene. - function emitShadowSvg( - entry: MeshEntry, - dedupDrop: Set, + // Scene-level ground surface: one SVG containing every caster's + // projection onto the global ground plane. Overlapping caster shadows + // (e.g. pole shadow + cube shadow) collapse into one filled silhouette + // via fill-rule=nonzero instead of stacking opacity. + function emitSceneGroundShadow( + casters: MeshEntry[], + dedupByCaster: Map>, lightDir: Vec3, groundCssZ: number, - r: number, - g: number, - b: number, + r: number, g: number, b: number, opacity: number, ): void { const polyProjections: Array> = []; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; let fpMinX = Infinity, fpMinY = Infinity, fpMaxX = -Infinity, fpMaxY = -Infinity; - // Caster position (world units). Baked into each vertex so the - // shadow moves with the mesh when setTransform(position) changes. - // TODO: also bake rotation/scale for full mesh-transform support. - const cpos = entry.handle.transform.position ?? [0, 0, 0]; - for (const item of entry.rendered) { - if (dedupDrop.has(item.polygonIndex)) continue; - const plan = item.plan; - if (!plan) continue; - const polygon = entry.polygons[item.polygonIndex]; - if (!polygon) continue; - - const projected: Array<[number, number]> = []; - for (const v of polygon.vertices) { - // World → CSS-3D: swap X and Y, scale by BASE_TILE. Matches the - // axis convention used by plan.matrix / --shadow-proj so the - // projected output sits where the dynamic-mode shadow would. - const wx = v[0] + cpos[0]; - const wy = v[1] + cpos[1]; - const wz = v[2] + cpos[2]; - const cssVertex: Vec3 = [ - wy * DEFAULT_TILE, - wx * DEFAULT_TILE, - wz * DEFAULT_TILE, - ]; - if (cssVertex[0] < fpMinX) fpMinX = cssVertex[0]; - if (cssVertex[1] < fpMinY) fpMinY = cssVertex[1]; - if (cssVertex[0] > fpMaxX) fpMaxX = cssVertex[0]; - if (cssVertex[1] > fpMaxY) fpMaxY = cssVertex[1]; - const p = projectCssVertexToGround(cssVertex, lightDir, groundCssZ); - projected.push(p); - if (p[0] < minX) minX = p[0]; - if (p[1] < minY) minY = p[1]; - if (p[0] > maxX) maxX = p[0]; - if (p[1] > maxY) maxY = p[1]; + for (const caster of casters) { + const cpos = caster.handle.transform.position ?? [0, 0, 0]; + const dedupDrop = dedupByCaster.get(caster)!; + for (const item of caster.rendered) { + if (dedupDrop.has(item.polygonIndex)) continue; + const plan = item.plan; + if (!plan) continue; + const polygon = caster.polygons[item.polygonIndex]; + if (!polygon) continue; + + const projected: Array<[number, number]> = []; + for (const v of polygon.vertices) { + // World vertex (mesh-local, world units) → CSS via the same + // axis swap (world.x → CSS-Y, world.y → CSS-X) and tile scale + // (× DEFAULT_TILE) that the atlas builder applies per leaf. + // Then add transform.position as raw CSS px — that's how the + // mesh WRAPPER applies it (translate3d(pos[0]px, pos[1]px, + // pos[2]px), no axis swap, no tile multiplier). + const cssVertex: Vec3 = [ + v[1] * DEFAULT_TILE + cpos[0], + v[0] * DEFAULT_TILE + cpos[1], + v[2] * DEFAULT_TILE + cpos[2], + ]; + if (cssVertex[0] < fpMinX) fpMinX = cssVertex[0]; + if (cssVertex[1] < fpMinY) fpMinY = cssVertex[1]; + if (cssVertex[0] > fpMaxX) fpMaxX = cssVertex[0]; + if (cssVertex[1] > fpMaxY) fpMaxY = cssVertex[1]; + const p = projectCssVertexToGround(cssVertex, lightDir, groundCssZ); + projected.push(p); + if (p[0] < minX) minX = p[0]; + if (p[1] < minY) minY = p[1]; + if (p[0] > maxX) maxX = p[0]; + if (p[1] > maxY) maxY = p[1]; + } + // Per-polygon convex hull on the projected 2D points. N-gons + // from glTF imports aren't always perfectly planar in 3D, and + // projecting a non-planar N-gon yields a SELF-INTERSECTING 2D + // polygon. The signed-area-based winding check + // (`ensureCcw2D`) returns the NET signed area, which can + // disagree with the actual visual winding for self-intersecting + // shapes — leading to one rogue subpath rendered with + // opposite winding under fill-rule=nonzero, which then + // SUBTRACTS from neighboring CCW shadows (visible as wedge- + // shaped holes in the final shadow). Hull-per-polygon + // guarantees each subpath is a simple convex polygon → + // winding is always reliable. Triangles are unchanged + // (already simple); only N-gons get hulled. + const simplified = projected.length > 3 ? convexHull2D(projected) : projected; + if (simplified.length >= 3) polyProjections.push(simplified); } - polyProjections.push(projected); } if (polyProjections.length === 0) return; - // Cap how far the shadow can extend BEYOND THE MESH FOOTPRINT. - // Low-elevation lights shear projections across the ground so far - // that the bbox can exceed tens of thousands of pixels each side, - // which forces the browser to rasterize a >100M-pixel backing store - // on every repaint (visible as scene-wide flicker when the camera - // or light moves). The footprint (no-shear silhouette) must stay - // fully inside the SVG so the shadow under/next to the mesh is - // preserved — we only truncate the sheared end that's off-screen - // anyway. SVG overflow:hidden does the actual clipping. Callers - // can disable the cap by setting shadow.maxExtend to Infinity. const maxExtend = currentOptions.shadow?.maxExtend ?? 2000; const bx0 = Math.max(minX, fpMinX - maxExtend); const by0 = Math.max(minY, fpMinY - maxExtend); @@ -1307,13 +1324,6 @@ export function createPolyScene( const height = by1 - by0; if (!(width > 0) || !(height > 0)) return; - // Concatenate every projected polygon into ONE compound `d` string — - // each subpath as its own `M…L…Z` block. Under `fill-rule="nonzero"` - // overlapping CCW subpaths composite as one filled region without - // alpha stacking, AND gaps between subpaths remain as gaps (the - // shadow inherits the silhouette's holes for free). We normalize - // each projected polygon to CCW so any back-projected (CW) ones - // don't cancel adjacent winding and punch unwanted holes. let d = ""; for (const verts of polyProjections) { const ccw = ensureCcw2D(verts); @@ -1323,35 +1333,21 @@ export function createPolyScene( } d += "Z"; } - // Cut the receiver footprints out of the ground shadow: each - // receiver mesh's convex XY hull becomes a CW hole subpath under - // fill-rule=nonzero. Without this the ground shadow extends - // straight through the receiver in 2D and visually leaks past the - // receiver edge in 3D (the ground plane sits below the receiver's - // height). - for (const receiver of meshes) { - if (receiver === entry || receiver.disposed || !receiver.receiveShadow) continue; - const rpos = receiver.handle.transform.position ?? [0, 0, 0]; - const xy: Array<[number, number]> = []; - for (const poly of receiver.polygons) { - for (const v of poly.vertices) { - xy.push([ - (v[1] + rpos[1]) * DEFAULT_TILE, - (v[0] + rpos[0]) * DEFAULT_TILE, - ]); - } - } - if (xy.length < 3) continue; - const hull = convexHull2D(xy); - if (hull.length < 3) continue; - // CW order = subtract under nonzero fill-rule. Reverse the CCW hull. - const cw = hull.slice().reverse(); - d += `M${(cw[0]![0] - bx0).toFixed(3)},${(cw[0]![1] - by0).toFixed(3)}`; - for (let i = 1; i < cw.length; i++) { - d += `L${(cw[i]![0] - bx0).toFixed(3)},${(cw[i]![1] - by0).toFixed(3)}`; - } - d += "Z"; - } + // (No receiver-footprint subtraction.) The earlier "cut every + // receiver's hull as a CW hole" approach broke fill-rule=nonzero + // wherever a receiver overlapped the caster's silhouette: a CCW + // caster (+1) plus a CW receiver (-1) cancels at every single- + // coverage edge, leaving only doubled-coverage interior and + // producing visible wedge holes / halos along every shadow edge. + // + // Physically the cut was trying to express "this receiver blocks + // light from reaching the ground under it." But for casters that + // already include the receiver's body in their own silhouette + // (apple on ground), the cut redundantly cancels the very shadow + // we want. For casters above an elevated receiver (pole on cube), + // the right fix is volumetric occlusion, not 2D subtraction. + // Deferred until we hit a scene where shadow-through-elevated- + // receiver is actually distracting. const svgNS = "http://www.w3.org/2000/svg"; const svg = doc.createElementNS(svgNS, "svg"); @@ -1359,12 +1355,6 @@ export function createPolyScene( svg.setAttribute("width", String(width)); svg.setAttribute("height", String(height)); svg.setAttribute("viewBox", `0 0 ${width} ${height}`); - // CSS-Z places the SVG plane at the ground in the mesh's local frame; - // the mesh wrapper's own transform is applied above this. X/Y origin - // shifts the SVG so its (0,0) lines up with the (clamped) bbox corner. - // overflow:hidden + will-change:transform: bound the backing store - // and keep the SVG on its own GPU layer so scene repaints don't - // re-rasterize the whole sheet. svg.setAttribute( "style", `position:absolute;top:0;left:0;display:block;overflow:hidden;` + @@ -1377,74 +1367,71 @@ export function createPolyScene( const fillColor = `rgb(${r},${g},${b})`; path.setAttribute("fill", fillColor); path.setAttribute("fill-rule", "nonzero"); - // Hairline stroke in the same color as the fill: covers the - // sub-pixel cracks between adjacent projected polygons (their shared - // edges don't rasterize exactly under nonzero fill, leaving visible - // 1-px slivers when zoomed out). Round joins smooth sharp corners. path.setAttribute("stroke", fillColor); path.setAttribute("stroke-width", "2"); path.setAttribute("stroke-linejoin", "round"); path.setAttribute("opacity", opacity.toFixed(4)); svg.appendChild(path); - entry.shadowSvgs.push(svg); - // Mount on the SCENE root, not on the mesh wrapper — vertex coords - // are now in WORLD space (mesh position baked in above), so we - // can't sit inside a wrapper whose translate would double-apply. + sceneShadowSvgs.push(svg); const sceneFirst = sceneEl.firstChild; if (sceneFirst) sceneEl.insertBefore(svg, sceneFirst); else sceneEl.appendChild(svg); } - // (experimental) For each face of a receiver mesh, projects the caster's - // polygons onto that face's plane (along the light direction), clips - // each projection to the face's 2D outline (Sutherland-Hodgman), and - // emits one SVG per face whose path is the union of all clipped - // shadows. The SVG mounts on the RECEIVER's wrapper with a matrix3d - // that orients its 2D content to the face plane in 3D. - // - // NOTE: assumes both caster and receiver have identity mesh transforms - // (no position/rotation/scale). Supporting transforms requires baking - // each mesh's wrapper transform into vertex coords before projection; - // deferred to follow-up. - function emitReceiverFaceShadows( - casterEntry: MeshEntry, - receiverEntry: MeshEntry, - dedupDrop: Set, - lightDir: Vec3, - r: number, g: number, b: number, - opacity: number, - ): void { - const llen = Math.hypot(lightDir[0], lightDir[1], lightDir[2]) || 1; - const Lx = lightDir[0] / llen; - const Ly = lightDir[1] / llen; - const Lz = lightDir[2] / llen; - const svgNS = "http://www.w3.org/2000/svg"; - // Bake mesh positions (caster + receiver) into vertex coords. Both - // sets of polygon.vertices are mesh-local; the projection math - // needs them in the same (world) frame to be meaningful. - const cpos = casterEntry.handle.transform.position ?? [0, 0, 0]; - const rpos = receiverEntry.handle.transform.position ?? [0, 0, 0]; - const worldCss = (vert: Vec3, pos: Vec3): Vec3 => [ - (vert[1] + pos[1]) * DEFAULT_TILE, - (vert[0] + pos[0]) * DEFAULT_TILE, - (vert[2] + pos[2]) * DEFAULT_TILE, - ]; + type ReceiverPlaneGroup = { + O: Vec3; // CSS-3D origin (representative face vertex 0) + n: Vec3; // unit normal + u: Vec3; // in-plane u basis + v: Vec3; // in-plane v basis (= n × u) + outlineUv: Array<[number, number]>; // CCW convex hull of group's (u,v) coords + }; - for (const face of receiverEntry.polygons) { + // Groups a receiver's polygons by shared plane (matching normal + + // plane offset within tolerance), then computes a 2D convex-hull + // outline per group in the group's own (u, v) coords. Each returned + // group becomes one shadow-receiving surface. + // + // Why convex hull instead of a proper polygon union: Sutherland- + // Hodgman (used downstream for caster clipping) only handles convex + // clip polygons, and the hull is cheap and stable. For typical + // receivers (cubes, planes, simple platforms) the hull is the exact + // outline. For L-shaped coplanar regions it over-extends — shadows + // would extend past the receiver in the L's concave corner — but + // those are rare in practice. + // + // Tolerance choices: dot-product > 0.999 (~2.5° angular) catches + // tessellation artifacts on flat surfaces without merging adjacent + // faces of a low-poly curved mesh. Plane-offset tolerance is 0.5 + // CSS px — sub-pixel coplanarity drift in glTF imports doesn't + // separate what should be a single surface. + function groupReceiverFaceGroups( + receiver: MeshEntry, + rpos: Vec3, + worldCss: (vert: Vec3, pos: Vec3) => Vec3, + ): ReceiverPlaneGroup[] { + type FacePlane = { + face: Polygon; + O: Vec3; n: Vec3; u: Vec3; v: Vec3; + offset: number; // plane offset = n · O, used as the hashing dim + }; + const facePlanes: FacePlane[] = []; + for (const face of receiver.polygons) { if (face.vertices.length < 3) continue; - const v0 = face.vertices[0]!; - const v1 = face.vertices[1]!; - const v2 = face.vertices[2]!; - const O = worldCss(v0, rpos); - const w1 = worldCss(v1, rpos); - const w2 = worldCss(v2, rpos); + const O = worldCss(face.vertices[0]!, rpos); + const w1 = worldCss(face.vertices[1]!, rpos); + const w2 = worldCss(face.vertices[2]!, rpos); const e1: Vec3 = [w1[0] - O[0], w1[1] - O[1], w1[2] - O[2]]; const e2: Vec3 = [w2[0] - O[0], w2[1] - O[1], w2[2] - O[2]]; - // Face normal = e2 × e1 (NOT e1 × e2). polygon.vertices are CCW - // in world coords; the world→CSS axis swap (Y↔X) inverts that - // handedness, so the cross product order must flip to recover an - // outward-pointing normal in CSS-3D space. + // Normal = e2 × e1 (NOT e1 × e2). polycss uses an axis swap + // (world Y → CSS X) when emitting leaves, which flips + // handedness. The atlas builder's outward face normal in CSS + // coords is the LEFT-hand cross product (= -right-hand). For + // shadow projection we need the same outward direction so the + // back-face cull aligns with what the renderer treats as the + // lit side. e1 × e2 would point inward → shadow would land on + // the side of the apple facing AWAY from the light (visible as + // "shadow on back/bottom of apple" instead of the lit side). const nx = e2[1] * e1[2] - e2[2] * e1[1]; const ny = e2[2] * e1[0] - e2[0] * e1[2]; const nz = e2[0] * e1[1] - e2[1] * e1[0]; @@ -1459,71 +1446,210 @@ export function createPolyScene( n[2] * u[0] - n[0] * u[2], n[0] * u[1] - n[1] * u[0], ]; + const offset = n[0] * O[0] + n[1] * O[1] + n[2] * O[2]; + facePlanes.push({ face, O, n, u, v, offset }); + } - // Face outline in (u, v) local coords. CCW for clip-poly contract. - const faceUv: Array<[number, number]> = face.vertices.map((vert) => { - const w = worldCss(vert, rpos); - const dx = w[0] - O[0]; - const dy = w[1] - O[1]; - const dz = w[2] - O[2]; - return [ - dx * u[0] + dy * u[1] + dz * u[2], - dx * v[0] + dy * v[1] + dz * v[2], - ]; - }); - const faceCcw = ensureCcw2D(faceUv); + const NORMAL_TOL = 0.001; // 1 - dot < 0.001 → ~2.5° + const OFFSET_TOL = 0.5; // CSS px + type Group = { rep: FacePlane; faces: FacePlane[] }; + const groups: Group[] = []; + // First-fit O(F²) grouping. Apple-class meshes (~300 faces) are + // fine; higher-poly receivers may need a bucketed lookup later. + for (const fp of facePlanes) { + let merged = false; + for (const g of groups) { + const r = g.rep; + const dot = fp.n[0] * r.n[0] + fp.n[1] * r.n[1] + fp.n[2] * r.n[2]; + if (1 - dot > NORMAL_TOL) continue; + if (Math.abs(fp.offset - r.offset) > OFFSET_TOL) continue; + g.faces.push(fp); + merged = true; + break; + } + if (!merged) groups.push({ rep: fp, faces: [fp] }); + } - // dot(L, n) — denominator for the plane-ray intersection. We also - // require the face's outward normal to point TOWARD the light - // source (Ldotn > 0): back-facing receiver faces (e.g. the bottom - // of a cube) can't physically receive a shadow, and projecting - // onto them would paint a phantom shadow under the receiver. + const out: ReceiverPlaneGroup[] = []; + for (const g of groups) { + const { O, n, u, v } = g.rep; + const uvs: Array<[number, number]> = []; + for (const fp of g.faces) { + for (const vert of fp.face.vertices) { + const w = worldCss(vert, rpos); + const dx = w[0] - O[0]; + const dy = w[1] - O[1]; + const dz = w[2] - O[2]; + uvs.push([ + dx * u[0] + dy * u[1] + dz * u[2], + dx * v[0] + dy * v[1] + dz * v[2], + ]); + } + } + if (uvs.length < 3) continue; + const hull = convexHull2D(uvs); + if (hull.length < 3) continue; + out.push({ O, n, u, v, outlineUv: ensureCcw2D(hull) }); + } + return out; + } + + // Scene-level per-receiver-surface shadow. For each coplanar face + // group on the receiver, project EVERY caster's polygons onto that + // group's plane, clip each projection to the group's outline + // (Sutherland-Hodgman), and emit ONE SVG per group whose path is the + // union of every clipped caster shadow. The SVG sits on the scene + // root with a matrix3d that orients its 2D content to the group + // plane in 3D. + // + // Aggregating all casters into one SVG per surface is the whole point + // of the scene-level refactor: overlapping shadows from different + // casters share one alpha pass instead of stacking under + // multiply/screen. + // + // NOTE: assumes casters and receivers have identity rotation/scale + // (positions are baked). Rotation/scale support requires extending + // worldCss to apply the wrapper's full transform; deferred. + function emitSceneReceiverShadows( + casters: MeshEntry[], + dedupByCaster: Map>, + receiverEntry: MeshEntry, + lightDir: Vec3, + r: number, g: number, b: number, + opacity: number, + ): void { + const llen = Math.hypot(lightDir[0], lightDir[1], lightDir[2]) || 1; + const Lx = lightDir[0] / llen; + const Ly = lightDir[1] / llen; + const Lz = lightDir[2] / llen; + const svgNS = "http://www.w3.org/2000/svg"; + const rpos = receiverEntry.handle.transform.position ?? [0, 0, 0]; + // Mesh-local vertex (world units) → CSS via the same axis swap + // (world.x → CSS-Y, world.y → CSS-X) and tile scale that the atlas + // builder applies. transform.position is then added as raw CSS px + // — that's how the mesh wrapper's translate3d treats it. Mixing + // tile-scaled position into the same expression would shift the + // shadow at a different rate than the visible mesh. + const worldCss = (vert: Vec3, pos: Vec3): Vec3 => [ + vert[1] * DEFAULT_TILE + pos[0], + vert[0] * DEFAULT_TILE + pos[1], + vert[2] * DEFAULT_TILE + pos[2], + ]; + + // Group receiver polygons by shared plane (matching normal AND offset + // within tolerance). Each group becomes ONE shadow surface: instead + // of N tiny SVGs along a tessellated quad we emit a single SVG whose + // outline is the convex hull of the group's coplanar faces. Cubes + // stay at 6 surfaces (each face is its own group); a flat plane + // subdivided into N triangles collapses to 1 surface; an apple + // shrinks from O(triangles) to O(distinct normals * planes). + // One surface per coplanar group on the receiver. For flat-faced + // receivers (cubes, planes) groupReceiverFaceGroups merges into + // few groups. For curved/tessellated receivers (apple, sphere) + // each face becomes its own group → one shadow per face on its + // actual surface plane. The shadow projects in the real light + // direction onto each face's exact plane and is clipped to that + // face's outline — accurate by polygon, no flat-blob faking. + const surfaces = groupReceiverFaceGroups(receiverEntry, rpos, worldCss); + + for (const group of surfaces) { + const { O, n, u, v, outlineUv } = group; + // Cull back-facing surfaces. A back-facing receiver face has + // its outward normal pointing AWAY from the light — physically + // it can't receive light at all (the receiver's own body + // occludes it). Projecting a caster shadow onto it computes a + // "virtual" intersection BEHIND the front of the receiver, but + // the receiver's lit polygons would render in front of it + // anyway. Skip them to avoid painting shadow on faces that + // already sit in their own self-shadow. const Ldotn = Lx * n[0] + Ly * n[1] + Lz * n[2]; if (Ldotn <= 1e-6) continue; - const clipped: Array> = []; + // Group outline → bbox cap for the SVG viewport. Inflated slightly + // (1 CSS px) so clipped subjects that touch the outline edge don't + // get sliced off by rounding. let minU = Infinity, minV = Infinity, maxU = -Infinity, maxV = -Infinity; - for (const item of casterEntry.rendered) { - if (dedupDrop.has(item.polygonIndex)) continue; - const plan = item.plan; - if (!plan) continue; - const polygon = casterEntry.polygons[item.polygonIndex]; - if (!polygon) continue; + for (const pt of outlineUv) { + if (pt[0] < minU) minU = pt[0]; + if (pt[1] < minV) minV = pt[1]; + if (pt[0] > maxU) maxU = pt[0]; + if (pt[1] > maxV) maxV = pt[1]; + } - const projected: Array<[number, number]> = []; - let skip = false; - for (const vert of polygon.vertices) { - const w = worldCss(vert, cpos); - const Vx = w[0]; - const Vy = w[1]; - const Vz = w[2]; - // t such that V - t*L lies on the plane. - const VmOx = Vx - O[0]; - const VmOy = Vy - O[1]; - const VmOz = Vz - O[2]; - const t = (VmOx * n[0] + VmOy * n[1] + VmOz * n[2]) / Ldotn; - if (t < -1e-6) { skip = true; break; } - const Px = Vx - t * Lx; - const Py = Vy - t * Ly; - const Pz = Vz - t * Lz; - const dx = Px - O[0]; - const dy = Py - O[1]; - const dz = Pz - O[2]; - projected.push([ - dx * u[0] + dy * u[1] + dz * u[2], - dx * v[0] + dy * v[1] + dz * v[2], - ]); - } - if (skip || projected.length < 3) continue; - const subjectCcw = ensureCcw2D(projected); - const clip = clipPolygonToConvex2D(subjectCcw, faceCcw); - if (clip.length < 3) continue; - clipped.push(clip); - for (const pt of clip) { - if (pt[0] < minU) minU = pt[0]; - if (pt[1] < minV) minV = pt[1]; - if (pt[0] > maxU) maxU = pt[0]; - if (pt[1] > maxV) maxV = pt[1]; + // Per-triangle 3D-clip then project. For each caster polygon + // (fan-triangulated), 3D-clip the tri against the receiver + // plane half-space (keeping only the above-plane part), project + // the surviving 3D polygon onto the face's 2D plane along the + // light, then Sutherland-Hodgman-clip against the face outline. + // + // This matches a true raytracer's per-tri occlusion test + // (verified against a Möller-Trumbore reference: 0 false + // negatives on front-facing receiver faces; ~10% false + // positives in edge-case projection geometry, which present + // as faint extra shadow on faces adjacent to true shadow). + // + // Earlier per-vertex approaches failed because (a) projecting + // only above-plane vertices loses the silhouette contribution + // of tris that straddle the plane (their projected shape + // collapses to a line), and (b) per-caster hull engulfs faces + // in the bounding silhouette but outside the actual occluding + // geometry. + const planeDist = (w: Vec3): number => + (w[0] - O[0]) * n[0] + (w[1] - O[1]) * n[1] + (w[2] - O[2]) * n[2]; + const planeCross = (a: Vec3, b: Vec3, da: number, db: number): Vec3 => { + const s = da / (da - db); + return [a[0] + s * (b[0] - a[0]), a[1] + s * (b[1] - a[1]), a[2] + s * (b[2] - a[2])]; + }; + const projectOntoPlane = (w: Vec3): [number, number] => { + const VmOx = w[0] - O[0]; + const VmOy = w[1] - O[1]; + const VmOz = w[2] - O[2]; + const t = (VmOx * n[0] + VmOy * n[1] + VmOz * n[2]) / Ldotn; + const Px = w[0] - t * Lx; + const Py = w[1] - t * Ly; + const Pz = w[2] - t * Lz; + const dx = Px - O[0]; + const dy = Py - O[1]; + const dz = Pz - O[2]; + return [dx * u[0] + dy * u[1] + dz * u[2], dx * v[0] + dy * v[1] + dz * v[2]]; + }; + const clipped: Array> = []; + for (const caster of casters) { + if (caster === receiverEntry) continue; + const dedupDrop = dedupByCaster.get(caster)!; + const cpos = caster.handle.transform.position ?? [0, 0, 0]; + for (const item of caster.rendered) { + if (dedupDrop.has(item.polygonIndex)) continue; + const plan = item.plan; + if (!plan) continue; + const polygon = caster.polygons[item.polygonIndex]; + if (!polygon) continue; + const wv = polygon.vertices.map((vert) => worldCss(vert, cpos)); + // Fan-triangulate the polygon. + for (let triIdx = 1; triIdx < wv.length - 1; triIdx++) { + const tA = wv[0]!, tB = wv[triIdx]!, tC = wv[triIdx + 1]!; + const dA = planeDist(tA), dB = planeDist(tB), dC = planeDist(tC); + // 3D-clip the tri against the receiver plane half-space. + // Keep vertices with dist >= 0; emit edge-plane + // intersections when an edge crosses. Result is the + // tri's above-plane polygon (triangle if 1 vertex was + // above + 2 below, quad if 2 above + 1 below, the + // original triangle if all 3 above, empty if all below). + const above: Vec3[] = []; + const cycle: Array<[Vec3, number]> = [[tA, dA], [tB, dB], [tC, dC]]; + for (let k = 0; k < 3; k++) { + const [p, dp] = cycle[k]!; + const [q, dq] = cycle[(k + 1) % 3]!; + if (dp >= 0) above.push(p); + if ((dp >= 0) !== (dq >= 0)) above.push(planeCross(p, q, dp, dq)); + } + if (above.length < 3) continue; + const projected = above.map(projectOntoPlane); + const subjectCcw = ensureCcw2D(projected); + const clip = clipPolygonToConvex2D(subjectCcw, outlineUv); + if (clip.length < 3) continue; + clipped.push(clip); + } } } @@ -1545,10 +1671,13 @@ export function createPolyScene( // SVG matrix3d places the 2D layout box onto the face plane in 3D: // svg(x, y) → world = O' + x*u + y*v // where O' = O + minU*u + minV*v (anchor at the clipped bbox corner) - // plus a tiny push along the face normal so the shadow sits in - // front of the receiver surface (otherwise CSS Z-fighting with the - // receiver's own polygon can drop the shadow behind it). - const lift = 0.5; + // plus a push along the face normal so the shadow sits IN FRONT + // of the receiver surface. Without enough lift, CSS Z-fighting + // resolves the shadow BEHIND the receiver polygon — the shadow + // exists but the receiver's lit color is painted on top, hiding + // it. 5px is enough to win the depth test reliably without being + // visibly detached. + const lift = 5; const Ox = O[0] + minU * u[0] + minV * v[0] + lift * n[0]; const Oy = O[1] + minU * u[1] + minV * v[1] + lift * n[1]; const Oz = O[2] + minU * u[2] + minV * v[2] + lift * n[2]; @@ -1582,9 +1711,7 @@ export function createPolyScene( path.setAttribute("opacity", opacity.toFixed(4)); svg.appendChild(path); - casterEntry.shadowSvgs.push(svg); - // Mount on the SCENE root — coords are world space, so we can't - // sit inside a mesh wrapper whose translate would double-apply. + sceneShadowSvgs.push(svg); const first = sceneEl.firstChild; if (first) sceneEl.insertBefore(svg, first); else sceneEl.appendChild(svg); @@ -1691,11 +1818,7 @@ export function createPolyScene( const hadGround = currentGroundCssZ !== null; currentGroundCssZ = null; // No casters left: drop any shadow elements still mounted. - if (hadGround) { - for (const entry of meshes) { - if (entry.shadowSvgs.length) clearShadowLeaves(entry); - } - } + if (hadGround) clearAllSceneShadows(); return; } const lift = currentOptions.shadow?.lift ?? 0.05; @@ -1706,11 +1829,8 @@ export function createPolyScene( const groundCssZ = (minWorldZ + lift) * DEFAULT_TILE; const prevGround = currentGroundCssZ; currentGroundCssZ = groundCssZ; - if (prevGround !== groundCssZ) { - for (const entry of meshes) { - if (entry.castShadow) emitShadowLeaves(entry); - } - } + // Ground changed: rebuild the scene-level shadow set once. + if (prevGround !== groundCssZ) emitSceneShadows(); } async function renderEntryChunked( @@ -1853,7 +1973,6 @@ export function createPolyScene( parseResult, rendered: [], shadowRendered: [], - shadowSvgs: [], polygons: sourcePolygons, voxelSource: parseResult.voxelSource, disposed: false, @@ -2187,22 +2306,16 @@ export function createPolyScene( emitShadowLeaves(entry); recomputeShadowGround(); } - // Receiver toggled: re-emit every caster so their per-receiver - // shadows are added (or removed). - if (entry.receiveShadow !== prevReceiveShadow) { - for (const m of meshes) { - if (m.castShadow) emitShadowLeaves(m); - } - } + // Receiver toggled: rebuild the scene-level shadow set so this + // mesh's faces are added (or removed) as receivers. + if (entry.receiveShadow !== prevReceiveShadow) emitSceneShadows(); // Position change: shadow geometry depends on world-space coords, - // so recompute ground + re-emit every caster (this one if it - // casts, plus all casters if this one is a receiver they project - // onto). + // so recompute ground (which itself rebuilds the scene shadows + // when the plane moves) and rebuild once more in case only the + // caster/receiver moved within the same plane. if (t.position !== undefined) { recomputeShadowGround(); - for (const m of meshes) { - if (m.castShadow) emitShadowLeaves(m); - } + emitSceneShadows(); } }, dispose() { @@ -2241,14 +2354,11 @@ export function createPolyScene( applyMeshLightVarOverride(entry, transform.rotation); recomputeAutoCenter(); recomputeShadowGround(); - // New receiver: existing casters need to re-emit so they project - // onto this receiver's faces. recomputeShadowGround only re-emits - // when the global ground changes. - if (entry.receiveShadow) { - for (const m of meshes) { - if (m !== entry && m.castShadow) emitShadowLeaves(m); - } - } + // New receiver: the scene-level shadow set must rebuild so existing + // casters get faces to project onto. recomputeShadowGround only + // does this when the global ground changes; force a rebuild for the + // receiver-only case. + if (entry.receiveShadow) emitSceneShadows(); return handle; } @@ -2297,18 +2407,18 @@ export function createPolyScene( && !shadowOptsEqual(prevShadow, nextShadow); const shadowReemitNeeded = lightDirChanged || shadowAppearanceChanged; if (textureLightingChanged) { + // Voxel meshes need a full re-render to swap baked/dynamic leaf + // emission; everything else just needs the shadow set rebuilt + // (one scene-level pass at the end covers all casters). for (const entry of meshes) { if (!strategiesChanged && !seamBleedChanged && (entry.voxelSource || entry.voxelRenderer)) { renderEntry(entry); - } else { - emitShadowLeaves(entry); } } recomputeShadowGround(); + emitSceneShadows(); } else if (shadowReemitNeeded) { - for (const entry of meshes) { - if (entry.castShadow) emitShadowLeaves(entry); - } + emitSceneShadows(); } if (shadowAppearanceChanged && partial.shadow?.lift !== prevShadow?.lift) { recomputeShadowGround(); From 85b2e4a9217751a70ed4c4584a039eb590535dea Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 24 May 2026 01:23:45 +0200 Subject: [PATCH 26/28] perf(shadow): cache per-receiver face planes across light updates --- bench/real-shadow-shot.mjs | 2 +- bench/real-shadow.html | 17 ++- packages/polycss/src/api/createPolyScene.ts | 130 ++++++++++++-------- 3 files changed, 91 insertions(+), 58 deletions(-) diff --git a/bench/real-shadow-shot.mjs b/bench/real-shadow-shot.mjs index ac99aaa8..a65cfe76 100644 --- a/bench/real-shadow-shot.mjs +++ b/bench/real-shadow-shot.mjs @@ -15,7 +15,7 @@ try { page.on("console", (msg) => { if (msg.type() === "error") console.log(`[console.error] ${msg.text()}`); }); - await page.goto("http://localhost:4400/real-shadow.html", { waitUntil: "networkidle", timeout: 15000 }); + await page.goto("http://localhost:4400/real-shadow.html?norayt", { waitUntil: "networkidle", timeout: 15000 }); await page.waitForTimeout(1500); await page.waitForTimeout(300); const status = await page.evaluate(() => document.getElementById("status")?.textContent ?? ""); diff --git a/bench/real-shadow.html b/bench/real-shadow.html index 345f6231..e138b24e 100644 --- a/bench/real-shadow.html +++ b/bench/real-shadow.html @@ -78,6 +78,8 @@ }; } + const NO_SHADOW = new URLSearchParams(location.search).has("noshadow"); + const PLANE_R = 12; const planeHandle = scene.add( { @@ -90,7 +92,7 @@ }], dispose() {}, }, - { castShadow: false, receiveShadow: true, merge: false }, + { castShadow: false, receiveShadow: !NO_SHADOW, merge: false }, ); // ────────────────────────────────────────────────────────────────── @@ -502,7 +504,14 @@ return { tp, fp, fn, tn, fnFaces, fpFaces, ldotnHist }; } + // URL toggles for trace/debug runs: + // ?norayt skip the brute-force raytracer + overlay + // ?noshadow disable cast/receive on apple+pole (baseline) + const URL_FLAGS = new URLSearchParams(location.search); + const SKIP_RAYTRACE = URL_FLAGS.has("norayt"); + const SKIP_SHADOWS = URL_FLAGS.has("noshadow"); function runRaytraceAndOverlay() { + if (SKIP_RAYTRACE) return; if (!meshes.apple || !meshes.pole) return; const t0 = performance.now(); // Pole-only occlusion. Receiver self-occlusion is handled by @@ -544,13 +553,13 @@ const applePos = [2.5, 0, 0]; const polePos = [-50, 1000, 0]; const appleHandle = scene.add(scalePolygons(appleRaw, 0.5), { - castShadow: true, - receiveShadow: true, + castShadow: !NO_SHADOW, + receiveShadow: !NO_SHADOW, merge: false, position: applePos, }); const poleHandle = scene.add(scalePolygons(poleRaw, 2), { - castShadow: true, + castShadow: !NO_SHADOW, receiveShadow: false, merge: false, position: polePos, diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index 1b1583d3..3e5f5755 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -657,6 +657,40 @@ export function createPolyScene( sceneShadowSvgs.length = 0; } + // Per-receiver cached face geometry. Each entry holds one record + // per coplanar face group on the receiver: plane (O, n, u, v), + // outline polygon (used as Sutherland-Hodgman clip), bbox in (u, v) + // for SVG sizing, and the pre-stringified matrix3d transform that + // places an SVG on that face plane. + // + // All of this is invariant under light/caster changes. Per light + // tick we just re-run the per-tri SH and build the path `d` — + // never recompute groups or basis. Cache invalidated when the + // receiver's polygon count or position changes. + interface ReceiverFacePlane { + O: Vec3; + n: Vec3; + u: Vec3; + v: Vec3; + outlineUv: Array<[number, number]>; + minU: number; + minV: number; + width: number; + height: number; + matrixCss: string; + } + const receiverShadowCache = new Map(); + const receiverShadowCacheKey = new Map(); + function clearReceiverShadowCache(entry?: MeshEntry): void { + if (entry) { + receiverShadowCache.delete(entry); + receiverShadowCacheKey.delete(entry); + } else { + receiverShadowCache.clear(); + receiverShadowCacheKey.clear(); + } + } + // Apply CSS perspective on the camera wrapper, not the scene element. // CSS `perspective` only foreshortens direct children's 3D transforms, so // the wrapper must be the perspective context for .polycss-scene to work @@ -1547,17 +1581,45 @@ export function createPolyScene( // stay at 6 surfaces (each face is its own group); a flat plane // subdivided into N triangles collapses to 1 surface; an apple // shrinks from O(triangles) to O(distinct normals * planes). - // One surface per coplanar group on the receiver. For flat-faced - // receivers (cubes, planes) groupReceiverFaceGroups merges into - // few groups. For curved/tessellated receivers (apple, sphere) - // each face becomes its own group → one shadow per face on its - // actual surface plane. The shadow projects in the real light - // direction onto each face's exact plane and is clipped to that - // face's outline — accurate by polygon, no flat-blob faking. - const surfaces = groupReceiverFaceGroups(receiverEntry, rpos, worldCss); - - for (const group of surfaces) { - const { O, n, u, v, outlineUv } = group; + // Per-receiver face cache: plane data invariant under light. We + // recompute groups (which is O(F²) and allocates lots of vectors) + // only when receiver polygons or position change. The SVG element + // is still created per-frame for non-empty paths — pre-mounting + // an SVG per face balloons compositor layers (248 → +33ms gpuViz). + const cacheKey = `${receiverEntry.polygons.length}|${rpos.join(",")}`; + let cachedPlanes = receiverShadowCache.get(receiverEntry); + if (cachedPlanes === undefined || receiverShadowCacheKey.get(receiverEntry) !== cacheKey) { + const surfaces = groupReceiverFaceGroups(receiverEntry, rpos, worldCss); + cachedPlanes = surfaces.map((group): ReceiverFacePlane => { + const { O, n, u, v, outlineUv } = group; + let minU = Infinity, minV = Infinity, maxU = -Infinity, maxV = -Infinity; + for (const pt of outlineUv) { + if (pt[0] < minU) minU = pt[0]; + if (pt[1] < minV) minV = pt[1]; + if (pt[0] > maxU) maxU = pt[0]; + if (pt[1] > maxV) maxV = pt[1]; + } + const width = maxU - minU; + const height = maxV - minV; + const lift = 5; + const Ox = O[0] + minU * u[0] + minV * v[0] + lift * n[0]; + const Oy = O[1] + minU * u[1] + minV * v[1] + lift * n[1]; + const Oz = O[2] + minU * u[2] + minV * v[2] + lift * n[2]; + const m = [ + u[0], u[1], u[2], 0, + v[0], v[1], v[2], 0, + n[0], n[1], n[2], 0, + Ox, Oy, Oz, 1, + ]; + const matrixCss = `matrix3d(${m.map((x) => x.toFixed(4)).join(",")})`; + return { O, n, u, v, outlineUv, minU, minV, width, height, matrixCss }; + }); + receiverShadowCache.set(receiverEntry, cachedPlanes); + receiverShadowCacheKey.set(receiverEntry, cacheKey); + } + + for (const group of cachedPlanes) { + const { O, n, u, v, outlineUv, minU, minV, width, height, matrixCss } = group; // Cull back-facing surfaces. A back-facing receiver face has // its outward normal pointing AWAY from the light — physically // it can't receive light at all (the receiver's own body @@ -1569,17 +1631,6 @@ export function createPolyScene( const Ldotn = Lx * n[0] + Ly * n[1] + Lz * n[2]; if (Ldotn <= 1e-6) continue; - // Group outline → bbox cap for the SVG viewport. Inflated slightly - // (1 CSS px) so clipped subjects that touch the outline edge don't - // get sliced off by rounding. - let minU = Infinity, minV = Infinity, maxU = -Infinity, maxV = -Infinity; - for (const pt of outlineUv) { - if (pt[0] < minU) minU = pt[0]; - if (pt[1] < minV) minV = pt[1]; - if (pt[0] > maxU) maxU = pt[0]; - if (pt[1] > maxV) maxV = pt[1]; - } - // Per-triangle 3D-clip then project. For each caster polygon // (fan-triangulated), 3D-clip the tri against the receiver // plane half-space (keeping only the above-plane part), project @@ -1657,12 +1708,8 @@ export function createPolyScene( } } - if (clipped.length === 0) continue; - const width = maxU - minU; - const height = maxV - minV; - if (!(width > 0) || !(height > 0)) continue; + if (clipped.length === 0 || !(width > 0) || !(height > 0)) continue; - // Compound path offset to start at (0, 0) inside the SVG. let d = ""; for (const verts of clipped) { d += `M${(verts[0]![0] - minU).toFixed(3)},${(verts[0]![1] - minV).toFixed(3)}`; @@ -1672,26 +1719,6 @@ export function createPolyScene( d += "Z"; } - // SVG matrix3d places the 2D layout box onto the face plane in 3D: - // svg(x, y) → world = O' + x*u + y*v - // where O' = O + minU*u + minV*v (anchor at the clipped bbox corner) - // plus a push along the face normal so the shadow sits IN FRONT - // of the receiver surface. Without enough lift, CSS Z-fighting - // resolves the shadow BEHIND the receiver polygon — the shadow - // exists but the receiver's lit color is painted on top, hiding - // it. 5px is enough to win the depth test reliably without being - // visibly detached. - const lift = 5; - const Ox = O[0] + minU * u[0] + minV * v[0] + lift * n[0]; - const Oy = O[1] + minU * u[1] + minV * v[1] + lift * n[1]; - const Oz = O[2] + minU * u[2] + minV * v[2] + lift * n[2]; - const m = [ - u[0], u[1], u[2], 0, - v[0], v[1], v[2], 0, - n[0], n[1], n[2], 0, - Ox, Oy, Oz, 1, - ]; - const svg = doc.createElementNS(svgNS, "svg"); svg.setAttribute("class", "polycss-shadow polycss-shadow-svg polycss-shadow-receiver"); svg.setAttribute("width", String(width)); @@ -1701,9 +1728,8 @@ export function createPolyScene( "style", `position:absolute;top:0;left:0;display:block;overflow:hidden;` + `transform-origin:0 0;pointer-events:none;will-change:transform;` + - `transform:matrix3d(${m.map((x) => x.toFixed(4)).join(",")})`, + `transform:${matrixCss}`, ); - const path = doc.createElementNS(svgNS, "path"); path.setAttribute("d", d); const fillColor = `rgb(${r},${g},${b})`; @@ -1714,11 +1740,8 @@ export function createPolyScene( path.setAttribute("stroke-linejoin", "round"); path.setAttribute("opacity", opacity.toFixed(4)); svg.appendChild(path); - sceneShadowSvgs.push(svg); - const first = sceneEl.firstChild; - if (first) sceneEl.insertBefore(svg, first); - else sceneEl.appendChild(svg); + sceneEl.insertBefore(svg, sceneEl.firstChild); } } @@ -2121,6 +2144,7 @@ export function createPolyScene( // Removing from DOM doesn't auto-dispose generated atlas/blob URLs. clearRendered(entry); meshes.delete(entry); + clearReceiverShadowCache(entry); recomputeAutoCenter(); recomputeShadowGround(); }, From 333b641f6fe9581d6be3c9b64ff7bcd2a0754e6e Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 24 May 2026 01:45:36 +0200 Subject: [PATCH 27/28] perf(shadow): mount-once SVGs, AABB cull, lower precision, drop stroke Stack of four scene-shadow optimisations: - Mount each per-face shadow SVG once and toggle display:none instead of recreating per frame; eliminates ~150 createElement/removeChild + ~600 setAttribute mutations per drag tick - Pre-build per-caster-polygon items (world-space verts + 3D AABB) once per frame; project the 8 AABB corners onto each face plane and bbox-cull against the face outline before the per-tri Sutherland-Hodgman loop - Round path coordinates to 1 decimal (sub-pixel for receiver-plane CSS px); ~30% smaller path strings to parse/raster - Drop the per-path stroke; the implicit AA seam bleed was not visually load-bearing in any tested scene Real-shadow drag bench (apple + electric pole, ?norayt): - script ms/frame: 29.6 -> 12.6 (-57%) - frame_time_p95_ms: 33.3 -> 25.0 (-25%) - frame_time_p99_ms: 39.9 -> 33.7 (-15%) - compositorMain ms/frame: 14.9 -> 7.3 (-51%) - paint/prePaint/layout/style all -64..-79% --- bench/count-svgs.mjs | 40 ++++ bench/shadow-diff.mjs | 45 +++++ bench/shadow-multi-angle.mjs | 83 ++++++++ packages/polycss/src/api/createPolyScene.ts | 213 ++++++++++++++------ 4 files changed, 322 insertions(+), 59 deletions(-) create mode 100644 bench/count-svgs.mjs create mode 100644 bench/shadow-diff.mjs create mode 100644 bench/shadow-multi-angle.mjs diff --git a/bench/count-svgs.mjs b/bench/count-svgs.mjs new file mode 100644 index 00000000..c9f58f9e --- /dev/null +++ b/bench/count-svgs.mjs @@ -0,0 +1,40 @@ +import { chromium } from "playwright"; +import { chromiumArgsWithGpuDefault } from "/Users/apresmoi/Documents/voxcss/bench/chromium-defaults.mjs"; +const browser = await chromium.launch({ headless: true, args: chromiumArgsWithGpuDefault([], { softwareBackend: false }) }); +const ctx = await browser.newContext({ viewport: { width: 1200, height: 900 } }); +const page = await ctx.newPage(); +await page.goto("http://localhost:4400/real-shadow.html?norayt", { waitUntil: "networkidle", timeout: 15000 }); +await page.waitForTimeout(2000); +// Take a few samples across drag. +const samples = []; +for (const az of [60, 120, 180, 240, 300]) { + await page.evaluate((v) => { + const el = document.getElementById("az"); + el.value = String(v); + el.dispatchEvent(new Event("input")); + }, az); + await page.waitForTimeout(50); + const stats = await page.evaluate(() => { + const allSvgs = document.querySelectorAll("svg.polycss-shadow"); + const groundSvgs = document.querySelectorAll("svg.polycss-shadow:not(.polycss-shadow-receiver)"); + const recvSvgs = document.querySelectorAll("svg.polycss-shadow-receiver"); + const paths = document.querySelectorAll("svg.polycss-shadow path"); + let subpaths = 0, dlen = 0; + paths.forEach(p => { + const d = p.getAttribute("d") || ""; + subpaths += (d.match(/M/g) || []).length; + dlen += d.length; + }); + return { + total: allSvgs.length, + ground: groundSvgs.length, + receivers: recvSvgs.length, + paths: paths.length, + subpaths, + dlenKB: (dlen / 1024).toFixed(1), + }; + }); + samples.push({ az, ...stats }); +} +console.log(JSON.stringify(samples, null, 2)); +await browser.close(); diff --git a/bench/shadow-diff.mjs b/bench/shadow-diff.mjs new file mode 100644 index 00000000..67033e88 --- /dev/null +++ b/bench/shadow-diff.mjs @@ -0,0 +1,45 @@ +// Quick pixel-diff between two screenshot directories. +// Compares same-named files; reports max channel delta and pct of changed pixels. +import { readdirSync, readFileSync } from "node:fs"; +import { resolve, basename } from "node:path"; +import { PNG } from "pngjs"; + +const [aDir, bDir] = process.argv.slice(2).map((p) => resolve(p)); +if (!aDir || !bDir) { + console.error("usage: node shadow-diff.mjs "); + process.exit(2); +} + +const files = readdirSync(aDir).filter((f) => f.endsWith(".png")); +let worst = { file: "", maxDelta: 0, changedPct: 0 }; +for (const f of files) { + const a = PNG.sync.read(readFileSync(`${aDir}/${f}`)); + let b; + try { + b = PNG.sync.read(readFileSync(`${bDir}/${f}`)); + } catch (e) { + console.log(`${f}: MISSING IN B`); + continue; + } + if (a.width !== b.width || a.height !== b.height) { + console.log(`${f}: SIZE MISMATCH ${a.width}x${a.height} vs ${b.width}x${b.height}`); + continue; + } + let maxD = 0; + let changed = 0; + for (let i = 0; i < a.data.length; i += 4) { + const dr = Math.abs(a.data[i] - b.data[i]); + const dg = Math.abs(a.data[i + 1] - b.data[i + 1]); + const db = Math.abs(a.data[i + 2] - b.data[i + 2]); + const d = Math.max(dr, dg, db); + if (d > 0) changed++; + if (d > maxD) maxD = d; + } + const pct = (changed / (a.width * a.height)) * 100; + console.log(`${f.padEnd(36)} maxΔ=${String(maxD).padStart(3)} changed=${pct.toFixed(3)}%`); + if (maxD > worst.maxDelta || (maxD === worst.maxDelta && pct > worst.changedPct)) { + worst = { file: f, maxDelta: maxD, changedPct: pct }; + } +} +console.log("---"); +console.log(`worst: ${worst.file} maxΔ=${worst.maxDelta} changed=${worst.changedPct.toFixed(3)}%`); diff --git a/bench/shadow-multi-angle.mjs b/bench/shadow-multi-angle.mjs new file mode 100644 index 00000000..7f6ef7ed --- /dev/null +++ b/bench/shadow-multi-angle.mjs @@ -0,0 +1,83 @@ +// Capture shadow scene from many camera positions + a few light positions +// so we can compare baseline vs each optimization visually. +// +// Usage: node bench/shadow-multi-angle.mjs +// Output: /cam-{az}-light-{lightAz}.png +// +// Camera rotates by faking pointer drag on the host element. +// Light is set programmatically via the #az slider on real-shadow.html. +import { chromium } from "playwright"; +import { resolve } from "node:path"; +import { chromiumArgsWithGpuDefault } from "./chromium-defaults.mjs"; + +const outDir = process.argv[2] ? resolve(process.argv[2]) : resolve("bench/results/baselines/shadows"); + +const browser = await chromium.launch({ + headless: true, + args: chromiumArgsWithGpuDefault([], { softwareBackend: false }), +}); + +try { + const ctx = await browser.newContext({ viewport: { width: 1200, height: 900 } }); + const page = await ctx.newPage(); + page.on("pageerror", (err) => console.log(`[pageerror] ${err.message}`)); + page.on("console", (msg) => { + if (msg.type() === "error") console.log(`[console.error] ${msg.text()}`); + }); + await page.goto("http://localhost:4400/real-shadow.html?norayt", { waitUntil: "networkidle", timeout: 15000 }); + // Wait for meshes + first frame. + await page.waitForFunction(() => document.querySelectorAll("svg.polycss-shadow").length > 0, { timeout: 15000 }); + await page.waitForTimeout(400); + + const lightSweep = [60, 150, 240, 330]; + const camRotations = [0, 90, 180, 270]; // horizontal drag pixels = rotation + const dragPxPer90Deg = 300; + + for (const lightAz of lightSweep) { + await page.evaluate((v) => { + const el = document.getElementById("az"); + el.value = String(v); + el.dispatchEvent(new Event("input")); + }, lightAz); + await page.waitForTimeout(80); + + for (const camAz of camRotations) { + // Reset camera by hard-reloading? Too slow. Instead, rotate + // by drag delta from previous position. Track absolute drag. + const dragX = camAz === 0 ? 0 : dragPxPer90Deg * (camAz / 90); + // Each frame: pointerdown at center, pointermove by dragX, pointerup. + // The orbit controls track delta, so each iteration applies a + // relative rotation. To make absolute we'd need to reset the + // camera; for our purposes a per-angle sweep works the same. + if (camAz > 0) { + await page.evaluate(({ dx }) => { + const host = document.getElementById("host"); + const r = host.getBoundingClientRect(); + const cx = r.x + r.width / 2; + const cy = r.y + r.height / 2; + host.dispatchEvent(new PointerEvent("pointerdown", { clientX: cx, clientY: cy, button: 0, pointerType: "mouse", bubbles: true, pointerId: 1 })); + host.dispatchEvent(new PointerEvent("pointermove", { clientX: cx + dx, clientY: cy, button: 0, pointerType: "mouse", bubbles: true, pointerId: 1 })); + host.dispatchEvent(new PointerEvent("pointerup", { clientX: cx + dx, clientY: cy, button: 0, pointerType: "mouse", bubbles: true, pointerId: 1 })); + }, { dx: dragPxPer90Deg / (camRotations.length - 1) }); + await page.waitForTimeout(120); + } + + const file = `${outDir}/cam-${String(camAz).padStart(3, "0")}-light-${String(lightAz).padStart(3, "0")}.png`; + await page.screenshot({ path: file, fullPage: false }); + console.log(file); + } + // Reset camera back to azim 0 for next light run by reversing drags. + await page.evaluate(({ dx }) => { + const host = document.getElementById("host"); + const r = host.getBoundingClientRect(); + const cx = r.x + r.width / 2; + const cy = r.y + r.height / 2; + host.dispatchEvent(new PointerEvent("pointerdown", { clientX: cx, clientY: cy, button: 0, pointerType: "mouse", bubbles: true, pointerId: 1 })); + host.dispatchEvent(new PointerEvent("pointermove", { clientX: cx - dx, clientY: cy, button: 0, pointerType: "mouse", bubbles: true, pointerId: 1 })); + host.dispatchEvent(new PointerEvent("pointerup", { clientX: cx - dx, clientY: cy, button: 0, pointerType: "mouse", bubbles: true, pointerId: 1 })); + }, { dx: dragPxPer90Deg }); + await page.waitForTimeout(120); + } +} finally { + await browser.close(); +} diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index 3e5f5755..3294c832 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -655,6 +655,11 @@ export function createPolyScene( if (svg.parentNode) svg.parentNode.removeChild(svg); } sceneShadowSvgs.length = 0; + // Mark all cached receiver-face SVGs as hidden. Per-frame + // emitSceneReceiverShadows will reveal the ones with shadow + // content and leave the rest in `display:none`, which keeps the + // compositor layer count low without tearing the elements down. + hideAllReceiverFaceSvgs(); } // Per-receiver cached face geometry. Each entry holds one record @@ -678,18 +683,46 @@ export function createPolyScene( width: number; height: number; matrixCss: string; + // Mount-once SVG + path: created on first non-empty frame for + // this face, then kept in the DOM. Per-frame we just mutate + // `d`/`fill`/`opacity` and toggle `display`. Avoids per-frame + // ~248 createElementNS + insertBefore + 248 layer churn that + // dominated gpuViz (~40 ms/frame). + svg: SVGSVGElement | null; + path: SVGPathElement | null; + visible: boolean; } const receiverShadowCache = new Map(); const receiverShadowCacheKey = new Map(); + function disposeReceiverPlanes(planes: ReceiverFacePlane[]): void { + for (const p of planes) { + if (p.svg && p.svg.parentNode) p.svg.parentNode.removeChild(p.svg); + p.svg = null; + p.path = null; + } + } function clearReceiverShadowCache(entry?: MeshEntry): void { if (entry) { + const planes = receiverShadowCache.get(entry); + if (planes) disposeReceiverPlanes(planes); receiverShadowCache.delete(entry); receiverShadowCacheKey.delete(entry); } else { + for (const planes of receiverShadowCache.values()) disposeReceiverPlanes(planes); receiverShadowCache.clear(); receiverShadowCacheKey.clear(); } } + function hideAllReceiverFaceSvgs(): void { + for (const planes of receiverShadowCache.values()) { + for (const p of planes) { + if (p.svg && p.visible) { + p.svg.style.display = "none"; + p.visible = false; + } + } + } + } // Apply CSS perspective on the camera wrapper, not the scene element. // CSS `perspective` only foreshortens direct children's 3D transforms, so @@ -1612,12 +1645,54 @@ export function createPolyScene( Ox, Oy, Oz, 1, ]; const matrixCss = `matrix3d(${m.map((x) => x.toFixed(4)).join(",")})`; - return { O, n, u, v, outlineUv, minU, minV, width, height, matrixCss }; + return { + O, n, u, v, outlineUv, minU, minV, width, height, matrixCss, + svg: null, path: null, visible: false, + }; }); receiverShadowCache.set(receiverEntry, cachedPlanes); receiverShadowCacheKey.set(receiverEntry, cacheKey); } + // Pre-build per-caster-polygon items once per frame: world-space + // vertices and 3D AABB. We need both for every face anyway, so + // doing it inside the face loop redundantly transforms the same + // mesh-local vertices N_faces times. The AABB also lets us + // bbox-cull a polygon against the face plane before per-tri SH. + interface CasterPolyItem { + wv: Vec3[]; + // 8 corners of axis-aligned 3D bbox in CSS world. + bboxCorners: Vec3[]; + } + const casterItems: CasterPolyItem[] = []; + for (const caster of casters) { + if (caster === receiverEntry) continue; + const dedupDrop = dedupByCaster.get(caster)!; + const cpos = caster.handle.transform.position ?? [0, 0, 0]; + for (const item of caster.rendered) { + if (dedupDrop.has(item.polygonIndex)) continue; + const plan = item.plan; + if (!plan) continue; + const polygon = caster.polygons[item.polygonIndex]; + if (!polygon) continue; + const wv = polygon.vertices.map((vert) => worldCss(vert, cpos)); + let minX = Infinity, minY = Infinity, minZ = Infinity; + let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; + for (const w of wv) { + if (w[0] < minX) minX = w[0]; if (w[0] > maxX) maxX = w[0]; + if (w[1] < minY) minY = w[1]; if (w[1] > maxY) maxY = w[1]; + if (w[2] < minZ) minZ = w[2]; if (w[2] > maxZ) maxZ = w[2]; + } + const bboxCorners: Vec3[] = [ + [minX, minY, minZ], [maxX, minY, minZ], + [minX, maxY, minZ], [maxX, maxY, minZ], + [minX, minY, maxZ], [maxX, minY, maxZ], + [minX, maxY, maxZ], [maxX, maxY, maxZ], + ]; + casterItems.push({ wv, bboxCorners }); + } + } + for (const group of cachedPlanes) { const { O, n, u, v, outlineUv, minU, minV, width, height, matrixCss } = group; // Cull back-facing surfaces. A back-facing receiver face has @@ -1669,79 +1744,99 @@ export function createPolyScene( return [dx * u[0] + dy * u[1] + dz * u[2], dx * v[0] + dy * v[1] + dz * v[2]]; }; const clipped: Array> = []; - for (const caster of casters) { - if (caster === receiverEntry) continue; - const dedupDrop = dedupByCaster.get(caster)!; - const cpos = caster.handle.transform.position ?? [0, 0, 0]; - for (const item of caster.rendered) { - if (dedupDrop.has(item.polygonIndex)) continue; - const plan = item.plan; - if (!plan) continue; - const polygon = caster.polygons[item.polygonIndex]; - if (!polygon) continue; - const wv = polygon.vertices.map((vert) => worldCss(vert, cpos)); - // Fan-triangulate the polygon. - for (let triIdx = 1; triIdx < wv.length - 1; triIdx++) { - const tA = wv[0]!, tB = wv[triIdx]!, tC = wv[triIdx + 1]!; - const dA = planeDist(tA), dB = planeDist(tB), dC = planeDist(tC); - // 3D-clip the tri against the receiver plane half-space. - // Keep vertices with dist >= 0; emit edge-plane - // intersections when an edge crosses. Result is the - // tri's above-plane polygon (triangle if 1 vertex was - // above + 2 below, quad if 2 above + 1 below, the - // original triangle if all 3 above, empty if all below). - const above: Vec3[] = []; - const cycle: Array<[Vec3, number]> = [[tA, dA], [tB, dB], [tC, dC]]; - for (let k = 0; k < 3; k++) { - const [p, dp] = cycle[k]!; - const [q, dq] = cycle[(k + 1) % 3]!; - if (dp >= 0) above.push(p); - if ((dp >= 0) !== (dq >= 0)) above.push(planeCross(p, q, dp, dq)); - } - if (above.length < 3) continue; - const projected = above.map(projectOntoPlane); - const subjectCcw = ensureCcw2D(projected); - const clip = clipPolygonToConvex2D(subjectCcw, outlineUv); - if (clip.length < 3) continue; - clipped.push(clip); + const fMinU = minU, fMinV = minV; + const fMaxU = group.minU + width; + const fMaxV = group.minV + height; + for (const item of casterItems) { + // Project 3D bbox corners onto the face plane; if the bbox of + // those projections is disjoint from the face outline bbox in + // (u, v), this polygon casts no shadow on this face. Cheap + // 8-projection prefilter that skips the per-tri 3D-clip + SH. + // Also confirms the polygon has at least one corner ABOVE the + // receiver plane — if all 8 corners are below, no shadow. + const corners = item.bboxCorners; + let anyAbove = false; + let pMinU = Infinity, pMinV = Infinity, pMaxU = -Infinity, pMaxV = -Infinity; + for (let ci = 0; ci < 8; ci++) { + const c = corners[ci]!; + if (planeDist(c) >= 0) anyAbove = true; + const pr = projectOntoPlane(c); + if (pr[0] < pMinU) pMinU = pr[0]; + if (pr[0] > pMaxU) pMaxU = pr[0]; + if (pr[1] < pMinV) pMinV = pr[1]; + if (pr[1] > pMaxV) pMaxV = pr[1]; + } + if (!anyAbove) continue; + if (pMaxU < fMinU || pMinU > fMaxU || pMaxV < fMinV || pMinV > fMaxV) continue; + const wv = item.wv; + // Fan-triangulate the polygon. + for (let triIdx = 1; triIdx < wv.length - 1; triIdx++) { + const tA = wv[0]!, tB = wv[triIdx]!, tC = wv[triIdx + 1]!; + const dA = planeDist(tA), dB = planeDist(tB), dC = planeDist(tC); + const above: Vec3[] = []; + const cycle: Array<[Vec3, number]> = [[tA, dA], [tB, dB], [tC, dC]]; + for (let k = 0; k < 3; k++) { + const [p, dp] = cycle[k]!; + const [q, dq] = cycle[(k + 1) % 3]!; + if (dp >= 0) above.push(p); + if ((dp >= 0) !== (dq >= 0)) above.push(planeCross(p, q, dp, dq)); } + if (above.length < 3) continue; + const projected = above.map(projectOntoPlane); + const subjectCcw = ensureCcw2D(projected); + const clip = clipPolygonToConvex2D(subjectCcw, outlineUv); + if (clip.length < 3) continue; + clipped.push(clip); } } if (clipped.length === 0 || !(width > 0) || !(height > 0)) continue; + // Coordinate precision of 1 decimal is sub-pixel for typical + // CSS-px values; the path is sized in receiver-plane CSS px + // (often 100-1000). Cutting from .toFixed(3) drops path string + // size by ~30%, less browser parsing + raster fast path. let d = ""; for (const verts of clipped) { - d += `M${(verts[0]![0] - minU).toFixed(3)},${(verts[0]![1] - minV).toFixed(3)}`; + d += `M${(verts[0]![0] - minU).toFixed(1)},${(verts[0]![1] - minV).toFixed(1)}`; for (let i = 1; i < verts.length; i++) { - d += `L${(verts[i]![0] - minU).toFixed(3)},${(verts[i]![1] - minV).toFixed(3)}`; + d += `L${(verts[i]![0] - minU).toFixed(1)},${(verts[i]![1] - minV).toFixed(1)}`; } d += "Z"; } - const svg = doc.createElementNS(svgNS, "svg"); - svg.setAttribute("class", "polycss-shadow polycss-shadow-svg polycss-shadow-receiver"); - svg.setAttribute("width", String(width)); - svg.setAttribute("height", String(height)); - svg.setAttribute("viewBox", `0 0 ${width} ${height}`); - svg.setAttribute( - "style", - `position:absolute;top:0;left:0;display:block;overflow:hidden;` + - `transform-origin:0 0;pointer-events:none;will-change:transform;` + - `transform:${matrixCss}`, - ); - const path = doc.createElementNS(svgNS, "path"); + // Mount-once SVG + path. First frame this face has a shadow + // we allocate the elements and parent them; subsequent frames + // mutate `d`/`fill`/`opacity` and just flip `display`. + let svg = group.svg; + let path = group.path; + if (!svg || !path) { + svg = doc.createElementNS(svgNS, "svg"); + svg.setAttribute("class", "polycss-shadow polycss-shadow-svg polycss-shadow-receiver"); + svg.setAttribute("width", String(width)); + svg.setAttribute("height", String(height)); + svg.setAttribute("viewBox", `0 0 ${width} ${height}`); + svg.setAttribute( + "style", + `position:absolute;top:0;left:0;display:block;overflow:hidden;` + + `transform-origin:0 0;pointer-events:none;will-change:transform;` + + `transform:${matrixCss}`, + ); + path = doc.createElementNS(svgNS, "path"); + path.setAttribute("fill-rule", "nonzero"); + svg.appendChild(path); + sceneEl.insertBefore(svg, sceneEl.firstChild); + group.svg = svg; + group.path = path; + } else if (!group.visible) { + svg.style.display = "block"; + } + group.visible = true; path.setAttribute("d", d); const fillColor = `rgb(${r},${g},${b})`; - path.setAttribute("fill", fillColor); - path.setAttribute("fill-rule", "nonzero"); - path.setAttribute("stroke", fillColor); - path.setAttribute("stroke-width", "2"); - path.setAttribute("stroke-linejoin", "round"); - path.setAttribute("opacity", opacity.toFixed(4)); - svg.appendChild(path); - sceneShadowSvgs.push(svg); - sceneEl.insertBefore(svg, sceneEl.firstChild); + if (path.getAttribute("fill") !== fillColor) path.setAttribute("fill", fillColor); + const opStr = opacity.toFixed(4); + if (path.getAttribute("opacity") !== opStr) path.setAttribute("opacity", opStr); } } From 3dd725dd5b61ab47eabfd4febf1127e494324fcf Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 24 May 2026 01:51:06 +0200 Subject: [PATCH 28/28] perf(shadow): cache caster world-vertices + AABB across light updates Per-caster cached per-polygon data (world-space vertices and 3D AABB) is invariant under light direction. Previously rebuilt every emitSceneReceiverShadows call (once per frame during drag). Now cached on the MeshEntry and invalidated only on geometry/position change. Real-shadow drag bench: script -5%, raster -4%, compositorMain -4%, gpuViz -2%. --- packages/polycss/src/api/createPolyScene.ts | 89 ++++++++++++++------- 1 file changed, 58 insertions(+), 31 deletions(-) diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index 3294c832..8dde9ec7 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -724,6 +724,27 @@ export function createPolyScene( } } + // Per-caster cached per-polygon data: world-space vertices + 3D + // AABB corners. Invariant under light direction; depends only on + // the caster mesh's geometry and position. Reused across every + // receiver-face SH-clip in a frame and across frames within a + // drag, so the caching pays for itself many times over. + interface CasterPolyItem { + wv: Vec3[]; + bboxCorners: Vec3[]; + } + const casterItemsCache = new Map(); + const casterItemsCacheKey = new Map(); + function clearCasterItemsCache(entry?: MeshEntry): void { + if (entry) { + casterItemsCache.delete(entry); + casterItemsCacheKey.delete(entry); + } else { + casterItemsCache.clear(); + casterItemsCacheKey.clear(); + } + } + // Apply CSS perspective on the camera wrapper, not the scene element. // CSS `perspective` only foreshortens direct children's 3D transforms, so // the wrapper must be the perspective context for .polycss-scene to work @@ -1654,43 +1675,46 @@ export function createPolyScene( receiverShadowCacheKey.set(receiverEntry, cacheKey); } - // Pre-build per-caster-polygon items once per frame: world-space - // vertices and 3D AABB. We need both for every face anyway, so - // doing it inside the face loop redundantly transforms the same - // mesh-local vertices N_faces times. The AABB also lets us - // bbox-cull a polygon against the face plane before per-tri SH. - interface CasterPolyItem { - wv: Vec3[]; - // 8 corners of axis-aligned 3D bbox in CSS world. - bboxCorners: Vec3[]; - } + // Per-caster cached items: world-vertices + 3D AABB per polygon. + // Geometry is invariant under light direction, so once cached + // every receiver-face SH-clip across every drag tick reads from + // the cache. Invalidated when a caster mesh changes geometry or + // position (clearCasterItemsCache from mesh setters). const casterItems: CasterPolyItem[] = []; for (const caster of casters) { if (caster === receiverEntry) continue; - const dedupDrop = dedupByCaster.get(caster)!; const cpos = caster.handle.transform.position ?? [0, 0, 0]; - for (const item of caster.rendered) { - if (dedupDrop.has(item.polygonIndex)) continue; - const plan = item.plan; - if (!plan) continue; - const polygon = caster.polygons[item.polygonIndex]; - if (!polygon) continue; - const wv = polygon.vertices.map((vert) => worldCss(vert, cpos)); - let minX = Infinity, minY = Infinity, minZ = Infinity; - let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; - for (const w of wv) { - if (w[0] < minX) minX = w[0]; if (w[0] > maxX) maxX = w[0]; - if (w[1] < minY) minY = w[1]; if (w[1] > maxY) maxY = w[1]; - if (w[2] < minZ) minZ = w[2]; if (w[2] > maxZ) maxZ = w[2]; + const ckey = `${caster.polygons.length}|${cpos.join(",")}`; + let cached = casterItemsCache.get(caster); + if (cached === undefined || casterItemsCacheKey.get(caster) !== ckey) { + const dedupDrop = dedupByCaster.get(caster)!; + cached = []; + for (const item of caster.rendered) { + if (dedupDrop.has(item.polygonIndex)) continue; + const plan = item.plan; + if (!plan) continue; + const polygon = caster.polygons[item.polygonIndex]; + if (!polygon) continue; + const wv = polygon.vertices.map((vert) => worldCss(vert, cpos)); + let minX = Infinity, minY = Infinity, minZ = Infinity; + let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; + for (const w of wv) { + if (w[0] < minX) minX = w[0]; if (w[0] > maxX) maxX = w[0]; + if (w[1] < minY) minY = w[1]; if (w[1] > maxY) maxY = w[1]; + if (w[2] < minZ) minZ = w[2]; if (w[2] > maxZ) maxZ = w[2]; + } + const bboxCorners: Vec3[] = [ + [minX, minY, minZ], [maxX, minY, minZ], + [minX, maxY, minZ], [maxX, maxY, minZ], + [minX, minY, maxZ], [maxX, minY, maxZ], + [minX, maxY, maxZ], [maxX, maxY, maxZ], + ]; + cached.push({ wv, bboxCorners }); } - const bboxCorners: Vec3[] = [ - [minX, minY, minZ], [maxX, minY, minZ], - [minX, maxY, minZ], [maxX, maxY, minZ], - [minX, minY, maxZ], [maxX, minY, maxZ], - [minX, maxY, maxZ], [maxX, maxY, maxZ], - ]; - casterItems.push({ wv, bboxCorners }); + casterItemsCache.set(caster, cached); + casterItemsCacheKey.set(caster, ckey); } + for (const it of cached) casterItems.push(it); } for (const group of cachedPlanes) { @@ -2240,6 +2264,7 @@ export function createPolyScene( clearRendered(entry); meshes.delete(entry); clearReceiverShadowCache(entry); + clearCasterItemsCache(entry); recomputeAutoCenter(); recomputeShadowGround(); }, @@ -2250,6 +2275,8 @@ export function createPolyScene( entry.stableDom = stableDomOnUpdate; entry.voxelSource = undefined; entry.polygons = preparePolygons(polygons, mergeOnUpdate); + clearCasterItemsCache(entry); + clearReceiverShadowCache(entry); clearCurrentTriangleFrame(); handle.polygons = entry.polygons; const shouldRecomputeAutoCenter = options?.recomputeAutoCenter ?? true;