Skip to content

Commit 5353eff

Browse files
authored
fix(shadow): kill cast/self-shadow holes on imported meshes + shadow-parity bench tooling (#70)
* fix(shadow): fill floor cast-shadow gaps on imported meshes via silhouette-reliability gate + double-sided fallback * feat(bench): real-capture all-mesh shadow oracle with headless JSON verdict * feat(bench): shadow-mask parity oracle — binary shadow-shape diff, IoU, rotation-correct occluder attribution * fix(shadow): cast from all polygons, not just camera-rendered ones, to kill camera-dependent floor-shadow holes * fix(shadow): gate self-shadow seam cull to near-coplanar casters so faceted meshes keep real self-shadows * feat(bench): light-animation panel — per-axis math expressions of t with play/pause, speed, and scrub * fix(shadow): cast self-shadow double-sided so non-watertight meshes keep self-shadows from light-back-facing occluders
1 parent 3e6df5b commit 5353eff

11 files changed

Lines changed: 1069 additions & 196 deletions

File tree

AGENTS.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ Strategies are ordered cheapest → most expensive. The mesher's job is to maxim
4141

4242
Callers can opt out of specific strategies via `strategies: { disable: ["b" | "i" | "u"] }` on `RenderTextureAtlasOptions`. Disabled or unsupported strategies fall through the chain (`b → i → s`, `u → i → s`, `i → s`). Disabling `"i"` also disables the exact corner-shape solid branch even though that branch emits a bare `<u>`, because it belongs to the non-triangle clipped-solid family. `<s>` is the universal fallback and cannot be disabled. Solid seam bleed is internal: detected shared solid edges get up to `1.5` CSS px of per-edge overscan, fitted to the polygon plan, rather than inflating every side of each participating polygon. It is not exposed as a scene, mesh, custom-element, or atlas renderer option.
4343

44-
Cast shadows are not a render-strategy leaf tag. Meshes with `castShadow: true` project casting polygons on the CPU into SVG shadow surfaces: one aggregate path for the ground plane in vanilla, per-mesh SVG paths in React/Vue, plus scene-level receiver surfaces where `receiveShadow` is enabled. Overlapping projections are merged into compound paths so shadows do not alpha-stack polygon-by-polygon, and back-facing / duplicate projections are dropped before emission. Moving a light or changing caster/receiver geometry re-emits the shadow SVGs; this is DOM/SVG work only and does not redraw texture atlases.
44+
Cast shadows are not a render-strategy leaf tag. Meshes with `castShadow: true` project casting polygons on the CPU into SVG shadow surfaces: one aggregate path for the ground plane in vanilla, per-mesh SVG paths in React/Vue, plus scene-level receiver surfaces where `receiveShadow` is enabled. EVERY polygon casts — shadow casters are NOT filtered to the camera-rendered set (atlas plan) or de-duplicated, because a polygon casts a shadow regardless of whether it's painted for the camera; filtering left camera-dependent holes in imported-mesh shadows. Coincident/overlapping projections are merged into one compound path per caster under `fill-rule: nonzero`, so they don't alpha-stack rather than being pre-dropped. Moving a light or changing caster/receiver geometry re-emits the shadow SVGs; this is DOM/SVG work only and does not redraw texture atlases.
45+
46+
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.
4547

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

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Shadow-MASK parity verdict — the better oracle.
4+
*
5+
* Instead of a luma diff with a tolerance knob, this drives both engines
6+
* into "shadows only" mode (PolyCSS hides meshes → shadow SVGs only;
7+
* three.js → ShadowMaterial), reduces each REAL render to a binary
8+
* "in shadow?" mask, and compares shadow SHAPE: missing (three shadowed,
9+
* PolyCSS lit = a hole), extra (PolyCSS shadowed, three lit). three.js
10+
* stays the reference; only the comparison method changes.
11+
*
12+
* Writes illustration PNGs (diff classification + the on-render green
13+
* occluder outlines) next to the JSON verdict.
14+
*
15+
* Usage:
16+
* node bench/scripts/shadow-mask-verdict.mjs "<shadow-oracle URL>" [outPrefix]
17+
*
18+
* Server must be running on :4400.
19+
*/
20+
import { chromium } from 'playwright';
21+
import { writeFileSync } from 'node:fs';
22+
23+
const url = process.argv[2];
24+
if (!url) { console.error('usage: shadow-mask-verdict.mjs <url> [outPrefix]'); process.exit(2); }
25+
const prefix = process.argv[3] ?? '/tmp/shadow-mask';
26+
27+
const browser = await chromium.launch({ args: ['--use-angle=metal', '--enable-gpu-rasterization'] });
28+
const ctx = await browser.newContext({ viewport: { width: 1500, height: 950 }, deviceScaleFactor: 2 });
29+
const page = await ctx.newPage();
30+
page.on('pageerror', e => console.error('[pageerror]', e.message));
31+
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
32+
await page.waitForTimeout(6500);
33+
34+
await page.evaluate(() => window.oracleSetHelpersHidden(true));
35+
// Shadows-only mode for clean binary masks on both engines.
36+
await page.evaluate(() => window.oracleSetShadowsOnly(true));
37+
await page.waitForTimeout(800); // SVG recolor + three ShadowMaterial render
38+
39+
const shot = await (await page.$('#poly-host')).screenshot();
40+
const json = await page.evaluate(
41+
d => window.oracleMaskCompare(d),
42+
'data:image/png;base64,' + shot.toString('base64'),
43+
);
44+
45+
// Illustration 1: the diff classification (red=missing, blue=extra).
46+
await (await page.$('.stage.diff')).screenshot({ path: `${prefix}-diff.png` });
47+
// Illustration 2: back to normal render so the green occluder outlines show.
48+
await page.evaluate(() => window.oracleSetShadowsOnly(false));
49+
await page.evaluate(() => window.oracleSetHelpersHidden(true));
50+
await page.waitForTimeout(400);
51+
await (await page.$('.stage.polycss')).screenshot({ path: `${prefix}-render.png` });
52+
await (await page.$('.stage.three')).screenshot({ path: `${prefix}-three.png` });
53+
54+
writeFileSync(`${prefix}.json`, JSON.stringify(json, null, 2));
55+
console.log(JSON.stringify(json, null, 2));
56+
console.error(`wrote ${prefix}-diff.png ${prefix}-render.png ${prefix}-three.png ${prefix}.json`);
57+
await browser.close();
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Headless shadow-oracle verdict.
4+
*
5+
* Drives bench/shadow-oracle.html with a deterministic URL, screenshots
6+
* #poly-host with the REAL compositor (Playwright — captures the
7+
* 3D-`matrix3d`-transformed receiver-shadow SVGs that html-to-image drops),
8+
* hands it to the in-page oracle, and prints the JSON verdict: every polygon
9+
* (across ALL meshes — caster model + floor receiver) whose rendered
10+
* shading/shadow disagrees with three.js beyond the luma tolerance.
11+
*
12+
* Usage:
13+
* node bench/scripts/shadow-oracle-verdict.mjs "<shadow-oracle URL>" [outFile.json]
14+
*
15+
* Server must be running on :4400 (node bench/perf-serve.mjs).
16+
*/
17+
import { chromium } from 'playwright';
18+
import { writeFileSync } from 'node:fs';
19+
20+
const url = process.argv[2];
21+
if (!url) { console.error('usage: shadow-oracle-verdict.mjs <url> [out.json]'); process.exit(2); }
22+
const outFile = process.argv[3];
23+
24+
const browser = await chromium.launch({ args: ['--use-angle=metal', '--enable-gpu-rasterization'] });
25+
const ctx = await browser.newContext({ viewport: { width: 1500, height: 950 }, deviceScaleFactor: 1 });
26+
const page = await ctx.newPage();
27+
page.on('pageerror', e => console.error('[pageerror]', e.message));
28+
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
29+
// Settle: atlas blobs + shadow SVGs + three draw.
30+
await page.waitForTimeout(6500);
31+
32+
// Hide the light helper, let it present, screenshot the real PolyCSS render.
33+
await page.evaluate(() => window.oracleSetHelpersHidden(true));
34+
await page.waitForTimeout(200);
35+
const host = await page.$('#poly-host');
36+
const shot = await host.screenshot();
37+
const dataUrl = 'data:image/png;base64,' + shot.toString('base64');
38+
39+
const json = await page.evaluate(d => window.oracleCompareWithImage(d), dataUrl);
40+
await page.evaluate(() => window.oracleSetHelpersHidden(false));
41+
42+
if (!json) { console.error('oracle returned null'); process.exit(1); }
43+
const out = JSON.stringify(json, null, 2);
44+
if (outFile) { writeFileSync(outFile, out); console.error('wrote', outFile); }
45+
console.log(out);
46+
await browser.close();

0 commit comments

Comments
 (0)