diff --git a/.gitignore b/.gitignore index 2875a6b6..a013cdfe 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,7 @@ packages/vue/tsconfig.tsbuildinfo log.md log-texture.md website/.baseline-shots/ + +# chrome-trace skill raw output +chrome-trace.json +/tmp/*.trace.json diff --git a/AGENTS.md b/AGENTS.md index 306100e0..93ba08ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,14 @@ Cast shadows are not a render-strategy leaf tag. Meshes with `castShadow: true` Receiver-shadow geometry has two caster paths. The default per-mesh **silhouette fast path** (caster ≠ receiver, ≥40 polys) projects one outline per caster instead of every front-facing triangle — but only when the caster's silhouette under the current light is a clean union of simple closed loops (every silhouette vertex shared by exactly two silhouette edges). Meshes whose silhouette has non-manifold / T-junction / open-boundary vertices (imported architecture like the castle) fall back to the **per-polygon union**, which is gap-free for any topology. Light-back-facing caster polygons are normally culled (single-sided casting, correct for clean closed meshes); the per-poly path casts **double-sided** (skips that cull) for two cases — cross-mesh casters whose silhouette is unreliable, and ALL self-shadow casters (caster = receiver) — so badly-wound / single-sided interior walls don't leave holes. Closed meshes are unaffected by double-siding: their far back-faces sit below each lit receiver plane and get above-plane-culled, adding no spurious shadow. +**Parametric shadows (all three renderers, opt-in via `shadow.parametric` + `shadow.definition`).** A lightweight approximation that replaces a caster's per-poly/silhouette projection with low-resolution coverage-contour loops, so a complex caster emits far fewer shadow-path vertices. Per caster the light-perpendicular coverage is rasterised into a mask (resolution scales with `definition`), traced with marching squares, simplified, and lifted back to 3D for the normal receiver projector. Cross-mesh casting uses ONE flat proxy outline slid toward the light so it clears receivers below the caster (footprint is depth-invariant under directional projection); self-shadow uses depth-stratified bands, each biased to its far-from-light edge so a face is only shadowed by geometry genuinely in front of it. The approximation is governed by named **rubrics** measured parametric-vs-exact in `bench/shadow-rubric.mjs`; the current correction terms are: **flat casters route to the exact path** (their tilted proxy would project garbage onto a coplanar receiver), **convex casters skip self-shadow** (a convex surface self-shadows nothing), **self-shadow depth-bias** (flat band proxies otherwise over-darken), and **hole subtraction** (coverage holes — courtyards, the coliseum arena — are emitted with opposite winding by nesting depth and the override path preserves that relative winding so they subtract under the receiver's `fill-rule: nonzero`; the exact path keeps per-loop CCW). Higher `definition` trades DOM weight for fidelity and is the knob for resolving fine concave holes. **Point lights** are supported: each shadow-casting point light gets its own RADIAL override silhouette built from the caster-centroid → light direction (the small-object approximation point shading already uses), stored per-light in `overridePointSilhouettes` and selected per pass; the directional override is the wrong outline for a finite-distance light, so point passes never reuse it. Parametric does not change the exact path, which remains the default. **Render style** is selectable via `shadow.style`: `"vector"` (default) traces the smooth concave contour; `"pixel"` greedy-meshes the coverage mask into axis-aligned rectangles for a blocky/voxel shadow — holes (courtyards, the coliseum arena) fall out for free as absent cells (no winding/`fill-rule` work), and `definition` becomes the pixel-grid resolution (lower → chunkier; the block size is the aesthetic). Both styles share the per-mesh def, point-light, and progressive machinery. The override builder (`buildParametricCasterOverride` + `isFlatCaster`/`isConvexCaster`) lives in **core** so all three renderers produce byte-identical loops — vanilla `receiverShadow.ts`, React `PolyMesh.tsx`, and Vue `PolyMesh.ts` each just call it in their caster loop and drop the result on the `ReceiverCasterInput`. Per-mesh `shadowDefinition` is a `PolyMeshTransform` field (vanilla), a `` prop (React), and a `shadow-definition` prop (Vue). The ONE intentional asymmetry: **progressive `dragDefinition` is vanilla-only**, because it's a `createPolyScene` orchestration concern (auto-detect a light-direction change → emit at the drag def → debounced full-def refine). React/Vue are declarative and re-render on prop change, so an app gets the same effect idiomatically by lowering `shadow.definition` in its own state during a drag and restoring it at rest. **Animated shadows** (`shadow.followAnimation`, all three renderers): a caster's shadow normally FREEZES during a same-topology deform — re-projecting every frame is expensive — so the default is frozen and `followAnimation` opts into tracking the pose (pair with a low parametric `definition`). Vanilla re-emits (throttled ~12fps) from the freshly-deformed polygons in `setPolygons`; React/Vue gate the caster re-registration (a same-topology polygon change is skipped unless `followAnimation`, so the receiver re-emits only when following). Topology changes (different polygon count) always re-emit regardless. + +**Cost + the laggless levers.** Cross-mesh/floor cast shadows are cheap (one outline, ~1 receiver face) and follow a moving light at 60fps+; camera orbit is free (shadows ride the scene transform — only light/geometry changes re-emit). SELF-shadow is the expensive case: it projects every depth band onto every one of the mesh's own coplanar faces, so the recompute is O(faces × bands × points) and that work is irreducible by spatial culling (the depth-band proxies span the whole mesh, so their AABBs overlap nearly every face — a broad-phase cull was measured to reject nothing). The lever for smooth interaction is therefore reducing *quality during motion*, not a faster projector. Two knobs make this configurable: +- **Per-mesh `PolyMeshTransform.shadowDefinition`** overrides the scene `shadow.definition` for one mesh's cast/self shadow — a detailed caster stays sharp while a simple prop runs cheap in the same scene. +- **`shadow.dragDefinition`** is the library's built-in progressive refinement: when set (and `parametric`), a light-DIRECTION change emits at `min(definition, dragDefinition)` for a laggless drag (castle ~100fps at 16 vs 36fps at 96), then a debounced pass re-emits at full `definition` once the light settles — the same atlas-rebake-at-rest escape hatch. It's auto-detected in `setOptions` (direction change = motion; an appearance edit like the `definition` slider renders full immediately). Per-mesh def composes: motion caps each mesh at `min(its def, dragDefinition)`. + +The fps↔definition curve and per-phase breakdown are measured with `bench/shadow-trace.html` + the chrome-trace skill; `bench/shadow-parametric.html` exposes both knobs (`definition`, `dragDef` sliders; `?def=`, `?dragdef=`). + The `.vox` fast path emits plain `` elements inside `.polycss-voxel-face` wrappers. They intentionally reuse the cheap quad tag; each visible quad has one `matrix3d(...)`, with same-color shared-edge overscan folded into the local left/top/width/height before matrix generation. The face wrappers are grouping nodes for cheap add/remove and are not render-strategy leaves. Desktop-class documents use a canonical 1px primitive for the cheapest transform shape; mobile-class documents (`pointer: coarse` or `hover: none`) use an 8px primitive and divide the in-plane matrix scale by 8 to preserve identical CSS-space geometry while avoiding large GPU filtering gaps. ### Lights diff --git a/bench/anim-shadow-test.html b/bench/anim-shadow-test.html new file mode 100644 index 00000000..9ea4a703 --- /dev/null +++ b/bench/anim-shadow-test.html @@ -0,0 +1,25 @@ + + +
+ + diff --git a/bench/entries/parityMeshes.ts b/bench/entries/parityMeshes.ts index d62caf3a..15958c51 100644 --- a/bench/entries/parityMeshes.ts +++ b/bench/entries/parityMeshes.ts @@ -3,9 +3,15 @@ * (vanilla / React / Vue) renders byte-identical polygons. Bundled into * bench/.generated/parity-meshes.js. */ -import { boxPolygons } from "@layoutit/polycss-core"; +import { boxPolygons, spherePolygons } from "@layoutit/polycss-core"; import type { Polygon } from "@layoutit/polycss-core"; +/** Round caster — its many-vertex silhouette makes the parametric `definition` + * knob visually obvious. */ +export function spherePolys(radius = 8, subdivisions = 2, color = "#dc2626"): Polygon[] { + return spherePolygons({ radius, subdivisions }).map((p) => ({ ...p, color })); +} + /** Unit-2 cube centered at the origin (sit it on the floor with position z=1). */ export function cubePolygons(color = "#dc2626"): Polygon[] { return boxPolygons({ size: 2 }).map((p) => ({ ...p, color })); diff --git a/bench/shadow-diff.mjs b/bench/shadow-diff.mjs new file mode 100644 index 00000000..23cc4c2c --- /dev/null +++ b/bench/shadow-diff.mjs @@ -0,0 +1,91 @@ +// Parametric-vs-exact shadow harness. +// +// Compares PolyCSS parametric shadows (pm=1) against PolyCSS exact shadows +// (pm=0) at IDENTICAL camera + light. Exact is ground truth, so the pixel diff +// IS the parametric approximation error. Object pixels are identical in both +// renders and cancel, so the diff isolates the shadow (no need to tell object +// from shadow visually). The browser decodes the PNGs and returns only scalar +// metrics, so no Node image library is needed. +// +// Usage: node bench/shadow-diff.mjs [base] (default base http://localhost:4322) +import { chromium } from "playwright"; +import { chromiumArgsWithGpuDefault } from "./chromium-defaults.mjs"; + +const BASE = process.argv[2] ?? "http://localhost:4322"; +const PAGE = "/shadow-parametric.html"; + +// scenario knobs shared by both renders; `pm` flips parametric on/off. +const CONFIGS = [ + { name: "castle floor (steep)", mesh: "castle", sx: 1, ss: 0, def: 48, q: "rx=55&ry=15&z=18&dx=1&dy=0.6&dz=1" }, + { name: "castle floor (graze)", mesh: "castle", sx: 1, ss: 0, def: 48, q: "rx=55&ry=15&z=18&dx=1.4&dy=0.2&dz=0.5" }, + { name: "castle self (steep)", mesh: "castle", sx: 0, ss: 1, def: 48, q: "rx=62&ry=20&z=40&dx=1&dy=0.6&dz=1" }, + { name: "castle self (side) ", mesh: "castle", sx: 0, ss: 1, def: 48, q: "rx=62&ry=20&z=40&dx=1.4&dy=0.2&dz=0.5" }, + { name: "apple floor (steep)", mesh: "apple", sx: 1, ss: 0, def: 48, q: "rx=45&ry=10&z=30&dx=1&dy=0.6&dz=1" }, + { name: "apple self (steep)", mesh: "apple", sx: 0, ss: 1, def: 48, q: "rx=45&ry=10&z=40&dx=1&dy=0.6&dz=1" }, + { name: "cottage floor (steep)", mesh: "cottage", sx: 1, ss: 0, def: 48, q: "rx=50&ry=12&z=22&dx=1&dy=0.6&dz=1" }, + { name: "coliseum floor (steep)", mesh: "coliseum", sx: 1, ss: 0, def: 48, q: "rx=50&ry=12&z=22&dx=1&dy=0.6&dz=1" }, +]; + +const url = (c, pm) => `${BASE}${PAGE}?mesh=${c.mesh}&pm=${pm}&sx=${c.sx}&ss=${c.ss}&def=${c.def}&fv=1&${c.q}`; + +async function shotHost(page, u) { + await page.goto(u, { waitUntil: "networkidle" }); + await page.waitForSelector("#poly-host .polycss-scene", { state: "attached", timeout: 30000 }).catch(() => {}); + await page.waitForTimeout(1800); + const el = await page.$("#poly-host"); + return await el.screenshot(); +} + +async function diff(scratch, bufA, bufB) { + return await scratch.evaluate(async ([a, b]) => { + const load = (src) => new Promise((res) => { const i = new Image(); i.onload = () => res(i); i.src = "data:image/png;base64," + src; }); + const [iA, iB] = await Promise.all([load(a), load(b)]); + const W = Math.min(iA.width, iB.width), H = Math.min(iA.height, iB.height); + const cv = document.createElement("canvas"); cv.width = W; cv.height = H; + const cx = cv.getContext("2d", { willReadFrequently: true }); + cx.drawImage(iA, 0, 0); const A = cx.getImageData(0, 0, W, H).data; + cx.clearRect(0, 0, W, H); cx.drawImage(iB, 0, 0); const B = cx.getImageData(0, 0, W, H).data; + const THR = 18; + // background luminance = top-left corner (always empty in both panels). + const bg = A[0] * 0.299 + A[1] * 0.587 + A[2] * 0.114; + let diff = 0, over = 0, under = 0, sum = 0; + let pShadow = 0, eShadow = 0, inter = 0, uni = 0; + for (let i = 0; i < A.length; i += 4) { + const la = A[i] * 0.299 + A[i + 1] * 0.587 + A[i + 2] * 0.114; + const lb = B[i] * 0.299 + B[i + 1] * 0.587 + B[i + 2] * 0.114; + const d = la - lb, ad = Math.abs(d); + if (ad > THR) { diff++; sum += ad; if (d < 0) over++; else under++; } + const sa = la < bg - THR, sb = lb < bg - THR; + if (sa) pShadow++; if (sb) eShadow++; + if (sa && sb) inter++; if (sa || sb) uni++; + } + const N = A.length / 4; + return { + pAreaPct: (100 * pShadow / N).toFixed(2), // parametric shadow coverage area + eAreaPct: (100 * eShadow / N).toFixed(2), // exact shadow coverage area + overPct: (100 * over / N).toFixed(2), // param darker than exact (over-shadow) + underPct: (100 * under / N).toFixed(2), // param lighter (missing shadow) + mad: (sum / N).toFixed(2), + iou: uni ? (inter / uni).toFixed(3) : "1.000", // only meaningful for shadows-only (sx=1) + }; + }, [bufA.toString("base64"), bufB.toString("base64")]); +} + +const browser = await chromium.launch({ args: chromiumArgsWithGpuDefault() }); +const page = await browser.newPage({ viewport: { width: 1600, height: 900 } }); +const scratch = await browser.newPage(); +await scratch.setContent(""); +page.on("console", (m) => { if (m.type() === "error") console.error("[page]", m.text().slice(0, 120)); }); + +console.log("\nparametric (pm=1) vs exact (pm=0) — exact is ground truth\n"); +console.log("scenario pArea% eArea% over% under% MAD IoU(sx=1)"); +console.log("-".repeat(72)); +for (const c of CONFIGS) { + const a = await shotHost(page, url(c, 1)); + const b = await shotHost(page, url(c, 0)); + const r = await diff(scratch, a, b); + const iou = c.sx === 1 ? r.iou : " - "; + console.log(`${c.name} ${r.pAreaPct.padStart(6)} ${r.eAreaPct.padStart(6)} ${r.overPct.padStart(6)} ${r.underPct.padStart(5)} ${r.mad.padStart(5)} ${iou}`); +} +console.log(""); +await browser.close(); diff --git a/bench/shadow-parametric.html b/bench/shadow-parametric.html new file mode 100644 index 00000000..db83aeae --- /dev/null +++ b/bench/shadow-parametric.html @@ -0,0 +1,2913 @@ + + + + + shadow oracle — polycss vs three.js per-poly verdicts + + + +

PolyCSS

+

Three.js

+

Diff (PolyCSS − Three.js)

+

Misclassified polys

+
+ + + + + + diff --git a/bench/shadow-parity.html b/bench/shadow-parity.html index 59f2b802..9c8d88e8 100644 --- a/bench/shadow-parity.html +++ b/bench/shadow-parity.html @@ -85,7 +85,7 @@ pointLights: pts, ambientLight: { color: S.amb.color, intensity: S.amb.intensity }, textureLighting: S.lighting, - shadow: { color: "#000000", opacity: S.so, lift: 0.02 }, + shadow: { color: "#000000", opacity: S.so, lift: 0.02, parametric: qs("pm","0")==="1", definition: qn("def",24), style: qs("style","vector") }, cam: { ...S.cam }, }; } diff --git a/bench/shadow-rubric.mjs b/bench/shadow-rubric.mjs new file mode 100644 index 00000000..3b8b216b --- /dev/null +++ b/bench/shadow-rubric.mjs @@ -0,0 +1,117 @@ +// Parametric-shadow RUBRIC harness. +// +// Treats the parametric shadow as an approximation refined by named, checkable +// invariants ("rubrics"). Each rubric measures one describable property against +// PolyCSS exact shadows (ground truth) or against a geometric truth (e.g. a +// convex mesh must self-shadow nothing). Every correction we append to the +// approximation is judged by the whole rubric set, so we see both the win and +// any regression. +// +// Metric primitives (all from in-browser pixel decode, no Node image lib): +// over% = parametric darker than exact (false / excess shadow) +// under% = parametric lighter than exact (missing shadow) +// IoU = shadow-region overlap (shadows-only mode only) +// +// Usage: node bench/shadow-rubric.mjs [base] +import { chromium } from "playwright"; +import { chromiumArgsWithGpuDefault } from "./chromium-defaults.mjs"; + +const BASE = process.argv[2] ?? "http://localhost:4322"; +const PAGE = "/shadow-parametric.html"; + +// A scene = one (mesh, view, light, mode). `sx:1` shadows-only isolates floor +// cast shadow; `sx:0 ss:1` isolates self-shadow (object identical in both +// renders, so the diff is purely self-shadow). +const SCENES = { + cubeSelf: { mesh: "cube", sx: 0, ss: 1, def: 48, q: "rx=58&ry=24&z=30&dx=1&dy=0.5&dz=1" }, + cubeFloor: { mesh: "cube", sx: 1, ss: 0, def: 48, q: "rx=55&ry=15&z=22&dx=1&dy=0.5&dz=1" }, + castleFloor: { mesh: "castle", sx: 1, ss: 0, def: 48, q: "rx=55&ry=15&z=18&dx=1&dy=0.6&dz=1" }, + castleFloorG:{ mesh: "castle", sx: 1, ss: 0, def: 48, q: "rx=55&ry=15&z=18&dx=1.4&dy=0.2&dz=0.5" }, + castleSelf: { mesh: "castle", sx: 0, ss: 1, def: 48, q: "rx=62&ry=20&z=40&dx=1&dy=0.6&dz=1" }, + castleSelfG: { mesh: "castle", sx: 0, ss: 1, def: 48, q: "rx=62&ry=20&z=40&dx=1.5&dy=0.15&dz=0.4" }, + appleFloor: { mesh: "apple", sx: 1, ss: 0, def: 48, q: "rx=45&ry=10&z=30&dx=1&dy=0.6&dz=1" }, + appleSelf: { mesh: "apple", sx: 0, ss: 1, def: 48, q: "rx=45&ry=10&z=40&dx=1&dy=0.6&dz=1" }, + cottageFloor:{ mesh: "cottage", sx: 1, ss: 0, def: 48, q: "rx=50&ry=12&z=22&dx=1&dy=0.6&dz=1" }, + coliseumFloor:{ mesh: "coliseum",sx: 1, ss: 0, def: 144, q: "rx=50&ry=12&z=22&dx=1&dy=0.6&dz=1" }, + coliseumSelf: { mesh: "coliseum",sx: 0, ss: 1, def: 96, q: "rx=42.7&ry=338.4&z=16&ax=1&oz_coliseum=0.1&dx=-1&dy=0.33&dz=0.98" }, +}; + +// Each rubric: a scene, the metric to read, a pass predicate, and a plain +// description of the invariant it encodes. +const RUBRICS = [ + { id: "convex-no-self/cube", scene: "cubeSelf", metric: "over", max: 0.20, why: "a convex mesh self-shadows nothing" }, + { id: "convex-no-self/apple", scene: "appleSelf", metric: "over", max: 2.00, why: "a near-convex mesh barely self-shadows" }, + { id: "floor-iou/castle", scene: "castleFloor", metric: "iou", min: 0.93, why: "floor cast shadow matches exact footprint" }, + { id: "floor-iou/castle-graze",scene: "castleFloorG",metric: "iou", min: 0.93, why: "floor footprint holds at grazing light" }, + { id: "floor-iou/apple", scene: "appleFloor", metric: "iou", min: 0.93, why: "convex floor shadow matches exact" }, + { id: "floor-iou/cottage", scene: "cottageFloor",metric: "iou",min: 0.93, why: "floor footprint matches exact" }, + { id: "floor-iou/coliseum", scene: "coliseumFloor",metric:"iou",min: 0.90, why: "holed floor footprint matches exact" }, + { id: "no-overshadow/castleSelf",scene:"castleSelf", metric:"over",max: 6.00, why: "self-shadow does not over-darken" }, + { id: "no-undershadow/castleSelf",scene:"castleSelf",metric:"under",max:3.00,why:"real self-shadow is not erased by bias" }, + { id: "no-overshadow/castleSelfG",scene:"castleSelfG",metric:"over",max:6.00,why:"grazing-light self-shadow does not over-darken" }, + { id: "no-undershadow/castleSelfG",scene:"castleSelfG",metric:"under",max:3.00,why:"grazing-light self-shadow not erased (depth bands suffice)" }, + { id: "no-undershadow/coliseumSelf",scene:"coliseumSelf",metric:"under",max:1.50,why:"coliseum interior self-shadow not erased by depth bias" }, + { id: "no-overshadow/coliseumSelf",scene:"coliseumSelf",metric:"over",max:3.00,why:"coliseum self-shadow does not over-darken the arena" }, + { id: "no-undershadow/castleFloor",scene:"castleFloor",metric:"under",max:1.00,why:"floor shadow has no holes vs exact" }, +]; + +const url = (s, pm) => `${BASE}${PAGE}?mesh=${s.mesh}&pm=${pm}&sx=${s.sx}&ss=${s.ss}&def=${s.def}&fv=1&${s.q}`; + +async function shotHost(page, u) { + await page.goto(u, { waitUntil: "networkidle" }); + await page.waitForSelector("#poly-host .polycss-scene", { state: "attached", timeout: 30000 }).catch(() => {}); + await page.waitForTimeout(1500); + return await (await page.$("#poly-host")).screenshot(); +} + +async function diff(scratch, bufA, bufB) { + return await scratch.evaluate(async ([a, b]) => { + const load = (src) => new Promise((res) => { const i = new Image(); i.onload = () => res(i); i.src = "data:image/png;base64," + src; }); + const [iA, iB] = await Promise.all([load(a), load(b)]); + const W = Math.min(iA.width, iB.width), H = Math.min(iA.height, iB.height); + const cv = document.createElement("canvas"); cv.width = W; cv.height = H; + const cx = cv.getContext("2d", { willReadFrequently: true }); + cx.drawImage(iA, 0, 0); const A = cx.getImageData(0, 0, W, H).data; + cx.clearRect(0, 0, W, H); cx.drawImage(iB, 0, 0); const B = cx.getImageData(0, 0, W, H).data; + const THR = 18, bg = A[0] * 0.299 + A[1] * 0.587 + A[2] * 0.114; + let over = 0, under = 0, inter = 0, uni = 0, N = A.length / 4; + for (let i = 0; i < A.length; i += 4) { + const la = A[i] * 0.299 + A[i + 1] * 0.587 + A[i + 2] * 0.114; + const lb = B[i] * 0.299 + B[i + 1] * 0.587 + B[i + 2] * 0.114; + const d = la - lb; + if (Math.abs(d) > THR) { if (d < 0) over++; else under++; } + const sa = la < bg - THR, sb = lb < bg - THR; + if (sa && sb) inter++; if (sa || sb) uni++; + } + return { over: 100 * over / N, under: 100 * under / N, iou: uni ? inter / uni : 1 }; + }, [bufA.toString("base64"), bufB.toString("base64")]); +} + +const browser = await chromium.launch({ args: chromiumArgsWithGpuDefault() }); +const page = await browser.newPage({ viewport: { width: 1600, height: 900 } }); +const scratch = await browser.newPage(); +await scratch.setContent(""); + +// measure each unique scene once +const metrics = {}; +const needed = [...new Set(RUBRICS.map((r) => r.scene))]; +for (const key of needed) { + const s = SCENES[key]; + const a = await shotHost(page, url(s, 1)); + const b = await shotHost(page, url(s, 0)); + metrics[key] = await diff(scratch, a, b); +} + +console.log("\nPARAMETRIC SHADOW RUBRICS\n"); +let pass = 0; +for (const r of RUBRICS) { + const m = metrics[r.scene]; + const v = m[r.metric]; + const ok = r.max != null ? v <= r.max : v >= r.min; + if (ok) pass++; + const bound = r.max != null ? `≤${r.max}` : `≥${r.min}`; + const val = r.metric === "iou" ? v.toFixed(3) : v.toFixed(2) + "%"; + console.log(`${ok ? "PASS" : "FAIL"} ${r.id.padEnd(28)} ${r.metric}=${val.padStart(7)} (${bound}) — ${r.why}`); +} +console.log(`\n${pass}/${RUBRICS.length} rubrics pass\n`); +await browser.close(); diff --git a/bench/shadow-trace.html b/bench/shadow-trace.html new file mode 100644 index 00000000..bf9c34d5 --- /dev/null +++ b/bench/shadow-trace.html @@ -0,0 +1,130 @@ + + + + + shadow trace — parametric cost + + + +
+
+ + + + diff --git a/bench/snap-shadow.mjs b/bench/snap-shadow.mjs new file mode 100644 index 00000000..49644c9b --- /dev/null +++ b/bench/snap-shadow.mjs @@ -0,0 +1,16 @@ +import { chromium } from "playwright"; +import { chromiumArgsWithGpuDefault } from "./chromium-defaults.mjs"; + +const url = process.argv[2]; +const out = process.argv[3] ?? "/tmp/shadow.png"; +if (!url) { console.error("usage: node bench/snap-shadow.mjs "); process.exit(1); } + +const browser = await chromium.launch({ args: chromiumArgsWithGpuDefault() }); +const page = await browser.newPage({ viewport: { width: 1600, height: 900 }, deviceScaleFactor: 2 }); +page.on("console", (m) => { if (m.type() === "error") console.error("[page]", m.text()); }); +await page.goto(url, { waitUntil: "networkidle" }); +await page.waitForSelector(".polycss-scene", { state: "attached", timeout: 30000 }).catch(() => {}); +await page.waitForTimeout(2500); +await page.screenshot({ path: out }); +console.log("saved", out); +await browser.close(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 62d8a228..446ab55b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -184,6 +184,15 @@ export { projectCssVertexToGround, projectCssVertexToGroundFromPoint, } from "./shadow/projection"; +export { computeParametricShadowSilhouette } from "./shadow/parametricSilhouette"; +export { computeCoverageShadowSilhouette } from "./shadow/coverageSilhouette"; +export { + buildParametricCasterOverride, + isFlatCaster, + isConvexCaster, + type ParametricOverrideInput, + type ParametricOverrideResult, +} from "./shadow/parametricOverride"; export { clipPolygonToConvex2D } from "./shadow/clipping"; export { expandConvexHullOutward, diff --git a/packages/core/src/shadow/computeReceiverShadows.ts b/packages/core/src/shadow/computeReceiverShadows.ts index 6e7fb411..26df7917 100644 --- a/packages/core/src/shadow/computeReceiverShadows.ts +++ b/packages/core/src/shadow/computeReceiverShadows.ts @@ -118,6 +118,15 @@ export interface ReceiverCasterInput { * array is sized correctly even when atlas-plan / dedup filters drop * some polygons from `items`. */ casterPolygonCount?: number; + /** Parametric-shadow override: a precomputed world-frame silhouette loop set + * (see `computeParametricShadowSilhouette`). When present it is projected + * directly — skipping per-poly and silhouette extraction — so a cheap, + * low-resolution outline casts onto every receiver via the normal pipeline. */ + overrideSilhouette?: Vec3[][]; + /** Per-point-light parametric override (indexed by the point light's index in + * `allPointLights`). A point pass uses this RADIAL silhouette instead of the + * directional `overrideSilhouette`; an undefined entry → exact point path. */ + overridePointSilhouettes?: Array; } /** @@ -557,6 +566,22 @@ export function computeReceiverShadowFaces( // Clean closed meshes keep single-sided casting (correct + cheaper). const doubleSidedByCaster: boolean[] = new Array(casters.length).fill(false); const silhouetteByCaster: Array = casters.map((casterEntry, casterIdx) => { + // Parametric-shadow override: low-res silhouette loops the projector casts + // onto each receiver face. A point pass uses the per-light RADIAL loops + // (`overridePointSilhouettes[thisPointIndex]`) — the directional loops are + // the wrong outline for a finite-distance light. An undefined per-point + // entry means this caster skipped parametric (flat/convex) → fall through + // to the exact point path. + if (isPoint) { + if (casterEntry.overridePointSilhouettes) { + const o = casterEntry.overridePointSilhouettes[thisPointIndex ?? -1]; + if (o) return o; + } else if (casterEntry.overrideSilhouette) { + return casterEntry.overrideSilhouette; + } + } else if (casterEntry.overrideSilhouette) { + return casterEntry.overrideSilhouette; + } // Point lights: project the caster SILHOUETTE (its outline as seen from // the light), not individual back-faces. An object resting on the // receiver casts a FILLED contact shadow; the per-poly back-face union @@ -809,6 +834,29 @@ export function computeReceiverShadowFaces( // Empty loop set means "light fully behind mesh" → no shadow on // this receiver from this caster. if (silhouette.length === 0) continue; + // Parametric (override) loops carry coverage holes (courtyards, the + // coliseum arena) as opposite-wound loops. Preserving that relative + // winding lets them subtract under the path's nonzero fill. The + // (u,v) basis may flip handedness, so pick one global orientation from + // the largest loop (→ outer CCW) and apply it uniformly. The exact + // silhouette path keeps per-loop `ensureCcw2D` (no holes there). + const isOverride = !!casterEntry.overrideSilhouette; + let overrideFlip = false; + if (isOverride) { + let bestAbs = 0; + for (const loop of silhouette) { + if (loop.length < 3) continue; + const ab = clipLoopAbovePlane(loop, O, n, SELF_SHADOW_EPS); + if (ab.length < 3) continue; + const pr = ab.map(projectOntoPlane); + let area = 0; + for (let i = 0; i < pr.length; i++) { + const p = pr[i]!, q = pr[(i + 1) % pr.length]!; + area += p[0] * q[1] - q[0] * p[1]; + } + if (Math.abs(area) > bestAbs) { bestAbs = Math.abs(area); overrideFlip = area < 0; } + } + } for (const loop of silhouette) { if (loop.length < 3) continue; // 3D clip against the receiver plane half-space first — silhouette @@ -817,7 +865,9 @@ export function computeReceiverShadowFaces( const above3D = clipLoopAbovePlane(loop, O, n, SELF_SHADOW_EPS); if (above3D.length < 3) continue; const projected = above3D.map(projectOntoPlane); - const subjectCcw = ensureCcw2D(projected); + const subjectCcw = isOverride + ? (overrideFlip ? [...projected].reverse() : projected) + : ensureCcw2D(projected); const reachClipped = reachRect.length === 4 ? clipPolygonToConvex2D(subjectCcw, reachRect) : subjectCcw; diff --git a/packages/core/src/shadow/coverageSilhouette.ts b/packages/core/src/shadow/coverageSilhouette.ts new file mode 100644 index 00000000..7721a48e --- /dev/null +++ b/packages/core/src/shadow/coverageSilhouette.ts @@ -0,0 +1,333 @@ +// Coverage-contour cast-shadow silhouette ("definition tier 2"). +// +// The convex-hull silhouette (parametricSilhouette.ts) can't represent +// concavity — a castle's towers/gaps collapse to a blob no matter how many +// points. The cast shadow on a plane is actually the 2D COVERAGE of every +// projected face (depth is irrelevant for a plane: any occluder shadows the +// point). So here we rasterize that coverage from the light's POV into a small +// mask, trace its contour with marching squares, simplify to `definition`, and +// lift the loops back to 3D for the normal receiver projector. +// +// DEPTH LAYERS. A single flat outline carries no depth, so it works for a floor +// (every blocker is in front) but over-darkens SELF-shadow: projecting one +// outline onto the caster's own interior faces shadows faces that are actually +// in front of the geometry. Passing `layers > 1` slices the caster into depth +// bands along the light and emits one coverage contour per band, each placed at +// its band's depth. The receiver projector's per-face half-space clip then keeps +// only the bands in front of each face — so a face is shadowed exactly by the +// geometry between it and the light. Cross-mesh casting uses `layers = 1` (one +// band, cheapest, identical result); self-shadow uses N bands scaled by detail. +// +// `definition` drives the mask resolution: low → blobby, high → the real +// concave outline (towers, gaps, courtyards). Robust on any mesh — no manifold +// or polygon-boolean requirement. +import type { Vec3 } from "../types"; + +function unit(v: Vec3): Vec3 { + const len = Math.hypot(v[0], v[1], v[2]); + if (len < 1e-9) return [0, 0, 1]; + return [v[0] / len, v[1] / len, v[2] / len]; +} + +// Marching-squares segment table: case (c0|c1<<1|c2<<2|c3<<3) → flat list of +// edge pairs. Edges: 0=bottom, 1=right, 2=top, 3=left. +const MS: number[][] = [ + [], [3, 0], [0, 1], [3, 1], [1, 2], [3, 0, 1, 2], [0, 2], [3, 2], + [2, 3], [2, 0], [0, 1, 2, 3], [2, 1], [1, 3], [1, 0], [0, 3], [], +]; + +/** Douglas–Peucker simplify a closed loop (grid coords) to `tol`. */ +function simplifyClosed(loop: Array<[number, number]>, tol: number): Array<[number, number]> { + if (loop.length < 4) return loop; + // Split the closed loop at the two farthest-apart points, simplify each half. + let i1 = 1, far = -1; + for (let i = 0; i < loop.length; i++) { + const dx = loop[i]![0] - loop[0]![0], dy = loop[i]![1] - loop[0]![1]; + const d = dx * dx + dy * dy; + if (d > far) { far = d; i1 = i; } + } + const dp = (pts: Array<[number, number]>): Array<[number, number]> => { + if (pts.length < 3) return pts; + const a = pts[0]!, b = pts[pts.length - 1]!; + let idx = -1, max = tol; + const dx = b[0] - a[0], dy = b[1] - a[1]; + const len = Math.hypot(dx, dy) || 1; + for (let i = 1; i < pts.length - 1; i++) { + const d = Math.abs((pts[i]![0] - a[0]) * dy - (pts[i]![1] - a[1]) * dx) / len; + if (d > max) { max = d; idx = i; } + } + if (idx < 0) return [a, b]; + return [...dp(pts.slice(0, idx + 1)).slice(0, -1), ...dp(pts.slice(idx))]; + }; + const half1 = loop.slice(0, i1 + 1); + const half2 = [...loop.slice(i1), loop[0]!]; + const s1 = dp(half1), s2 = dp(half2); + const out = [...s1.slice(0, -1), ...s2.slice(0, -1)]; + return out.length >= 3 ? out : loop; +} + +/** Ray-cast point-in-polygon (loop in grid coords). Used to count nesting + * depth so contour holes get the opposite winding to their container. */ +function pointInLoop(x: number, y: number, loop: ReadonlyArray): boolean { + let inside = false; + for (let i = 0, j = loop.length - 1; i < loop.length; j = i++) { + const xi = loop[i]![0], yi = loop[i]![1], xj = loop[j]![0], yj = loop[j]![1]; + if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside; + } + return inside; +} + +interface Poly2D { + pts: Array<[number, number]>; + depth: number; // centroid depth along L +} + +/** + * Build concave coverage-contour silhouette loops for a caster lit by a + * directional light. + * + * @param polysWorldVerts Each caster polygon's vertices in the world-CSS frame + * (e.g. `CasterPolyItem.wv`). + * @param lightDir Directional light vector (to-source) in that frame. + * @param definition Detail knob → coverage mask resolution. + * @param layers Depth bands along the light. 1 (default) = one flat + * outline (cross-mesh casting). >1 = depth-stratified + * outlines for correct self-shadow. + * @returns Closed 3D loops, or `null`. + */ +export function computeCoverageShadowSilhouette( + polysWorldVerts: ReadonlyArray>, + lightDir: Vec3, + definition: number, + layers = 1, + mode: "contour" | "pixel" = "contour", +): Vec3[][] | null { + const L = unit(lightDir); + const seed: Vec3 = Math.abs(L[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]; + const sd = seed[0] * L[0] + seed[1] * L[1] + seed[2] * L[2]; + const e1 = unit([seed[0] - sd * L[0], seed[1] - sd * L[1], seed[2] - sd * L[2]]); + const e2: Vec3 = [ + L[1] * e1[2] - L[2] * e1[1], + L[2] * e1[0] - L[0] * e1[2], + L[0] * e1[1] - L[1] * e1[0], + ]; + // Project every vertex to the light-perpendicular plane; track bbox and each + // polygon's centroid depth (its position along L) for depth banding. + let minA = Infinity, minB = Infinity, maxA = -Infinity, maxB = -Infinity; + let tMin = Infinity, tMax = -Infinity; + const polys: Poly2D[] = []; + for (const poly of polysWorldVerts) { + if (poly.length < 3) continue; + const pts: Array<[number, number]> = []; + let dSum = 0; + for (const v of poly) { + const a = v[0] * e1[0] + v[1] * e1[1] + v[2] * e1[2]; + const b = v[0] * e2[0] + v[1] * e2[1] + v[2] * e2[2]; + const t = v[0] * L[0] + v[1] * L[1] + v[2] * L[2]; + pts.push([a, b]); + dSum += t; + if (a < minA) minA = a; if (a > maxA) maxA = a; + if (b < minB) minB = b; if (b > maxB) maxB = b; + if (t < tMin) tMin = t; if (t > tMax) tMax = t; + } + polys.push({ pts, depth: dSum / poly.length }); + } + if (!Number.isFinite(minA) || polys.length === 0) return null; + const spanA = maxA - minA, spanB = maxB - minB; + const maxSpan = Math.max(spanA, spanB); + if (maxSpan < 1e-6) return null; + + // Mask resolution scales with definition; 1-cell empty padding so the + // contour closes inside the grid. One shared grid sizing for every band. + // Contour traces at 2× definition for smooth outlines; pixel mode uses + // `definition` directly as the cell-grid resolution so it reads as chunky. + const res = Math.max(mode === "pixel" ? 3 : 8, Math.min(384, Math.round(definition * (mode === "pixel" ? 1 : 2)))); + const cell = maxSpan / res; + const pad = 1; + const oA = minA - pad * cell, oB = minB - pad * cell; + const M = Math.ceil(spanA / cell) + 2 * pad + 1; + const K = Math.ceil(spanB / cell) + 2 * pad + 1; + if (M * K > 400_000) return null; // safety + const tol = 0.6; // grid cells + + const traceBand = (subset: Poly2D[], depth: number, out: Vec3[][]): void => { + const mask = new Uint8Array(M * K); + const at = (i: number, j: number) => (i < 0 || j < 0 || i >= M || j >= K ? 0 : mask[j * M + i]!); + // Rasterize coverage: fan-triangulate each polygon, fill cells whose center + // is inside (bbox-bounded point-in-triangle). + const d = (ax: number, ay: number, bx: number, by: number, cx: number, cy: number) => + (bx - ax) * (cy - ay) - (by - ay) * (cx - ax); + for (const poly of subset) { + const p = poly.pts; + for (let t = 1; t < p.length - 1; t++) { + const A = p[0]!, B = p[t]!, C = p[t + 1]!; + let lo = Math.floor((Math.min(A[0], B[0], C[0]) - oA) / cell); + let hi = Math.ceil((Math.max(A[0], B[0], C[0]) - oA) / cell); + let lj = Math.floor((Math.min(A[1], B[1], C[1]) - oB) / cell); + let hj = Math.ceil((Math.max(A[1], B[1], C[1]) - oB) / cell); + if (lo < 0) lo = 0; if (hi > M - 1) hi = M - 1; + if (lj < 0) lj = 0; if (hj > K - 1) hj = K - 1; + if (Math.abs(d(A[0], A[1], B[0], B[1], C[0], C[1])) < 1e-12) continue; + for (let j = lj; j <= hj; j++) { + const py = oB + (j + 0.5) * cell; + for (let i = lo; i <= hi; i++) { + if (mask[j * M + i]) continue; + const px = oA + (i + 0.5) * cell; + const w0 = d(B[0], B[1], C[0], C[1], px, py); + const w1 = d(C[0], C[1], A[0], A[1], px, py); + const w2 = d(A[0], A[1], B[0], B[1], px, py); + if ((w0 >= 0 && w1 >= 0 && w2 >= 0) || (w0 <= 0 && w1 <= 0 && w2 <= 0)) { + mask[j * M + i] = 1; + } + } + } + } + } + + if (mode === "pixel") { + // Greedy-mesh the filled cells into axis-aligned rects (voxel-style) and + // emit one rect loop each. Holes are simply absent cells — no winding or + // fill-rule subtraction needed. Run-length + vertical-extend keeps the + // rect count low (the same merge the .vox path uses). The blocky look IS + // the effect; `definition` is the grid resolution (lower → chunkier). + const used = new Uint8Array(M * K); + const lift = (gi: number, gj: number): Vec3 => { + const a = oA + gi * cell, b = oB + gj * cell; + return [ + a * e1[0] + b * e2[0] + depth * L[0], + a * e1[1] + b * e2[1] + depth * L[1], + a * e1[2] + b * e2[2] + depth * L[2], + ]; + }; + for (let j = 0; j < K; j++) { + for (let i = 0; i < M; i++) { + if (!mask[j * M + i] || used[j * M + i]) continue; + let w = 1; + while (i + w < M && mask[j * M + i + w] && !used[j * M + i + w]) w++; + let h = 1; + outer: while (j + h < K) { + for (let k = 0; k < w; k++) { + if (!mask[(j + h) * M + i + k] || used[(j + h) * M + i + k]) break outer; + } + h++; + } + for (let jj = j; jj < j + h; jj++) for (let ii = i; ii < i + w; ii++) used[jj * M + ii] = 1; + out.push([lift(i, j), lift(i + w, j), lift(i + w, j + h), lift(i, j + h)]); + } + } + return; + } + + // Marching squares → undirected segments between edge midpoints. + const key = (x: number, y: number) => `${Math.round(x * 2)},${Math.round(y * 2)}`; + const adj = new Map>(); + const ptOf = new Map(); + const emid = (e: number, i: number, j: number): [number, number] => + e === 0 ? [i + 0.5, j] : e === 1 ? [i + 1, j + 0.5] : e === 2 ? [i + 0.5, j + 1] : [i, j + 0.5]; + const addSeg = (a: [number, number], b: [number, number]) => { + const ka = key(a[0], a[1]), kb = key(b[0], b[1]); + if (ka === kb) return; + if (!adj.has(ka)) adj.set(ka, []); + if (!adj.has(kb)) adj.set(kb, []); + adj.get(ka)!.push(b); adj.get(kb)!.push(a); + ptOf.set(ka, a); ptOf.set(kb, b); + }; + for (let j = 0; j < K - 1; j++) { + for (let i = 0; i < M - 1; i++) { + const c = at(i, j) | (at(i + 1, j) << 1) | (at(i + 1, j + 1) << 2) | (at(i, j + 1) << 3); + const segs = MS[c]!; + for (let s = 0; s < segs.length; s += 2) { + addSeg(emid(segs[s]!, i, j), emid(segs[s + 1]!, i, j)); + } + } + } + if (adj.size === 0) return; + + // Stitch segments into closed loops. + const used = new Set(); + const simpLoops: Array> = []; + for (const start of adj.keys()) { + if (used.has(start)) continue; + const loop: Array<[number, number]> = []; + let cur = start, prev = "", guard = 0; + while (cur && !used.has(cur) && guard++ < 1e6) { + used.add(cur); + loop.push(ptOf.get(cur)!); + const nbrs = adj.get(cur) ?? []; + let next = ""; + for (const n of nbrs) { const k = key(n[0], n[1]); if (k !== prev && !used.has(k)) { next = k; break; } } + if (!next) { for (const n of nbrs) { const k = key(n[0], n[1]); if (k !== prev) { next = k; break; } } } + prev = cur; cur = next; + if (cur === start) break; + } + if (loop.length < 3) continue; + const simp = simplifyClosed(loop, tol); + if (simp.length >= 3) simpLoops.push(simp); + } + + // Orient by nesting so holes subtract under the receiver's nonzero fill: + // a loop nested in an EVEN number of others is an outer boundary (CCW); an + // ODD nesting is a hole (CW). Marching squares can emit either winding, so + // set it explicitly here. + for (const loop of simpLoops) { + let depthCount = 0; + const px = loop[0]![0], py = loop[0]![1]; + for (const other of simpLoops) { + if (other === loop) continue; + if (pointInLoop(px, py, other)) depthCount++; + } + const wantCcw = depthCount % 2 === 0; + let area = 0; + for (let i = 0; i < loop.length; i++) { + const p = loop[i]!, q = loop[(i + 1) % loop.length]!; + area += p[0] * q[1] - q[0] * p[1]; + } + const isCcw = area > 0; + const ordered = isCcw === wantCcw ? loop : [...loop].reverse(); + out.push(ordered.map((g) => { + const a = oA + g[0] * cell, b = oB + g[1] * cell; + return [ + a * e1[0] + b * e2[0] + depth * L[0], + a * e1[1] + b * e2[1] + depth * L[1], + a * e1[2] + b * e2[2] + depth * L[2], + ] as Vec3; + })); + } + }; + + const out: Vec3[][] = []; + const nLayers = Math.max(1, Math.floor(layers)); + if (nLayers <= 1 || tMax - tMin < 1e-6) { + // Cross-mesh: ONE flat proxy outline. Projection onto a receiver is + // depth-invariant (moving a point along L doesn't change where it lands), + // BUT the receiver projector clips loop vertices that fall below the + // receiver plane — and a flat outline at the caster's mid-depth has its + // lower half below a floor, so clipping mangles it and the survivor + // projects far. Slide the whole outline toward the light, past the + // caster's frontmost point, so every vertex clears any receiver beneath + // the caster. Footprint is unchanged (depth-invariance); only the clip is + // avoided. + const depth = tMax + (tMax - tMin) + maxSpan; + traceBand(polys, depth, out); + } else { + // Self-shadow: depth-stratified bands. Each band's flat proxy is a tilted + // quad that pokes slightly above the real face planes even where the actual + // geometry doesn't, so a band tends to false-shadow the very surface that + // generated it (a convex mesh ends up self-shadowed, which is wrong). Push + // each band slightly deeper (away from the light) by a small FIXED bias so + // its proxy sits below the planes of faces at its own depth — a face is then + // only shadowed by geometry genuinely in front of it. The bias is a fraction + // of the whole depth span, NOT one band-thickness: tying it to band count + // made fewer/thicker bands over-bias and erase real interior self-shadow + // (the coliseum's seating). Genuine occluders sit far ahead and still cast. + const span = tMax - tMin; + const band = span / nLayers; + const bias = span * 0.05; + for (let k = 0; k < nLayers; k++) { + const lo = tMin + k * band, hi = k === nLayers - 1 ? Infinity : tMin + (k + 1) * band; + const subset = polys.filter((p) => p.depth >= lo && p.depth < hi); + if (subset.length) traceBand(subset, lo - bias, out); + } + } + return out.length ? out : null; +} diff --git a/packages/core/src/shadow/parametricOverride.ts b/packages/core/src/shadow/parametricOverride.ts new file mode 100644 index 00000000..337268ee --- /dev/null +++ b/packages/core/src/shadow/parametricOverride.ts @@ -0,0 +1,134 @@ +// Shared parametric-shadow override builder. +// +// Given a caster's world-frame polygons + light(s), produces the low-res +// silhouette loops the receiver projector casts. This is the single source of +// truth for the parametric correction terms (flat-caster skip, convex self +// skip, depth-banded self-shadow, vector vs pixel style, per-point-light radial +// silhouettes) so the vanilla, React, and Vue renderers stay identical — each +// just calls this and drops the result onto its `ReceiverCasterInput`. +import type { Vec3 } from "../types"; +import { computeCoverageShadowSilhouette } from "./coverageSilhouette"; +import { computeParametricShadowSilhouette } from "./parametricSilhouette"; + +/** True when every caster vertex lies in a single plane (a ground quad, a + * billboard, etc.). Such casters have no coverage volume for the parametric + * proxy — their tilted proxy would project garbage onto a coplanar receiver — + * so the caller routes them through the exact path instead. */ +export function isFlatCaster(polysWv: ReadonlyArray>): boolean { + let ax = 0, ay = 0, az = 0, bx = 0, by = 0, bz = 0, ox = 0, oy = 0, oz = 0; + let haveBasis = false; + for (const poly of polysWv) { + if (poly.length >= 3 && !haveBasis) { + const p0 = poly[0]!, p1 = poly[1]!, p2 = poly[2]!; + ax = p1[0] - p0[0]; ay = p1[1] - p0[1]; az = p1[2] - p0[2]; + bx = p2[0] - p0[0]; by = p2[1] - p0[1]; bz = p2[2] - p0[2]; + ox = p0[0]; oy = p0[1]; oz = p0[2]; + haveBasis = true; + } + } + if (!haveBasis) return true; + let nx = ay * bz - az * by, ny = az * bx - ax * bz, nz = ax * by - ay * bx; + const nl = Math.hypot(nx, ny, nz); + if (nl < 1e-9) return true; + nx /= nl; ny /= nl; nz /= nl; + let span = 0; + for (const poly of polysWv) for (const p of poly) { + span = Math.max(span, Math.abs(p[0] - ox) + Math.abs(p[1] - oy) + Math.abs(p[2] - oz)); + } + const tol = Math.max(1e-3, span * 1e-3); + for (const poly of polysWv) for (const p of poly) { + const d = (p[0] - ox) * nx + (p[1] - oy) * ny + (p[2] - oz) * nz; + if (Math.abs(d) > tol) return false; + } + return true; +} + +/** Rubric: a CONVEX caster self-shadows nothing. The depth-band proxy still + * leaks a little false self-shadow on convex meshes, so detect convexity and + * skip self-shadow for them. Capped at `maxPolys` (O(faces × verts), and large + * meshes are essentially never convex — they early-exit on the first concave + * face anyway). */ +export function isConvexCaster(polysWv: ReadonlyArray>, maxPolys = 300): boolean { + if (polysWv.length === 0 || polysWv.length > maxPolys) return false; + let span = 0, ox = 0, oy = 0, oz = 0, seeded = false; + for (const poly of polysWv) for (const p of poly) { + if (!seeded) { ox = p[0]; oy = p[1]; oz = p[2]; seeded = true; } + span = Math.max(span, Math.abs(p[0] - ox) + Math.abs(p[1] - oy) + Math.abs(p[2] - oz)); + } + const tol = Math.max(1e-3, span * 5e-3); + for (const face of polysWv) { + if (face.length < 3) continue; + const a = face[0]!, b = face[1]!, c = face[2]!; + let nx = (b[1] - a[1]) * (c[2] - a[2]) - (b[2] - a[2]) * (c[1] - a[1]); + let ny = (b[2] - a[2]) * (c[0] - a[0]) - (b[0] - a[0]) * (c[2] - a[2]); + let nz = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]); + const nl = Math.hypot(nx, ny, nz); + if (nl < 1e-9) continue; + nx /= nl; ny /= nl; nz /= nl; + let pos = false, neg = false; + for (const poly of polysWv) for (const p of poly) { + const d = (p[0] - a[0]) * nx + (p[1] - a[1]) * ny + (p[2] - a[2]) * nz; + if (d > tol) pos = true; else if (d < -tol) neg = true; + if (pos && neg) return false; + } + } + return true; +} + +export interface ParametricOverrideInput { + /** Caster polygons, each a vertex list in the world-CSS frame. */ + polysWorldVerts: ReadonlyArray>; + /** Directional light (to-source) in the world-CSS frame. */ + lightDir: Vec3; + /** Effective definition (caller folds in per-mesh override + drag cap). */ + definition: number; + /** True when the caster is also the receiver (self-shadow). */ + isSelf: boolean; + /** `"pixel"` greedy-meshes the coverage into voxel rects; default contour. */ + style?: "vector" | "pixel"; + /** Shadow-casting point lights (CSS position + index in `allPointLights`). */ + pointLights?: ReadonlyArray<{ position: Vec3; index: number }>; +} + +export interface ParametricOverrideResult { + /** Directional override loops, or undefined to use the exact path. */ + overrideSilhouette?: Vec3[][]; + /** Per-point-light radial override loops (indexed by point light index). */ + overridePointSilhouettes?: Array; +} + +/** Build the parametric override(s) for one caster — directional plus one + * radial silhouette per shadow-casting point light. Returns empty overrides + * (use the exact path) for flat casters and convex self-shadow. */ +export function buildParametricCasterOverride(input: ParametricOverrideInput): ParametricOverrideResult { + const { polysWorldVerts, lightDir, definition, isSelf, style, pointLights } = input; + const flat = isFlatCaster(polysWorldVerts); + const convexSelfSkip = isSelf && isConvexCaster(polysWorldVerts); + if (flat || convexSelfSkip) return {}; + const layers = isSelf ? Math.max(2, Math.min(6, Math.round(definition / 8))) : 1; + const mode = style === "pixel" ? "pixel" : "contour"; + const buildOverride = (dir: Vec3): Vec3[][] | undefined => { + const loops = computeCoverageShadowSilhouette(polysWorldVerts, dir, definition, layers, mode); + if (loops && loops.length) return loops; + if (!isSelf) { + const allWv: Vec3[] = []; + for (const poly of polysWorldVerts) for (const w of poly) allWv.push(w as Vec3); + const loop = computeParametricShadowSilhouette(allWv, dir, definition); + if (loop && loop.length >= 3) return [loop]; + } + return undefined; + }; + const overrideSilhouette = buildOverride(lightDir); + let overridePointSilhouettes: Array | undefined; + if (pointLights && pointLights.length > 0) { + let cx = 0, cy = 0, cz = 0, n = 0; + for (const poly of polysWorldVerts) for (const w of poly) { cx += w[0]; cy += w[1]; cz += w[2]; n++; } + if (n > 0) { cx /= n; cy /= n; cz /= n; } + overridePointSilhouettes = []; + for (const pl of pointLights) { + const dir: Vec3 = [pl.position[0] - cx, pl.position[1] - cy, pl.position[2] - cz]; + overridePointSilhouettes[pl.index] = buildOverride(dir); + } + } + return { overrideSilhouette, overridePointSilhouettes }; +} diff --git a/packages/core/src/shadow/parametricSilhouette.ts b/packages/core/src/shadow/parametricSilhouette.ts new file mode 100644 index 00000000..8595fc4b --- /dev/null +++ b/packages/core/src/shadow/parametricSilhouette.ts @@ -0,0 +1,102 @@ +// Parametric cast-shadow silhouette. +// +// The cast shadow of an object on ANY receiver plane is the projection of the +// object's silhouette (its outline as seen from the light) along the light +// direction. Instead of re-projecting every caster polygon, this builds a +// single low-resolution silhouette loop as a function of the light direction — +// `Shadow(lightDir, definition)` — which the receiver-shadow projector then +// casts onto every receiver face (clip + colored-merge unchanged). +// +// `definition` is the quality knob: the maximum number of points in the loop. +// Low → a blobby approximation (lightweight DOM, cheap projection); high → +// converges to the caster's exact convex outline. Concave detail beyond the +// convex hull is intentionally not represented (that's the "approximate but +// light" trade — a future definition tier could add it). +import type { Vec3 } from "../types"; + +/** Unit vector; falls back to +Z for a zero input. */ +function unit(v: Vec3): Vec3 { + const len = Math.hypot(v[0], v[1], v[2]); + if (len < 1e-9) return [0, 0, 1]; + return [v[0] / len, v[1] / len, v[2] / len]; +} + +/** 2D convex hull (Andrew's monotone chain) returning INDICES into `pts`, CCW. */ +function convexHullIndices(pts: ReadonlyArray): number[] { + const n = pts.length; + if (n < 3) return pts.map((_, i) => i); + const idx = pts.map((_, i) => i).sort((a, b) => + pts[a]![0] - pts[b]![0] || pts[a]![1] - pts[b]![1]); + const cross = (o: number, a: number, b: number): number => + (pts[a]![0] - pts[o]![0]) * (pts[b]![1] - pts[o]![1]) - + (pts[a]![1] - pts[o]![1]) * (pts[b]![0] - pts[o]![0]); + const lower: number[] = []; + for (const i of idx) { + while (lower.length >= 2 && cross(lower[lower.length - 2]!, lower[lower.length - 1]!, i) <= 0) lower.pop(); + lower.push(i); + } + const upper: number[] = []; + for (let k = idx.length - 1; k >= 0; k--) { + const i = idx[k]!; + while (upper.length >= 2 && cross(upper[upper.length - 2]!, upper[upper.length - 1]!, i) <= 0) upper.pop(); + upper.push(i); + } + lower.pop(); + upper.pop(); + return lower.concat(upper); +} + +/** + * Build the parametric silhouette loop for a caster lit by a directional light. + * + * @param worldVerts Every caster vertex in the same world-CSS frame the + * receiver projector works in (e.g. `CasterPolyItem.wv`). + * @param lightDir Directional light vector (to-source) in that frame. + * @param definition Max loop points. The convex hull is decimated down to this + * by repeatedly dropping the lowest-area vertex (shape- + * preserving). `<= 2` is treated as 3. + * @returns A closed 3D loop (the silhouette vertices), or `null` if degenerate. + */ +export function computeParametricShadowSilhouette( + worldVerts: ReadonlyArray, + lightDir: Vec3, + definition: number, +): Vec3[] | null { + if (worldVerts.length < 3) return null; + const L = unit(lightDir); + // Orthonormal basis (e1, e2) spanning the plane perpendicular to L. + const seed: Vec3 = Math.abs(L[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]; + const d = seed[0] * L[0] + seed[1] * L[1] + seed[2] * L[2]; + const e1 = unit([seed[0] - d * L[0], seed[1] - d * L[1], seed[2] - d * L[2]]); + const e2: Vec3 = [ + L[1] * e1[2] - L[2] * e1[1], + L[2] * e1[0] - L[0] * e1[2], + L[0] * e1[1] - L[1] * e1[0], + ]; + // Project to the light-perpendicular plane (keeps the 3D index association). + const proj: Array<[number, number]> = worldVerts.map((v) => [ + v[0] * e1[0] + v[1] * e1[1] + v[2] * e1[2], + v[0] * e2[0] + v[1] * e2[1] + v[2] * e2[2], + ]); + let hull = convexHullIndices(proj); + if (hull.length < 3) return null; + + // Decimate to `definition` points, dropping the vertex whose removal changes + // the loop area least (Visvalingam-style) so the silhouette shape is kept. + const target = Math.max(3, Math.floor(definition)); + if (hull.length > target) { + const tri = (a: number, b: number, c: number): number => { + const ax = proj[a]![0], ay = proj[a]![1]; + return Math.abs((proj[b]![0] - ax) * (proj[c]![1] - ay) - (proj[c]![0] - ax) * (proj[b]![1] - ay)) * 0.5; + }; + while (hull.length > target) { + let minA = Infinity, minI = 0; + for (let i = 0; i < hull.length; i++) { + const a = tri(hull[(i - 1 + hull.length) % hull.length]!, hull[i]!, hull[(i + 1) % hull.length]!); + if (a < minA) { minA = a; minI = i; } + } + hull.splice(minI, 1); + } + } + return hull.map((i) => [worldVerts[i]![0], worldVerts[i]![1], worldVerts[i]![2]] as Vec3); +} diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index 1a492f54..b547f550 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -255,6 +255,24 @@ export function createPolyScene( // shadow-appearance change MUST call invalidateShadowLightCache(); the // cache key is light-only. let lastEmittedShadowLightKey: string | null = null; + // Progressive refinement: a light-drag emit renders at `shadow.dragDefinition` + // (laggless), then this timer re-emits at full `shadow.definition` once the + // light settles. Reset on every progressive emit; cleared on dispose. + let shadowRefineTimer: ReturnType | null = null; + const SHADOW_REFINE_MS = 140; + // Animated-shadow throttle: re-emit shadows at most this often while a mesh + // deforms (shadow.followAnimation). ~12fps keeps a parametric reproject cheap. + let lastAnimationShadowEmit = 0; + const ANIMATION_SHADOW_MS = 80; + function maybeEmitAnimationShadow(entry: MeshEntry): void { + if (!currentOptions.shadow?.followAnimation) return; + if (!entry.castShadow && !entry.receiveShadow) return; + const now = typeof performance !== "undefined" ? performance.now() : Date.now(); + if (now - lastAnimationShadowEmit < ANIMATION_SHADOW_MS) return; + lastAnimationShadowEmit = now; + invalidateShadowLightCache(); + emitSceneShadows(); + } function quantizeLightDirKey(d: Vec3 | undefined): string | null { if (!d) return null; const len = Math.hypot(d[0], d[1], d[2]); @@ -888,7 +906,8 @@ export function createPolyScene( if ((currentOptions.textureLighting ?? "baked") !== "baked") return false; applyLightingVars(sceneEl, { ...currentOptions, ...next }); if (!next.skipShadows && next.directionalLight?.direction) { - emitSceneShadows(next.directionalLight.direction as Vec3); + // Interactive light preview = motion → progressive drag definition. + emitSceneShadows(next.directionalLight.direction as Vec3, { progressive: true }); } let installed = false; for (const entry of meshes) { @@ -956,7 +975,27 @@ export function createPolyScene( // projection onto that surface into the same compound path. Mounted SVG // elements are reused across light changes; fill-rule=nonzero collapses // overlapping CCW outlines into one filled silhouette per surface. - function emitSceneShadows(lightDirectionOverride?: Vec3): void { + function emitSceneShadows(lightDirectionOverride?: Vec3, opts?: { progressive?: boolean }): void { + // Progressive: a light-drag emit uses `dragDefinition` (set the ctx flag the + // receiver projector reads), then schedules ONE debounced full-def refine + // after motion stops. Reset the timer each progressive call so a continuous + // drag stays low-def until it settles. Geometry/texture-change emits pass + // no `progressive` flag and always render at full definition. + const sh = currentOptions.shadow; + const wantProgressive = !!opts?.progressive + && !!sh?.parametric + && sh?.dragDefinition != null + && sh.dragDefinition < (sh.definition ?? 16); + ctx.shadowDragActive = wantProgressive; + if (wantProgressive) { + if (shadowRefineTimer) clearTimeout(shadowRefineTimer); + shadowRefineTimer = setTimeout(() => { + shadowRefineTimer = null; + ctx.shadowDragActive = false; + invalidateShadowLightCache(); + emitSceneShadows(); + }, SHADOW_REFINE_MS); + } const casters: MeshEntry[] = []; for (const m of meshes) if (!m.disposed && m.castShadow) casters.push(m); if (casters.length === 0) { @@ -1729,6 +1768,7 @@ export function createPolyScene( castShadow: !!transformIn.castShadow, bboxCenterCss: bboxCenterCssCache, receiveShadow: !!transformIn.receiveShadow, + shadowDefinition: transformIn.shadowDefinition, cameraCullGroups: [], cameraCullSignature: "", lightOverrideSignature: "clear", @@ -1893,6 +1933,10 @@ export function createPolyScene( clearCurrentTriangleFrame(); handle.polygons = entry.polygons; const shouldRecomputeAutoCenter = options?.recomputeAutoCenter ?? true; + // Animated shadows: re-project (throttled) from the freshly-deformed + // polygons so the shadow tracks the pose. entry.polygons is already the + // current frame's geometry here; the caster-item cache was just cleared. + maybeEmitAnimationShadow(entry); if (applyStableTopologyUpdate(options, shouldRecomputeAutoCenter)) return; renderEntry(entry); if (shouldRecomputeAutoCenter) recomputeAutoCenter(); @@ -2072,8 +2116,10 @@ export function createPolyScene( setTransform(t: Partial) { const prevCastShadow = entry.castShadow; const prevReceiveShadow = entry.receiveShadow; + const prevShadowDef = entry.shadowDefinition; if (t.castShadow !== undefined) entry.castShadow = !!t.castShadow; if (t.receiveShadow !== undefined) entry.receiveShadow = !!t.receiveShadow; + if (t.shadowDefinition !== undefined) entry.shadowDefinition = t.shadowDefinition; transform = { ...transform, ...t }; const css2 = buildMeshTransform(transform); wrapper.style.transform = css2 ?? ""; @@ -2112,6 +2158,11 @@ export function createPolyScene( invalidateShadowLightCache(); emitSceneShadows(); } + // Per-mesh shadow definition changed: re-emit at the new detail. + if (entry.shadowDefinition !== prevShadowDef && (entry.castShadow || entry.receiveShadow)) { + invalidateShadowLightCache(); + emitSceneShadows(); + } }, dispose() { if (entry.disposed) return; @@ -2295,7 +2346,10 @@ export function createPolyScene( // intensity/color changes must bust the cache explicitly or // emitSceneShadows would no-op. if (shadowAppearanceChanged || directionalChanged) invalidateShadowLightCache(); - emitSceneShadows(); + // A light-DIRECTION or point-light move is "motion" → eligible for the + // progressive drag-definition pass. Shadow-appearance edits (opacity, + // definition, dragDefinition) render at full definition immediately. + emitSceneShadows(undefined, { progressive: (directionalChanged || pointLightsChanged) && !shadowAppearanceChanged }); } if (shadowAppearanceChanged && partial.shadow?.lift !== prevShadow?.lift) { recomputeShadowGround(); @@ -2338,6 +2392,7 @@ export function createPolyScene( } function destroy(): void { + if (shadowRefineTimer) { clearTimeout(shadowRefineTimer); shadowRefineTimer = null; } // Dispose all meshes (revokes blob URLs) before removing the scene. // Snapshot first since dispose() mutates the set. const snapshot = Array.from(meshes); diff --git a/packages/polycss/src/api/scene/equality.ts b/packages/polycss/src/api/scene/equality.ts index 2bbbd33f..8f896325 100644 --- a/packages/polycss/src/api/scene/equality.ts +++ b/packages/polycss/src/api/scene/equality.ts @@ -35,5 +35,10 @@ export function shadowOptsEqual( && (a?.opacity ?? 0.25) === (b?.opacity ?? 0.25) && (a?.lift ?? 0.05) === (b?.lift ?? 0.05) && (a?.maxExtend ?? 2000) === (b?.maxExtend ?? 2000) + && (a?.parametric ?? false) === (b?.parametric ?? false) + && (a?.definition ?? 16) === (b?.definition ?? 16) + && (a?.dragDefinition ?? -1) === (b?.dragDefinition ?? -1) + && (a?.style ?? "vector") === (b?.style ?? "vector") + && (a?.followAnimation ?? false) === (b?.followAnimation ?? false) ); } diff --git a/packages/polycss/src/api/scene/internalTypes.ts b/packages/polycss/src/api/scene/internalTypes.ts index 4a14f01b..d5c43878 100644 --- a/packages/polycss/src/api/scene/internalTypes.ts +++ b/packages/polycss/src/api/scene/internalTypes.ts @@ -44,6 +44,9 @@ export interface MeshEntry { excludeFromAutoCenter: boolean; castShadow: boolean; receiveShadow: boolean; + /** Per-mesh parametric-shadow definition override (scene default when + * undefined). See `PolyMeshTransform.shadowDefinition`. */ + shadowDefinition?: number; /** Polygon bbox CENTER in CSS world coords. Same value the wrapper's * `--origin` CSS variable carries — i.e. the pivot point that the * wrapper's scale3d and rotation use. Cached so shadow geometry can apply diff --git a/packages/polycss/src/api/scene/receiverShadow.ts b/packages/polycss/src/api/scene/receiverShadow.ts index 51315a45..6d3d6f32 100644 --- a/packages/polycss/src/api/scene/receiverShadow.ts +++ b/packages/polycss/src/api/scene/receiverShadow.ts @@ -9,6 +9,7 @@ * React and Vue can share it without duplicating ~500 LOC of geometry. */ import { + buildParametricCasterOverride, buildSharedEdgeMap, computeMergedReceiverShadows, meshScaleVec3, @@ -29,6 +30,7 @@ import type { MeshEntry } from "./internalTypes"; const SVG_NS = "http://www.w3.org/2000/svg"; + /** Mounted SVG state per receiver face. Kept in a separate WeakMap so the * core ReceiverFacePlane stays pure data. */ interface MountedFace { @@ -222,12 +224,46 @@ export function emitReceiverShadows( } edgeOwners = cachedOwners; } + // Parametric shadow: replace the caster's geometry with low-res coverage + // contour loops (directional). The cast shadow on a plane is the 2D coverage + // of every projected face, so we rasterize that from the light POV and trace + // its concave contour (towers, gaps) at a resolution driven by `definition`. + // The override loops project onto every receiver face through the normal + // pipeline. Cross-mesh casting uses ONE flat layer (cheapest, exact for a + // distant receiver). SELF-shadow uses depth-stratified layers: a flat + // outline carries no depth and would over-darken a mesh's own interior, so + // we slice the caster along the light and let each face's half-space clip + // keep only the bands in front of it. + let overrideSilhouette: Vec3[][] | undefined; + let overridePointSilhouettes: Array | undefined; + if (options.shadow?.parametric) { + // Per-mesh override beats the scene default; during a progressive + // light-drag emit, cap it at `dragDefinition` for a cheap frame. The + // shared core helper owns the rest (flat/convex skip, depth bands, style, + // per-point radial) so vanilla/React/Vue stay identical. + const baseDef = caster.shadowDefinition ?? options.shadow.definition ?? 16; + const def = ctx.shadowDragActive + ? Math.min(baseDef, options.shadow.dragDefinition ?? baseDef) + : baseDef; + const result = buildParametricCasterOverride({ + polysWorldVerts: cached.map((item) => item.wv), + lightDir, + definition: def, + isSelf: caster === receiverEntry, + style: options.shadow.style, + pointLights: passes.points.map((pt) => ({ position: pt.lightPos, index: pt.index })), + }); + overrideSilhouette = result.overrideSilhouette; + overridePointSilhouettes = result.overridePointSilhouettes; + } casterInputs.push({ id: caster, items: cached, selfShadowEdgeMap, edgeOwners, casterPolygonCount: caster.polygons.length, + overrideSilhouette, + overridePointSilhouettes, }); } diff --git a/packages/polycss/src/api/scene/sceneContext.ts b/packages/polycss/src/api/scene/sceneContext.ts index b83942e2..a30d0a95 100644 --- a/packages/polycss/src/api/scene/sceneContext.ts +++ b/packages/polycss/src/api/scene/sceneContext.ts @@ -75,6 +75,10 @@ export interface SceneContext { receiverShadowCacheKey: Map; casterItemsCache: Map; casterItemsCacheKey: Map; + /** Set during a progressive (light-drag) shadow emit so the receiver + * projector uses `shadow.dragDefinition` instead of full `definition`. + * Cleared for the debounced full-quality refine. */ + shadowDragActive: boolean; } /** Build a fresh SceneContext for a new scene. The mutable containers @@ -106,5 +110,6 @@ export function createSceneContext(input: { receiverShadowCacheKey: new Map(), casterItemsCache: new Map(), casterItemsCacheKey: new Map(), + shadowDragActive: false, }; } diff --git a/packages/polycss/src/api/scene/types.ts b/packages/polycss/src/api/scene/types.ts index 8daa9bbf..0d869ea3 100644 --- a/packages/polycss/src/api/scene/types.ts +++ b/packages/polycss/src/api/scene/types.ts @@ -101,6 +101,50 @@ export interface PolySceneOptions { * very large number (e.g. `Infinity`) to disable the cap entirely. */ maxExtend?: number; + /** + * Experimental: cast a single low-resolution parametric **silhouette** + * outline per caster instead of projecting its full geometry. Lighter DOM + * + cheaper projection, at the cost of an approximate (convex) outline. + * Casts onto every receiver through the normal pipeline. Default: `false`. + */ + parametric?: boolean; + /** + * Parametric-shadow detail: the max number of points in the silhouette + * outline. Lower → blobbier + lighter; higher → closer to the exact convex + * outline. Only used when `parametric` is true. Default: `16`. + * + * Can be overridden per mesh via `PolyMeshTransform.shadowDefinition`. + */ + definition?: number; + /** + * Progressive refinement: the definition used WHILE the directional light + * is actively changing (a drag). When set (and `parametric` is true), each + * light-direction change emits at `min(definition, dragDefinition)` for a + * laggless drag, then a debounced pass re-emits at full `definition` once + * the light settles — mirroring the atlas-rebake-at-rest escape hatch. + * Self-shadow recompute is O(faces × bands), so dropping detail during + * motion is the only way to keep a complex mesh smooth. Unset → no + * progressive pass (every change renders at full `definition`). + */ + dragDefinition?: number; + /** + * Parametric-shadow render style (only used when `parametric` is true): + * - `"vector"` (default) — smooth concave contour outline. + * - `"pixel"` — the coverage is greedy-meshed into axis-aligned rectangles, + * giving a blocky/voxel shadow. Holes (courtyards, the coliseum arena) + * come free as absent cells. `definition` is the pixel-grid resolution + * (lower → chunkier); the block size is the aesthetic. + */ + style?: "vector" | "pixel"; + /** + * Re-emit shadows while a mesh is animating (skeletal/GLB deformation), so + * the shadow follows the pose instead of freezing at the rest pose. Each + * `setPolygons` from the animation loop triggers a re-projection, throttled + * internally (~12fps) so it stays affordable. Strongly recommended only + * with `parametric: true` (a low-res silhouette is cheap to reproject every + * few frames; the exact path is not). Default: `false`. + */ + followAnimation?: boolean; }; /** * When `true`, emit `data-poly-shadow-*` attribution attributes on every @@ -161,6 +205,14 @@ export interface PolyMeshTransform { * Defaults to `false`. */ receiveShadow?: boolean; + /** + * Per-mesh parametric-shadow detail (overrides the scene's + * `shadow.definition` for THIS mesh's cast/self shadow). Lets a detailed + * caster stay high-resolution while a simple prop runs cheap in the same + * scene. Only used when `shadow.parametric` is true. Unset → inherit the + * scene definition. + */ + shadowDefinition?: number; } export interface PolyMeshHandle { diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx index 5d7e7f60..3399642a 100644 --- a/packages/react/src/scene/PolyMesh.tsx +++ b/packages/react/src/scene/PolyMesh.tsx @@ -38,6 +38,7 @@ import type { } from "@layoutit/polycss-core"; import { BASE_TILE, + buildParametricCasterOverride, buildPolyMeshTransform, buildSharedEdgeMap, computeMergedReceiverShadows, @@ -193,6 +194,12 @@ export interface PolyMeshProps extends TransformProps, InteractionProps { * for caster meshes — receivers handle shadow display. Defaults to `false`. */ receiveShadow?: boolean; + /** + * Per-mesh parametric-shadow detail, overriding the scene's + * `shadow.definition` for this mesh's cast/self shadow. Only used when the + * scene's `shadow.parametric` is true. Unset → inherit the scene definition. + */ + shadowDefinition?: number; /** * Apply mesh optimization (coplanar merge + interior cull) before * rendering. Defaults to `true` — matches vanilla `scene.add`'s default. @@ -246,6 +253,7 @@ export const PolyMesh = forwardRef(function PolyM onFrameReady, castShadow, receiveShadow, + shadowDefinition, merge = true, children, fallback, @@ -775,23 +783,41 @@ export const PolyMesh = forwardRef(function PolyM } return s; }, [atlasPlans, polygons]); + // Caster registration is split so a deforming (animated) mesh can FREEZE its + // shadow by default — re-registering the caster on every animation frame + // re-emits the receiver shadow each frame (expensive). Effect A owns + // register/unregister lifecycle (castShadow on/off, unmount). Effect B pushes + // updated geometry, but skips same-topology deforms unless `shadow. + // followAnimation` is set — then the shadow tracks the pose (throttle this + // with a low parametric `definition`). Mirrors vanilla `setPolygons`. + const shadowCasterRegisteredRef = useRef(false); + const lastShadowPolyCountRef = useRef(-1); useEffect(() => { - if (!sceneRegisterShadowCaster) return; - if (castShadow) { - sceneRegisterShadowCaster(meshIdRef.current, { - polygons, - position: position ?? [0, 0, 0], - scale, - rotation, - renderedPolygonIndices, - }); - } else { - sceneRegisterShadowCaster(meshIdRef.current, null); - } + if (!sceneRegisterShadowCaster || !castShadow) return; return () => { sceneRegisterShadowCaster(meshIdRef.current, null); + shadowCasterRegisteredRef.current = false; + lastShadowPolyCountRef.current = -1; }; - }, [sceneRegisterShadowCaster, castShadow, polygons, position, scale, rotation, renderedPolygonIndices]); + }, [sceneRegisterShadowCaster, castShadow]); + useEffect(() => { + if (!sceneRegisterShadowCaster || !castShadow) return; + const followAnimation = sceneCtx?.shadow?.followAnimation ?? false; + const topologyChanged = polygons.length !== lastShadowPolyCountRef.current; + // Freeze: a same-topology deform with followAnimation off keeps the last + // registered pose (no re-emit). No cleanup here, so this never unregisters. + if (shadowCasterRegisteredRef.current && !followAnimation && !topologyChanged) return; + lastShadowPolyCountRef.current = polygons.length; + shadowCasterRegisteredRef.current = true; + sceneRegisterShadowCaster(meshIdRef.current, { + polygons, + position: position ?? [0, 0, 0], + scale, + rotation, + renderedPolygonIndices, + shadowDefinition, + }); + }, [sceneRegisterShadowCaster, castShadow, polygons, position, scale, rotation, renderedPolygonIndices, shadowDefinition, sceneCtx?.shadow]); // Mirror receiveShadow registration so the scene knows whether at least // one receiver exists (drives the ground-shadow-disable rule on casters). @@ -1055,12 +1081,32 @@ export const PolyMesh = forwardRef(function PolyM } edgeOwners = cachedOwners; } + // Parametric override: low-res silhouette loops (shared core helper, so + // vanilla/React/Vue are identical). Per-mesh `shadowDefinition` beats the + // scene default; directional + per-point-light radial silhouettes. + let overrideSilhouette: Vec3[][] | undefined; + let overridePointSilhouettes: Array | undefined; + if (sceneShadow?.parametric) { + const def = data.shadowDefinition ?? sceneShadow.definition ?? 16; + const result = buildParametricCasterOverride({ + polysWorldVerts: items.map((it) => it.wv), + lightDir, + definition: def, + isSelf, + style: sceneShadow.style, + pointLights: shadowPointIndices.map((i) => ({ position: allPointLightsCss[i]!.position, index: i })), + }); + overrideSilhouette = result.overrideSilhouette; + overridePointSilhouettes = result.overridePointSilhouettes; + } casterInputs.push({ id: casterId, items, selfShadowEdgeMap: selfMap, edgeOwners, casterPolygonCount: data.polygons.length, + overrideSilhouette, + overridePointSilhouettes, }); } const cameraState = cameraCtx?.store.getState().cameraState; diff --git a/packages/react/src/scene/sceneContext.ts b/packages/react/src/scene/sceneContext.ts index 690de974..008f61a3 100644 --- a/packages/react/src/scene/sceneContext.ts +++ b/packages/react/src/scene/sceneContext.ts @@ -35,6 +35,9 @@ export interface ShadowCasterRegistration { * shadow). Undefined means "include all polygons" (used when the caller * doesn't have plan information). */ renderedPolygonIndices?: ReadonlySet; + /** Per-mesh parametric-shadow definition override (scene default when + * undefined). Mirrors `PolyMeshTransform.shadowDefinition` in vanilla. */ + shadowDefinition?: number; } export interface ShadowOptions { @@ -48,6 +51,32 @@ export interface ShadowOptions { * disable the cap entirely. */ maxExtend?: number; + /** + * Cast a low-resolution parametric silhouette per caster instead of its full + * geometry — lighter DOM + cheaper projection at the cost of an approximate + * outline. Casts onto every receiver through the normal pipeline. Default: + * `false`. (Directional + point lights; the exact path stays the default.) + */ + parametric?: boolean; + /** + * Parametric-shadow detail (max silhouette points / pixel-grid resolution). + * Only used when `parametric` is true. Default: `16`. Override per mesh via + * ``. + */ + definition?: number; + /** + * Parametric render style: `"vector"` (smooth contour, default) or `"pixel"` + * (greedy-meshed voxel blocks; `definition` becomes the grid resolution). + */ + style?: "vector" | "pixel"; + /** + * Re-emit a caster's shadow while it animates (deforms) so the shadow follows + * the pose instead of freezing. Default `false`: a same-topology deform keeps + * the last shadow pose (re-emitting every frame is expensive). Best with + * `parametric` — a low-res silhouette is cheap to reproject. Topology changes + * (different polygon count) always re-emit regardless. + */ + followAnimation?: boolean; } export interface PolySceneContextValue { diff --git a/packages/vue/src/scene/PolyMesh.ts b/packages/vue/src/scene/PolyMesh.ts index 2add10b8..60f20501 100644 --- a/packages/vue/src/scene/PolyMesh.ts +++ b/packages/vue/src/scene/PolyMesh.ts @@ -16,7 +16,7 @@ * When no `polygon` slot is provided, each polygon is rendered automatically * using the cheapest supported render-strategy leaf. */ -import { defineComponent, h, Teleport, computed, inject, onMounted, onBeforeUnmount, ref, watch, watchEffect } from "vue"; +import { defineComponent, h, Teleport, computed, inject, onMounted, onBeforeUnmount, ref, shallowRef, watch, watchEffect } from "vue"; import type { PropType, VNode, CSSProperties } from "vue"; import type { MeshResolution, @@ -37,6 +37,7 @@ import { } from "@layoutit/polycss-core"; import { BASE_TILE, + buildParametricCasterOverride, buildPolyMeshTransform, computeMergedReceiverShadows, computeSceneBbox, @@ -222,6 +223,9 @@ export const PolyMesh = defineComponent({ onFrameReady: { type: Function as PropType<() => void>, default: undefined }, castShadow: { type: Boolean as PropType, default: false }, receiveShadow: { type: Boolean as PropType, default: false }, + /** Per-mesh parametric-shadow detail override (scene `shadow.definition` + * when undefined). Only used when the scene's `shadow.parametric` is true. */ + shadowDefinition: { type: Number as PropType, default: undefined }, merge: { type: Boolean as PropType, default: true }, meshResolution: { type: String as PropType, default: undefined }, parseOptions: { type: Object as PropType, default: undefined }, @@ -730,12 +734,32 @@ export const PolyMesh = defineComponent({ } edgeOwners = cachedOwners; } + // Parametric override: low-res silhouette loops via the shared core + // helper (identical to vanilla + React). Per-mesh `shadowDefinition` + // beats the scene default; directional + per-point-light radial. + let overrideSilhouette: Vec3[][] | undefined; + let overridePointSilhouettes: Array | undefined; + if (ctx?.shadow?.parametric) { + const def = data.shadowDefinition ?? ctx.shadow.definition ?? 16; + const result = buildParametricCasterOverride({ + polysWorldVerts: items.map((it) => it.wv), + lightDir, + definition: def, + isSelf, + style: ctx.shadow.style, + pointLights: shadowPointIndices.map((idx) => ({ position: allPointLightsCss[idx]!.position, index: idx })), + }); + overrideSilhouette = result.overrideSilhouette; + overridePointSilhouettes = result.overridePointSilhouettes; + } casterInputs.push({ id: Symbol(`caster-${i++}`), items, selfShadowEdgeMap: isSelf ? cachedSelfMap : undefined, edgeOwners, casterPolygonCount: data.polygons.length, + overrideSilhouette, + overridePointSilhouettes, }); } const cameraState = cameraCtx?.store.getState().cameraState; @@ -805,6 +829,26 @@ export const PolyMesh = defineComponent({ ); }); + // Gated shadow-caster geometry: an animated (deforming) mesh FREEZES its + // shadow by default — the receiver shadow re-projects whenever this ref + // updates, so re-emitting every frame is expensive. Update it on real + // topology changes always, but on a same-topology deform only when + // `shadow.followAnimation` is set (then the shadow tracks the pose; pair + // with a low parametric `definition`). Mirrors vanilla `setPolygons`. + const shadowCasterPolygons = shallowRef(polygons.value); + let lastShadowPolyCount = -1; + watch( + polygons, + (polys) => { + const follow = sceneCtx?.value.shadow?.followAnimation ?? false; + const topologyChanged = polys.length !== lastShadowPolyCount; + if (lastShadowPolyCount >= 0 && !follow && !topologyChanged) return; + lastShadowPolyCount = polys.length; + shadowCasterPolygons.value = polys; + }, + { immediate: true }, + ); + // Register this mesh with the shadow registry when castShadow=true in // either lighting mode — the scene needs caster polygons (with their // full transforms) to derive the ground plane and to feed receiver @@ -817,9 +861,10 @@ export const PolyMesh = defineComponent({ if (!registry) return; if (castShadow) { registry.register(shadowRegistryId, () => { + const casterPolygons = shadowCasterPolygons.value; // Mirror vanilla: skip no-plan polys AND overlapping duplicates // (vanilla's dedupByCaster filter, same 0.5/0.95 thresholds). - const dedupDrop = findOverlappingPolygonDuplicates(polygons.value, { + const dedupDrop = findOverlappingPolygonDuplicates(casterPolygons, { normalTolerance: 0.1, distanceTolerance: 0.5, overlapFraction: 0.95, @@ -831,11 +876,12 @@ export const PolyMesh = defineComponent({ if (plans[i] && !dedupDrop.has(i)) s.add(i); } return { - polygons: polygons.value, + polygons: casterPolygons, position: props.position ?? [0, 0, 0], scale: props.scale, rotation: props.rotation, renderedPolygonIndices: s, + shadowDefinition: props.shadowDefinition, }; }); } else { diff --git a/packages/vue/src/scene/sceneContext.ts b/packages/vue/src/scene/sceneContext.ts index d1a7904b..75c0d0c0 100644 --- a/packages/vue/src/scene/sceneContext.ts +++ b/packages/vue/src/scene/sceneContext.ts @@ -31,6 +31,9 @@ export interface ShadowCasterRegistration { * Receiver-shadow algorithm skips polygons NOT in this set — mirrors * vanilla which iterates `caster.rendered`. Undefined → include all. */ renderedPolygonIndices?: ReadonlySet; + /** Per-mesh parametric-shadow definition override (scene default when + * undefined). Mirrors `PolyMeshTransform.shadowDefinition` in vanilla. */ + shadowDefinition?: number; } export interface PolyShadowOptions { @@ -44,6 +47,30 @@ export interface PolyShadowOptions { * disable the cap entirely. */ maxExtend?: number; + /** + * Cast a low-resolution parametric silhouette per caster instead of full + * geometry — lighter DOM + cheaper projection. Directional + point lights. + * Default: `false`. + */ + parametric?: boolean; + /** + * Parametric-shadow detail (max silhouette points / pixel-grid resolution). + * Only used when `parametric` is true. Default: `16`. Override per mesh via + * ``. + */ + definition?: number; + /** + * Parametric render style: `"vector"` (smooth contour, default) or `"pixel"` + * (greedy-meshed voxel blocks; `definition` becomes the grid resolution). + */ + style?: "vector" | "pixel"; + /** + * Re-emit a caster's shadow while it animates (deforms) so the shadow follows + * the pose instead of freezing. Default `false`: a same-topology deform keeps + * the last shadow pose (re-emitting every frame is expensive). Best with + * `parametric`. Topology changes (different polygon count) always re-emit. + */ + followAnimation?: boolean; } export interface PolyShadowRegistry { diff --git a/website/src/components/BuilderWorkbench/components/BuilderScene.tsx b/website/src/components/BuilderWorkbench/components/BuilderScene.tsx index 3aaf42f9..7683df9c 100644 --- a/website/src/components/BuilderWorkbench/components/BuilderScene.tsx +++ b/website/src/components/BuilderWorkbench/components/BuilderScene.tsx @@ -748,7 +748,7 @@ export function BuilderScene({ textureLighting={sceneOptions.textureLighting} textureQuality={sceneOptions.textureQuality} strategies={{ disable: sceneOptions.disableStrategies }} - shadow={{ maxExtend: sceneOptions.shadowMaxExtend }} + shadow={{ maxExtend: sceneOptions.shadowMaxExtend, parametric: sceneOptions.shadowParametric, definition: sceneOptions.shadowDefinition, style: sceneOptions.shadowStyle }} > {sceneOptions.showGround && ( <> diff --git a/website/src/components/BuilderWorkbench/defaults.ts b/website/src/components/BuilderWorkbench/defaults.ts index faea1d73..7041b1bb 100644 --- a/website/src/components/BuilderWorkbench/defaults.ts +++ b/website/src/components/BuilderWorkbench/defaults.ts @@ -51,6 +51,10 @@ export const DEFAULT_SCENE: SceneOptionsState = { disableStrategies: [], castShadow: true, shadowMaxExtend: 600, + shadowParametric: false, + shadowDefinition: 16, + shadowStyle: "vector", + shadowFollowAnimation: false, showGround: true, groundColor: "#7d848e", fpvLook: true, diff --git a/website/src/components/Dock/folders/useLightingFolder.ts b/website/src/components/Dock/folders/useLightingFolder.ts index 7c28888a..6964a7fd 100644 --- a/website/src/components/Dock/folders/useLightingFolder.ts +++ b/website/src/components/Dock/folders/useLightingFolder.ts @@ -15,6 +15,10 @@ export interface LightingFolderInputs { castShadow: boolean; selfShadow: boolean; shadowMaxExtend: number; + shadowParametric: boolean; + shadowDefinition: number; + shadowStyle: "vector" | "pixel"; + shadowFollowAnimation: boolean; showGround: boolean; groundColor: string; showLight: boolean; @@ -28,6 +32,10 @@ export interface LightingFolderInputs { castShadow?: boolean; selfShadow?: boolean; shadowMaxExtend?: number; + shadowParametric?: boolean; + shadowDefinition?: number; + shadowStyle?: "vector" | "pixel"; + shadowFollowAnimation?: boolean; showGround?: boolean; groundColor?: string; showLight?: boolean; @@ -45,6 +53,10 @@ export function useLightingFolder(parent: GUI | null, inputs: LightingFolderInpu castShadow, selfShadow, shadowMaxExtend, + shadowParametric, + shadowDefinition, + shadowStyle, + shadowFollowAnimation, showGround, groundColor, showLight, @@ -82,6 +94,25 @@ export function useLightingFolder(parent: GUI | null, inputs: LightingFolderInpu shadowMaxExtend, (value) => onUpdateScene({ shadowMaxExtend: value }), ); + // Parametric shadows: a low-res silhouette per caster instead of full + // geometry. `Shadow def.` sets the detail (silhouette points / pixel grid); + // `Pixel shadow` swaps the smooth contour for blocky voxel blocks. + useToggle(folder, "Parametric shadow", shadowParametric, (value) => + onUpdateScene({ shadowParametric: value }), + ); + useSlider( + folder, + "Shadow def.", + { min: 3, max: 96, step: 1 }, + shadowDefinition, + (value) => onUpdateScene({ shadowDefinition: value }), + ); + useToggle(folder, "Pixel shadow", shadowStyle === "pixel", (value) => + onUpdateScene({ shadowStyle: value ? "pixel" : "vector" }), + ); + useToggle(folder, "Animate shadow", shadowFollowAnimation, (value) => + onUpdateScene({ shadowFollowAnimation: value }), + ); useToggle(folder, "Show ground", showGround, (value) => onUpdateScene({ showGround: value })); const groundColorControl = useColor(folder, "Ground color", groundColor, (value) => onUpdateScene({ groundColor: value }), diff --git a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx index 27efd55e..b4baf7da 100644 --- a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx +++ b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx @@ -136,6 +136,10 @@ const DEFAULT_SCENE: SceneOptionsState = { castShadow: false, selfShadow: false, shadowMaxExtend: 2000, + shadowParametric: true, + shadowDefinition: 35, + shadowStyle: "vector", + shadowFollowAnimation: true, showGround: false, groundColor: "#4a505a", fpvLook: true, @@ -1547,6 +1551,10 @@ export default function GalleryWorkbench() { castShadow={sceneOptions.castShadow} selfShadow={sceneOptions.selfShadow} shadowMaxExtend={sceneOptions.shadowMaxExtend} + shadowParametric={sceneOptions.shadowParametric} + shadowDefinition={sceneOptions.shadowDefinition} + shadowStyle={sceneOptions.shadowStyle} + shadowFollowAnimation={sceneOptions.shadowFollowAnimation} showGround={sceneOptions.showGround} groundColor={sceneOptions.groundColor} showLight={sceneOptions.showLight} diff --git a/website/src/components/ReactScene/ReactScene.tsx b/website/src/components/ReactScene/ReactScene.tsx index 8ebcbb03..d2c5373c 100644 --- a/website/src/components/ReactScene/ReactScene.tsx +++ b/website/src/components/ReactScene/ReactScene.tsx @@ -215,7 +215,7 @@ export function ReactScene({ textureLighting={sceneOptions.textureLighting} textureQuality={textureQuality} strategies={{ disable: sceneOptions.disableStrategies }} - shadow={{ maxExtend: sceneOptions.shadowMaxExtend }} + shadow={{ maxExtend: sceneOptions.shadowMaxExtend, parametric: sceneOptions.shadowParametric, definition: sceneOptions.shadowDefinition, style: sceneOptions.shadowStyle }} > {sceneOptions.selection ? ( diff --git a/website/src/components/VanillaScene/VanillaScene.tsx b/website/src/components/VanillaScene/VanillaScene.tsx index 2287e5e0..9fc648ed 100644 --- a/website/src/components/VanillaScene/VanillaScene.tsx +++ b/website/src/components/VanillaScene/VanillaScene.tsx @@ -437,7 +437,14 @@ export function VanillaScene({ const scene = sceneRef.current; if (!scene) return; const nextDirectionalLight = directionalFromOptions(nextOptions); - const nextShadow = { maxExtend: nextOptions.shadowMaxExtend, lift: GALLERY_SHADOW_LIFT }; + const nextShadow = { + maxExtend: nextOptions.shadowMaxExtend, + lift: GALLERY_SHADOW_LIFT, + parametric: nextOptions.shadowParametric, + definition: nextOptions.shadowDefinition, + style: nextOptions.shadowStyle, + followAnimation: nextOptions.shadowFollowAnimation, + }; const previewShadow = preview?.shadow !== false; if (nextOptions.textureLighting === "dynamic") { scene.setOptions({ @@ -493,7 +500,7 @@ export function VanillaScene({ directionalLight: nextDirectionalLight, ambientLight: ambientFromOptions(nextOptions), textureLighting: nextOptions.textureLighting, - shadow: { maxExtend: nextOptions.shadowMaxExtend, lift: GALLERY_SHADOW_LIFT }, + shadow: { maxExtend: nextOptions.shadowMaxExtend, lift: GALLERY_SHADOW_LIFT, parametric: nextOptions.shadowParametric, definition: nextOptions.shadowDefinition, style: nextOptions.shadowStyle, followAnimation: nextOptions.shadowFollowAnimation }, }); lightHandleRef.current?.setTransform({ position: lightHelperPosition( @@ -547,7 +554,7 @@ export function VanillaScene({ autoCenter: options.autoCenter, textureQuality: options.textureQuality, strategies: { disable: options.disableStrategies }, - shadow: { maxExtend: options.shadowMaxExtend, lift: GALLERY_SHADOW_LIFT }, + shadow: { maxExtend: options.shadowMaxExtend, lift: GALLERY_SHADOW_LIFT, parametric: options.shadowParametric, definition: options.shadowDefinition, style: options.shadowStyle, followAnimation: options.shadowFollowAnimation }, }; const scene = createPolyScene(host, sceneOptions); sceneRef.current = scene; @@ -811,6 +818,10 @@ export function VanillaScene({ const transformCache = stableDomForMesh && options.textureLighting === "baked" && + // Follow-animation shadows need fresh polygons via setPolygons every + // frame; the transform cache skips setPolygons on cache hits (leaving + // entry.polygons stale), so disable it while shadows track the pose. + !options.shadowFollowAnimation && typeof animationDurationSeconds === "number" && animationDurationSeconds > 0 ? createStableTriangleTransformCache() @@ -927,6 +938,7 @@ export function VanillaScene({ animationFrameFactory, animationDurationSeconds, options.textureLighting, + options.shadowFollowAnimation, stableDomForMesh, ]); @@ -960,7 +972,7 @@ export function VanillaScene({ directionalLight, ambientLight, textureLighting: options.textureLighting, - shadow: { maxExtend: options.shadowMaxExtend, lift: GALLERY_SHADOW_LIFT }, + shadow: { maxExtend: options.shadowMaxExtend, lift: GALLERY_SHADOW_LIFT, parametric: options.shadowParametric, definition: options.shadowDefinition, style: options.shadowStyle, followAnimation: options.shadowFollowAnimation }, }); const nextLightingSignature = bakedLightingSignature(directionalLight, ambientLight); if ( @@ -977,6 +989,10 @@ export function VanillaScene({ options.textureLighting, options.groundColor, options.shadowMaxExtend, + options.shadowParametric, + options.shadowDefinition, + options.shadowStyle, + options.shadowFollowAnimation, directionalLight, ambientLight, ]); diff --git a/website/src/components/types.ts b/website/src/components/types.ts index 50c6d213..ba513b40 100644 --- a/website/src/components/types.ts +++ b/website/src/components/types.ts @@ -78,6 +78,17 @@ export interface SceneOptionsState { /** Maximum CSS pixels the shadow may extend beyond the mesh footprint. * Caps the SVG backing store at low light elevations. */ shadowMaxExtend: number; + /** Cast a low-resolution parametric silhouette per caster instead of full + * geometry — lighter DOM + cheaper projection. */ + shadowParametric: boolean; + /** Parametric-shadow detail (silhouette points / pixel-grid resolution). + * Only used when `shadowParametric` is true. */ + shadowDefinition: number; + /** Parametric render style: smooth contour vs blocky voxel. */ + shadowStyle: "vector" | "pixel"; + /** Re-emit shadows while a mesh animates so the shadow follows the pose + * (throttled; best with parametric). */ + shadowFollowAnimation: boolean; showGround: boolean; groundColor: string; fpvLook: boolean;