From e7852ccc2e76856dd49089228a5854b566358363 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 19 Jun 2026 02:42:58 +0200 Subject: [PATCH 01/21] feat(core): direction-only point-light shading in the baked atlas plan (PolyPointLight + per-face contribs) --- packages/core/src/atlas/paintDefaults.test.ts | 33 ++++++++++ packages/core/src/atlas/paintDefaults.ts | 61 ++++++++++++++++--- packages/core/src/atlas/plan.ts | 43 ++++++++++++- packages/core/src/atlas/solidTrianglePlan.ts | 12 +++- packages/core/src/atlas/types.ts | 8 +++ packages/core/src/index.ts | 1 + packages/core/src/types.ts | 24 ++++++++ 7 files changed, 168 insertions(+), 14 deletions(-) diff --git a/packages/core/src/atlas/paintDefaults.test.ts b/packages/core/src/atlas/paintDefaults.test.ts index 1343839f..e574986a 100644 --- a/packages/core/src/atlas/paintDefaults.test.ts +++ b/packages/core/src/atlas/paintDefaults.test.ts @@ -334,3 +334,36 @@ describe("colorErrorScore — perceptual distance", () => { expect(closePair).toBeLessThan(farPair); }); }); + +describe("point-light contributions (shadePolygon / textureTintFactors)", () => { + it("a point contribution brightens a solid color vs ambient-only", () => { + const amb = ["#ffffff", 0.3] as const; + const dark = shadePolygon("#808080", 0, "#ffffff", amb[0], amb[1]); + const lit = shadePolygon("#808080", 0, "#ffffff", amb[0], amb[1], [ + { color: "#ffffff", scale: 0.8 }, + ]); + const lum = (hex: string) => { + const c = parseHex(hex); + return 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b; + }; + expect(lum(lit)).toBeGreaterThan(lum(dark)); + }); + + it("zero-scale point contribs are a no-op", () => { + const a = shadePolygon("#808080", 0.5, "#ffffff", "#ffffff", 0.3); + const b = shadePolygon("#808080", 0.5, "#ffffff", "#ffffff", 0.3, [ + { color: "#ffffff", scale: 0 }, + ]); + expect(b).toBe(a); + }); + + it("point contribs add to the directional+ambient texture tint per channel", () => { + const base = textureTintFactors(0.2, "#ffffff", "#ffffff", 0.3); + const withPoint = textureTintFactors(0.2, "#ffffff", "#ffffff", 0.3, [ + { color: "#ff0000", scale: 0.5 }, + ]); + // Red point light raises r more than g/b. + expect(withPoint.r).toBeGreaterThan(base.r); + expect(withPoint.r - base.r).toBeGreaterThan(withPoint.g - base.g); + }); +}); diff --git a/packages/core/src/atlas/paintDefaults.ts b/packages/core/src/atlas/paintDefaults.ts index d0f9da7a..3875524a 100644 --- a/packages/core/src/atlas/paintDefaults.ts +++ b/packages/core/src/atlas/paintDefaults.ts @@ -55,11 +55,39 @@ export function rgbToHex({ r, g, b }: RGB): string { * (see applyTextureTint in the renderer). `directScale` is already * `intensity × max(n·L, 0)` (computed by the caller). */ +/** + * One point light's per-face contribution to a polygon: its color and the + * scalar `intensity × max(0, n·L̂)` already computed by the caller against the + * face normal + centroid (point lights are direction-only — no distance + * falloff). Multiple lights of different colours can't fold into one scalar, + * so the shading functions accumulate these per channel in linear space. + */ +export interface PointLightContrib { + color: string; + scale: number; +} + +/** Accumulate Σ pointContrib.color_linear × scale into a running [r,g,b]. */ +function addPointContribs( + acc: [number, number, number], + pointContribs: readonly PointLightContrib[] | undefined, +): void { + if (!pointContribs) return; + for (const pc of pointContribs) { + if (pc.scale <= 0) continue; + const c = parseHex(pc.color); + acc[0] += srgbChannelToLinear(c.r / 255) * pc.scale; + acc[1] += srgbChannelToLinear(c.g / 255) * pc.scale; + acc[2] += srgbChannelToLinear(c.b / 255) * pc.scale; + } +} + export function textureTintFactors( directScale: number, lightColor: string, ambientColor: string, ambientIntensity: number, + pointContribs?: readonly PointLightContrib[], ): RGBFactors { const light = parseHex(lightColor); const amb = parseHex(ambientColor); @@ -69,10 +97,16 @@ export function textureTintFactors( const ambLR = srgbChannelToLinear(amb.r / 255); const ambLG = srgbChannelToLinear(amb.g / 255); const ambLB = srgbChannelToLinear(amb.b / 255); + const acc: [number, number, number] = [ + lightLR * directScale, + lightLG * directScale, + lightLB * directScale, + ]; + addPointContribs(acc, pointContribs); return { - r: (lightLR * directScale + ambLR * ambientIntensity) * INV_PI, - g: (lightLG * directScale + ambLG * ambientIntensity) * INV_PI, - b: (lightLB * directScale + ambLB * ambientIntensity) * INV_PI, + r: (acc[0] + ambLR * ambientIntensity) * INV_PI, + g: (acc[1] + ambLG * ambientIntensity) * INV_PI, + b: (acc[2] + ambLB * ambientIntensity) * INV_PI, }; } @@ -100,6 +134,7 @@ export function shadePolygon( lightColor: string, ambientColor: string, ambientIntensity: number, + pointContribs?: readonly PointLightContrib[], ): string { const base = parseHex(baseColor); const light = parseHex(lightColor); @@ -117,13 +152,19 @@ export function shadePolygon( const ambLB = srgbChannelToLinear(amb.b / 255); // Physically based diffuse (Three.js MeshLambertMaterial parity): // lit = (BRDF_Lambert(albedo)) × (directIrradiance + indirectIrradiance) - // = (albedo / π) × (lightColor × lambert × I + ambientColor × I_amb) - // The `/π` wraps the whole sum — both the direct and ambient contributions - // go through the same diffuse BRDF, so ambient is also normalized by π. - // `directScale` is `intensity × max(n·L, 0)` (computed by the caller). - const litLR = baseLR * (lightLR * directScale + ambLR * ambientIntensity) * INV_PI; - const litLG = baseLG * (lightLG * directScale + ambLG * ambientIntensity) * INV_PI; - const litLB = baseLB * (lightLB * directScale + ambLB * ambientIntensity) * INV_PI; + // = (albedo / π) × (Σ lightColor × lambert × I + ambientColor × I_amb) + // The `/π` wraps the whole sum — direct, point, and ambient contributions + // all go through the same diffuse BRDF. `directScale` is the directional + // `intensity × max(n·L, 0)`; pointContribs add Σ pointColor × pointScale. + const acc: [number, number, number] = [ + lightLR * directScale, + lightLG * directScale, + lightLB * directScale, + ]; + addPointContribs(acc, pointContribs); + const litLR = baseLR * (acc[0] + ambLR * ambientIntensity) * INV_PI; + const litLG = baseLG * (acc[1] + ambLG * ambientIntensity) * INV_PI; + const litLB = baseLB * (acc[2] + ambLB * ambientIntensity) * INV_PI; const enc = (v: number) => Math.max(0, Math.min(255, Math.round(linearChannelToSrgb(Math.max(0, Math.min(1, v))) * 255))); const r = enc(litLR); diff --git a/packages/core/src/atlas/plan.ts b/packages/core/src/atlas/plan.ts index 55acdc9b..e276465e 100644 --- a/packages/core/src/atlas/plan.ts +++ b/packages/core/src/atlas/plan.ts @@ -1,5 +1,6 @@ import type { Polygon, + PolyPointLight, TextureTriangle, Vec2, Vec3, @@ -49,7 +50,7 @@ import { offsetConvexPolygonPointsByEdgeAmounts, stableBasisFromPlan, } from "./solidTriangle"; -import { textureTintFactors, shadePolygon } from "./paintDefaults"; +import { textureTintFactors, shadePolygon, type PointLightContrib } from "./paintDefaults"; import { computePlanSeamBleedEdgeAmounts, computeSeamBleedInsets, @@ -252,6 +253,39 @@ export function seamLightBrightness( return tint.r * 0.2126 + tint.g * 0.7152 + tint.b * 0.0722; } +/** + * Per-face point-light contributions for a polygon. Each scene point light + * (already in MESH-LOCAL user coords via the renderer) is converted to the + * CSS frame the polygon's `cssPts` use, then a direction-only Lambert scale + * is computed against the face centroid + normal. Returns `undefined` when + * there are no point lights so the shading fast path is unchanged. + */ +export function computePointContribs( + pointLights: readonly PolyPointLight[] | undefined, + cssPts: Vec3[], + normal: Vec3, + tile: number, + elev: number, +): PointLightContrib[] | undefined { + if (!pointLights || pointLights.length === 0) return undefined; + let cx = 0, cy = 0, cz = 0; + for (const p of cssPts) { cx += p[0]; cy += p[1]; cz += p[2]; } + const inv = 1 / cssPts.length; + cx *= inv; cy *= inv; cz *= inv; + const out: PointLightContrib[] = []; + for (const pl of pointLights) { + // Mesh-local user position → CSS frame (same axis swap × tile/elev as cssPoints). + const px = pl.position[1] * tile, py = pl.position[0] * tile, pz = pl.position[2] * elev; + let dx = px - cx, dy = py - cy, dz = pz - cz; + const d = Math.hypot(dx, dy, dz) || 1; + dx /= d; dy /= d; dz /= d; + const lambert = normal[0] * dx + normal[1] * dy + normal[2] * dz; + if (lambert <= 0) continue; + out.push({ color: pl.color ?? "#ffffff", scale: Math.max(0, pl.intensity ?? 1) * lambert }); + } + return out.length ? out : undefined; +} + export function basisAxisKey(axis: Vec3): string { const canonical: Vec3 = [...axis] as Vec3; const first = Math.abs(canonical[0]) > BASIS_EPS @@ -829,8 +863,11 @@ export function computeTextureAtlasPlan( const directScale = occluded ? 0 : lightIntensity * Math.max(0, normal[0] * lx + normal[1] * ly + normal[2] * lz); - const textureTint = textureTintFactors(directScale, lightColor, ambientColor, ambientIntensity); - const shadedColor = shadePolygon(polygon.color ?? "#cccccc", directScale, lightColor, ambientColor, ambientIntensity); + // Point lights are occlusion-shaded the same as directional: a polygon the + // directional light can't reach (occluded) still gets point-light + ambient. + const pointContribs = computePointContribs(options.pointLights, pts, normal, tile, elev); + const textureTint = textureTintFactors(directScale, lightColor, ambientColor, ambientIntensity, pointContribs); + const shadedColor = shadePolygon(polygon.color ?? "#cccccc", directScale, lightColor, ambientColor, ambientIntensity, pointContribs); let uvAffine: UvAffine | null = null; let uvSampleRect: UvSampleRect | null = null; diff --git a/packages/core/src/atlas/solidTrianglePlan.ts b/packages/core/src/atlas/solidTrianglePlan.ts index 9fd142cf..5ab487ee 100644 --- a/packages/core/src/atlas/solidTrianglePlan.ts +++ b/packages/core/src/atlas/solidTrianglePlan.ts @@ -34,6 +34,7 @@ import { offsetStableTrianglePoints, stableTriangleMatrixDecimals, } from "./solidTriangle"; +import { computePointContribs } from "./plan"; import { resolveSeamBleed, safePlanSeamBleedAmount, @@ -95,7 +96,16 @@ export function computeSolidTriangleColorPlanFromNormal( const directScale = occluded ? 0 : lightIntensity * Math.max(0, nx * lx + ny * ly + nz * lz); - const shadedColorRaw = shadePolygon(baseColor, directScale, lightColor, ambientColor, ambientIntensity); + const ptTile = options.tileSize ?? DEFAULT_TILE; + const ptElev = options.layerElevation ?? ptTile; + const pointContribs = computePointContribs( + options.pointLights, + cssPoints(polygon.vertices, ptTile, ptElev), + [nx, ny, nz], + ptTile, + ptElev, + ); + const shadedColorRaw = shadePolygon(baseColor, directScale, lightColor, ambientColor, ambientIntensity, pointContribs); const textureLighting = options.textureLighting ?? "baked"; const shadedColor = textureLighting === "baked" && internalOptions.stableTriangleColorSteps ? quantizeCssColor(shadedColorRaw, internalOptions.stableTriangleColorSteps) diff --git a/packages/core/src/atlas/types.ts b/packages/core/src/atlas/types.ts index d2c9ac14..8627d49b 100644 --- a/packages/core/src/atlas/types.ts +++ b/packages/core/src/atlas/types.ts @@ -320,6 +320,10 @@ export interface SolidTrianglePlanOptions { tileSize?: number; layerElevation?: number; directionalLight?: import("../types").PolyDirectionalLight; + /** Point lights in MESH-LOCAL frame (renderer pre-transforms each scene + * point light by inverse-rotate(worldPos - meshPos) so positions match + * the local cssPoints frame). Direction-only, per-face Lambert. */ + pointLights?: import("../types").PolyPointLight[]; ambientLight?: import("../types").PolyAmbientLight; textureLighting?: import("../types").PolyTextureLightingMode; solidPaintDefaults?: SolidPaintDefaults; @@ -354,6 +358,10 @@ export interface ComputeTextureAtlasPlanOptions { tileSize?: number; layerElevation?: number; directionalLight?: import("../types").PolyDirectionalLight; + /** Point lights in MESH-LOCAL frame (renderer pre-transforms each scene + * point light by inverse-rotate(worldPos - meshPos) so positions match + * the local cssPoints frame). Direction-only, per-face Lambert. */ + pointLights?: import("../types").PolyPointLight[]; ambientLight?: import("../types").PolyAmbientLight; /** Shared-edge set returned by {@link buildTextureEdgeRepairSets}. */ textureEdgeRepairEdges?: Set; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a0dc5e79..d8cd422e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -21,6 +21,7 @@ export type { Polygon, PolyMaterial, PolyDirectionalLight, + PolyPointLight, PolyAmbientLight, PolyTextureLightingMode, MeshResolution, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 100ee3a0..c09e65a9 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -95,6 +95,30 @@ export interface PolyDirectionalLight { intensity?: number; } +/** + * Point light — radiates from a world-space position. Contributes per-face + * Lambert like a directional light, but the light direction is computed + * per polygon (face centroid → light position) rather than being global. + * + * DIRECTION-ONLY by design: there is no distance falloff (no `decay`/ + * `distance`). A point light differs from a directional light only in that + * its direction varies per face. To emulate in three.js for parity, use + * `new THREE.PointLight(color, intensity, 0, 0)` (distance 0, decay 0 → + * no attenuation). Shading is flat per face (the centroid direction), so it + * approximates three.js's per-fragment gradient — exact for small faces / + * distant lights, never a CSS gradient. + */ +export interface PolyPointLight { + /** World-space position the light radiates from. */ + position: Vec3; + /** Light tint, hex string. White by default. */ + color?: string; + /** Scalar multiplier on the contribution. Default 1. */ + intensity?: number; + /** When true, this light casts shadows (radial projection). Default false. */ + castShadow?: boolean; +} + /** * Ambient light — uniform fill that adds to every polygon regardless of * orientation. Mirrors three.js's `AmbientLight`. Decoupled from the From 033e6112467534599e64142c8cd6fd25f9b8b5bf Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 19 Jun 2026 02:48:54 +0200 Subject: [PATCH 02/21] =?UTF-8?q?feat(polycss):=20pointLights=20scene=20op?= =?UTF-8?q?tion=20=E2=80=94=20per-mesh=20local=20conversion=20+=20bake/re-?= =?UTF-8?q?bake=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/polycss/src/api/createPolyScene.ts | 35 ++++++++++++++++++++- packages/polycss/src/api/scene/types.ts | 4 +++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index 75c328cd..384ff580 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -23,6 +23,7 @@ import type { ParseResult, Polygon, Vec3, + PolyPointLight, CameraCullNormalGroup, CameraCullRotation, } from "@layoutit/polycss-core"; @@ -1132,6 +1133,24 @@ export function createPolyScene( !entry.castShadow; } + // Convert the scene's world-space point lights into a mesh's LOCAL frame + // (subtract the mesh position, inverse-rotate by the mesh rotation) so they + // match the local vertex frame the atlas plan shades in. The atlas plan + // applies the CSS axis-swap × tile itself (computePointContribs). Returns + // undefined when there are no point lights so the shading fast path holds. + function localPointLightsForEntry(entry: MeshEntry): PolyPointLight[] | undefined { + const pls = currentOptions.pointLights; + if (!pls || pls.length === 0) return undefined; + const pos = entry.handle.transform.position ?? [0, 0, 0]; + const rot = entry.handle.transform.rotation ?? [0, 0, 0]; + const hasRot = rot[0] !== 0 || rot[1] !== 0 || rot[2] !== 0; + return pls.map((pl) => { + const rel: Vec3 = [pl.position[0] - pos[0], pl.position[1] - pos[1], pl.position[2] - pos[2]]; + const local = hasRot ? inverseRotateVec3(rel, rot as Vec3) : rel; + return { ...pl, position: local }; + }); + } + function renderEntry(entry: MeshEntry, lightDirectionOverride?: Vec3): void { clearRendered(entry); const baseDirLight = currentOptions.directionalLight; @@ -1188,6 +1207,7 @@ export function createPolyScene( const renderOptions = { doc, directionalLight, + pointLights: localPointLightsForEntry(entry), ambientLight: currentOptions.ambientLight, textureLighting: currentOptions.textureLighting, textureQuality: currentOptions.textureQuality, @@ -1276,6 +1296,7 @@ export function createPolyScene( const renderOptions = { doc, directionalLight, + pointLights: localPointLightsForEntry(entry), ambientLight: currentOptions.ambientLight, textureLighting: currentOptions.textureLighting, textureQuality: currentOptions.textureQuality, @@ -1468,6 +1489,7 @@ export function createPolyScene( const renderOptions = { doc, directionalLight, + pointLights: localPointLightsForEntry(entry), ambientLight: currentOptions.ambientLight, textureLighting: currentOptions.textureLighting, textureQuality: currentOptions.textureQuality, @@ -2112,6 +2134,7 @@ export function createPolyScene( const prevTextureLighting = currentOptions.textureLighting; const prevLightDir = currentOptions.directionalLight?.direction; const prevShadow = currentOptions.shadow; + const prevPointLights = currentOptions.pointLights; const normalizedPartial = normalizeSceneOptions(partial); currentOptions = { ...currentOptions, ...normalizedPartial }; // Keep the SceneContext's options ref pointing to the latest snapshot so @@ -2145,13 +2168,23 @@ export function createPolyScene( const textureProjectionChanged = Object.prototype.hasOwnProperty.call(partial, "textureProjection") && normalizedPartial.textureProjection !== prevTextureProjection; + // Point lights are baked per-face into the atlas/solid colors, so any + // change (added/removed/moved/recolored) requires a re-bake of every + // mesh. Compare a compact signature so passing the same array each tick + // (e.g. bundled with camera updates) doesn't re-bake needlessly. + const pointLightSig = (pls: PolyPointLight[] | undefined): string => + (pls ?? []).map((p) => `${p.position.join(",")}|${p.color ?? ""}|${p.intensity ?? 1}|${p.castShadow ? 1 : 0}`).join(";"); + const pointLightsChanged = + Object.prototype.hasOwnProperty.call(partial, "pointLights") && + pointLightSig(currentOptions.pointLights) !== pointLightSig(prevPointLights); if ( strategiesChanged || seamBleedChanged || textureLeafSizingChanged || textureImageRenderingChanged || textureBackendChanged || - textureProjectionChanged + textureProjectionChanged || + pointLightsChanged ) { for (const entry of meshes) renderEntry(entry); } diff --git a/packages/polycss/src/api/scene/types.ts b/packages/polycss/src/api/scene/types.ts index 01e57722..8daa9bbf 100644 --- a/packages/polycss/src/api/scene/types.ts +++ b/packages/polycss/src/api/scene/types.ts @@ -9,6 +9,7 @@ import type { ParseResult, PolyAmbientLight, PolyDirectionalLight, + PolyPointLight, Polygon, PolyTextureBackend, PolyTextureImageRendering, @@ -35,6 +36,9 @@ export interface PolySceneOptions { */ camera: PolyPerspectiveCameraHandle | PolyOrthographicCameraHandle; directionalLight?: PolyDirectionalLight; + /** Point lights (world-space positions). Direction-only per-face Lambert + * shading (no distance falloff). Baked-mode only. */ + pointLights?: PolyPointLight[]; ambientLight?: PolyAmbientLight; /** Textured polygon lighting mode. Defaults to "baked". */ textureLighting?: PolyTextureLightingMode; From ed53a5bb8da14dc0beb3e31f67bfe0bdd03b6ce7 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 19 Jun 2026 02:52:53 +0200 Subject: [PATCH 03/21] =?UTF-8?q?feat(bench):=20point-light=20oracle=20?= =?UTF-8?q?=E2=80=94=202=20point=20lights=20(PolyCSS)=20vs=20three.js=20Po?= =?UTF-8?q?intLight(decay=3D0)=20emulation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bench/point-light-oracle.html | 2908 ++++++++++++++++++++ bench/scripts/point-light-mask-verdict.mjs | 57 + 2 files changed, 2965 insertions(+) create mode 100644 bench/point-light-oracle.html create mode 100644 bench/scripts/point-light-mask-verdict.mjs diff --git a/bench/point-light-oracle.html b/bench/point-light-oracle.html new file mode 100644 index 00000000..0672c9dd --- /dev/null +++ b/bench/point-light-oracle.html @@ -0,0 +1,2908 @@ + + + + + point-light oracle — polycss vs three.js (point + directional lights) + + + +

PolyCSS

+

Three.js

+

Diff (PolyCSS − Three.js)

+

Misclassified polys

+
+ + + + + + diff --git a/bench/scripts/point-light-mask-verdict.mjs b/bench/scripts/point-light-mask-verdict.mjs new file mode 100644 index 00000000..10b76ec0 --- /dev/null +++ b/bench/scripts/point-light-mask-verdict.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +/** + * Shadow-MASK parity verdict — the better oracle. + * + * Instead of a luma diff with a tolerance knob, this drives both engines + * into "shadows only" mode (PolyCSS hides meshes → shadow SVGs only; + * three.js → ShadowMaterial), reduces each REAL render to a binary + * "in shadow?" mask, and compares shadow SHAPE: missing (three shadowed, + * PolyCSS lit = a hole), extra (PolyCSS shadowed, three lit). three.js + * stays the reference; only the comparison method changes. + * + * Writes illustration PNGs (diff classification + the on-render green + * occluder outlines) next to the JSON verdict. + * + * Usage: + * node bench/scripts/shadow-mask-verdict.mjs "" [outPrefix] + * + * Server must be running on :4400. + */ +import { chromium } from 'playwright'; +import { writeFileSync } from 'node:fs'; + +const url = process.argv[2]; +if (!url) { console.error('usage: shadow-mask-verdict.mjs [outPrefix]'); process.exit(2); } +const prefix = process.argv[3] ?? '/tmp/shadow-mask'; + +const browser = await chromium.launch({ args: ['--use-angle=metal', '--enable-gpu-rasterization'] }); +const ctx = await browser.newContext({ viewport: { width: 1500, height: 950 }, deviceScaleFactor: 2 }); +const page = await ctx.newPage(); +page.on('pageerror', e => console.error('[pageerror]', e.message)); +await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 }); +await page.waitForTimeout(6500); + +await page.evaluate(() => window.oracleSetHelpersHidden(true)); +// Shadows-only mode for clean binary masks on both engines. +await page.evaluate(() => window.oracleSetShadowsOnly(true)); +await page.waitForTimeout(800); // SVG recolor + three ShadowMaterial render + +const shot = await (await page.$('#poly-host')).screenshot(); +const json = await page.evaluate( + d => window.oracleMaskCompare(d), + 'data:image/png;base64,' + shot.toString('base64'), +); + +// Illustration 1: the diff classification (red=missing, blue=extra). +await (await page.$('.stage.diff')).screenshot({ path: `${prefix}-diff.png` }); +// Illustration 2: back to normal render so the green occluder outlines show. +await page.evaluate(() => window.oracleSetShadowsOnly(false)); +await page.evaluate(() => window.oracleSetHelpersHidden(true)); +await page.waitForTimeout(400); +await (await page.$('.stage.polycss')).screenshot({ path: `${prefix}-render.png` }); +await (await page.$('.stage.three')).screenshot({ path: `${prefix}-three.png` }); + +writeFileSync(`${prefix}.json`, JSON.stringify(json, null, 2)); +console.log(JSON.stringify(json, null, 2)); +console.error(`wrote ${prefix}-diff.png ${prefix}-render.png ${prefix}-three.png ${prefix}.json`); +await browser.close(); From 59d5c4ab5da58bb256770a0bfb75c5544a949c52 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 19 Jun 2026 03:08:21 +0200 Subject: [PATCH 04/21] feat(react,vue): mirror direction-only point-light shading in baked atlas builders --- packages/react/src/scene/PolyMesh.tsx | 19 ++++++++++++++++++- packages/react/src/scene/PolyScene.tsx | 14 ++++++++++++-- packages/react/src/scene/sceneContext.ts | 2 ++ packages/vue/src/scene/PolyMesh.ts | 24 ++++++++++++++++++++++++ packages/vue/src/scene/PolyScene.ts | 11 +++++++++++ packages/vue/src/scene/sceneContext.ts | 2 ++ 6 files changed, 69 insertions(+), 3 deletions(-) diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx index 24af3a64..2f35dca8 100644 --- a/packages/react/src/scene/PolyMesh.tsx +++ b/packages/react/src/scene/PolyMesh.tsx @@ -643,6 +643,22 @@ export const PolyMesh = forwardRef(function PolyM }; }, [effectiveDirectional, bakedRotation]); + // Point lights → mesh-local frame (subtract mesh position, inverse-rotate), + // mirroring vanilla's localPointLightsForEntry. The atlas plan applies the + // CSS axis-swap itself, so we pass mesh-local USER coords here. + const bakedPointLights = useMemo(() => { + const pls = sceneCtx?.pointLights; + if (!pls || pls.length === 0) return undefined; + const pos = (position ?? [0, 0, 0]) as Vec3; + const rot = bakedRotation ?? ([0, 0, 0] as Vec3); + const hasRot = rot[0] !== 0 || rot[1] !== 0 || rot[2] !== 0; + return pls.map((pl) => { + const rel: Vec3 = [pl.position[0] - pos[0], pl.position[1] - pos[1], pl.position[2] - pos[2]]; + const local = hasRot ? inverseRotateVec3(rel, rot) : rel; + return { ...pl, position: local }; + }); + }, [sceneCtx?.pointLights, position, bakedRotation]); + // Per-light occlusion raytrace (task #121) used to mark polygons in // ray-traced shadow with `directScale=0` so they baked at ambient-only. // Three.js doesn't bake shadow into the diffuse atlas — the real shadow @@ -680,6 +696,7 @@ export const PolyMesh = forwardRef(function PolyM i, { directionalLight: bakedDirectional, + pointLights: bakedPointLights, ambientLight: effectiveAmbient, seamBleed: seamBleedEdges?.has(i) ? effectiveSeamBleed : undefined, seamEdges: seamBleedEdges?.get(i), @@ -689,7 +706,7 @@ export const PolyMesh = forwardRef(function PolyM basisHints[i], )); }, - [renderPolygon, directVoxelEnabled, polygons, bakedDirectional, effectiveAmbient, effectiveSeamBleed, lightOccludedPolyIndices], + [renderPolygon, directVoxelEnabled, polygons, bakedDirectional, bakedPointLights, effectiveAmbient, effectiveSeamBleed, lightOccludedPolyIndices], ); const textureAtlas = useTextureAtlas( atlasPlans, diff --git a/packages/react/src/scene/PolyScene.tsx b/packages/react/src/scene/PolyScene.tsx index a8da0598..66767d7c 100644 --- a/packages/react/src/scene/PolyScene.tsx +++ b/packages/react/src/scene/PolyScene.tsx @@ -3,6 +3,7 @@ import type { CSSProperties, ReactNode } from "react"; import type { Polygon, PolyDirectionalLight, + PolyPointLight, PolyAmbientLight, PolyTextureBackend, PolyTextureImageRendering, @@ -62,6 +63,9 @@ export interface PolySceneProps extends TransformProps { rotY?: number; zoom?: number; directionalLight?: PolyDirectionalLight; + /** Point lights (world-space positions). Direction-only per-face Lambert, + * baked-mode only. */ + pointLights?: PolyPointLight[]; ambientLight?: PolyAmbientLight; /** Textured polygon lighting mode. Defaults to "baked". */ textureLighting?: PolyTextureLightingMode; @@ -121,6 +125,7 @@ function PolySceneInner({ rotY: _rotY, zoom: _zoom, directionalLight, + pointLights, ambientLight, textureLighting = "baked", textureQuality, @@ -205,17 +210,21 @@ function PolySceneInner({ // a light slider. const directionalForAtlas = textureLighting === "dynamic" ? undefined : directionalLight; const ambientForAtlas = textureLighting === "dynamic" ? undefined : ambientLight; + // Scene-level polygons sit at world origin (no mesh transform), so point + // lights pass through in world coords. Baked-mode only. + const pointLightsForAtlas = textureLighting === "dynamic" ? undefined : pointLights; const polyContext = useMemo(() => { const tileSize = 50; return { tileSize, layerElevation: tileSize, directionalLight: directionalForAtlas, + pointLights: pointLightsForAtlas, ambientLight: ambientForAtlas, textureLighting, seamBleed, }; - }, [directionalForAtlas, ambientForAtlas, textureLighting, seamBleed]); + }, [directionalForAtlas, pointLightsForAtlas, ambientForAtlas, textureLighting, seamBleed]); // Bbox center of all auto-centerable meshes in world coords. Kept as a Vec3 // so it can be added to `target` inside the scene transform — same @@ -485,6 +494,7 @@ function PolySceneInner({ () => ({ textureLighting, directionalLight, + pointLights, ambientLight, strategies, seamBleed, @@ -501,7 +511,7 @@ function PolySceneInner({ groundCssZ, sceneEl, }), - [textureLighting, directionalLight, ambientLight, strategies, seamBleed, textureLeafSizing, textureImageRendering, textureBackend, textureProjection, shadow, registerShadowCaster, registerShadowReceiver, shadowCastersVersion, hasShadowReceiver, groundCssZ, sceneEl], + [textureLighting, directionalLight, pointLights, ambientLight, strategies, seamBleed, textureLeafSizing, textureImageRendering, textureBackend, textureProjection, shadow, registerShadowCaster, registerShadowReceiver, shadowCastersVersion, hasShadowReceiver, groundCssZ, sceneEl], ); return ( diff --git a/packages/react/src/scene/sceneContext.ts b/packages/react/src/scene/sceneContext.ts index eb1f2717..690de974 100644 --- a/packages/react/src/scene/sceneContext.ts +++ b/packages/react/src/scene/sceneContext.ts @@ -9,6 +9,7 @@ import { createContext, useContext } from "react"; import type { PolyAmbientLight, PolyDirectionalLight, + PolyPointLight, PolyTextureBackend, PolyTextureImageRendering, PolyTextureLeafSizing, @@ -52,6 +53,7 @@ export interface ShadowOptions { export interface PolySceneContextValue { textureLighting: PolyTextureLightingMode; directionalLight?: PolyDirectionalLight; + pointLights?: PolyPointLight[]; ambientLight?: PolyAmbientLight; strategies?: PolyRenderStrategiesOption; seamBleed?: PolySeamBleed; diff --git a/packages/vue/src/scene/PolyMesh.ts b/packages/vue/src/scene/PolyMesh.ts index 09f6ebf0..59715981 100644 --- a/packages/vue/src/scene/PolyMesh.ts +++ b/packages/vue/src/scene/PolyMesh.ts @@ -320,6 +320,7 @@ export const PolyMesh = defineComponent({ // Always forward the scene's lights to atlas plan, including in dynamic // mode (vanilla parity — see React PolyMesh comment). const atlasDirectional = computed(() => sceneCtx?.value.directionalLight); + const atlasPointLights = computed(() => sceneCtx?.value.pointLights); const atlasAmbient = computed(() => sceneCtx?.value.ambientLight); // voxelSource comes from useMesh (when src is set) OR from the prop // (when polygons array is provided directly). Vanilla scene.add receives @@ -382,6 +383,28 @@ export const PolyMesh = defineComponent({ return { ...cssLight, direction: inverseRotateVec3(cssLight.direction, bakedRotation.value) }; }); + // Point lights converted to mesh-local USER coords (plan.ts applies the + // CSS x↔y swap). Mirrors bakedDirectional + vanilla's + // localPointLightsForEntry: subtract mesh position, then inverse-rotate + // into the mesh's local frame so per-face Lambert matches the rendered + // orientation. + const bakedPointLights = computed(() => { + const pls = atlasPointLights.value; + if (!pls || pls.length === 0) return undefined; + const pos = (props.position ?? [0, 0, 0]) as Vec3; + const rot = bakedRotation.value ?? ([0, 0, 0] as Vec3); + const hasRot = rot[0] !== 0 || rot[1] !== 0 || rot[2] !== 0; + return pls.map((pl) => { + const rel: Vec3 = [ + pl.position[0] - pos[0], + pl.position[1] - pos[1], + pl.position[2] - pos[2], + ]; + const local = hasRot ? inverseRotateVec3(rel, rot) : rel; + return { ...pl, position: local }; + }); + }); + // Per-light occlusion raytrace (task #121) used to mark polygons in // ray-traced shadow with `directScale=0` so they baked at ambient-only. // Three.js doesn't bake shadow into the diffuse atlas — the real shadow @@ -417,6 +440,7 @@ export const PolyMesh = defineComponent({ i, { directionalLight: bakedDirectional.value, + pointLights: bakedPointLights.value, ambientLight: atlasAmbient.value, seamBleed: seamBleedEdges?.has(i) ? atlasSeamBleed.value : undefined, seamEdges: seamBleedEdges?.get(i), diff --git a/packages/vue/src/scene/PolyScene.ts b/packages/vue/src/scene/PolyScene.ts index a939f447..f2caaebc 100644 --- a/packages/vue/src/scene/PolyScene.ts +++ b/packages/vue/src/scene/PolyScene.ts @@ -23,6 +23,7 @@ import type { PropType } from "vue"; import type { Polygon, PolyDirectionalLight, + PolyPointLight, PolyAmbientLight, PolyTextureBackend, PolyTextureImageRendering, @@ -72,6 +73,7 @@ export interface PolySceneProps { rotY?: number; zoom?: number; directionalLight?: PolyDirectionalLight; + pointLights?: PolyPointLight[]; ambientLight?: PolyAmbientLight; textureLighting?: PolyTextureLightingMode; /** Atlas bitmap budget and CSS sprite size. `"auto"` (default) uses a @@ -129,6 +131,10 @@ export const PolyScene = defineComponent({ type: Object as PropType, default: undefined, }, + pointLights: { + type: Array as PropType, + default: undefined, + }, ambientLight: { type: Object as PropType, default: undefined, @@ -217,6 +223,7 @@ export const PolyScene = defineComponent({ const sceneCtxValue = computed(() => ({ textureLighting: props.textureLighting ?? "baked", directionalLight: props.directionalLight, + pointLights: props.pointLights, ambientLight: props.ambientLight, strategies: props.strategies, seamBleed: props.seamBleed ?? DEFAULT_SEAM_BLEED, @@ -314,6 +321,9 @@ export const PolyScene = defineComponent({ const dynamic = props.textureLighting === "dynamic"; const directionalForAtlas = dynamic ? undefined : props.directionalLight; const ambientForAtlas = dynamic ? undefined : props.ambientLight; + // Scene-level polygons sit at world origin, so point lights pass through + // in world coords. Baked-mode only. + const pointLightsForAtlas = dynamic ? undefined : props.pointLights; const repairEdges = buildTextureEdgeRepairSets(sceneResult.value.polygons); const seamBleed = props.seamBleed ?? DEFAULT_SEAM_BLEED; const seamBleedEdges = seamBleed === "auto" || ( @@ -333,6 +343,7 @@ export const PolyScene = defineComponent({ tileSize: polyContext.value.tileSize, layerElevation: polyContext.value.layerElevation, directionalLight: directionalForAtlas, + pointLights: pointLightsForAtlas, ambientLight: ambientForAtlas, seamBleed: seamBleedEdges?.has(i) ? seamBleed : undefined, seamEdges: seamBleedEdges?.get(i), diff --git a/packages/vue/src/scene/sceneContext.ts b/packages/vue/src/scene/sceneContext.ts index ad9c8b2b..d1a7904b 100644 --- a/packages/vue/src/scene/sceneContext.ts +++ b/packages/vue/src/scene/sceneContext.ts @@ -9,6 +9,7 @@ import { inject, type ComputedRef, type InjectionKey, type Ref } from "vue"; import type { PolyAmbientLight, PolyDirectionalLight, + PolyPointLight, PolyTextureBackend, PolyTextureImageRendering, PolyTextureLeafSizing, @@ -65,6 +66,7 @@ export interface PolyReceiverRegistry { export interface PolySceneContextValue { textureLighting: PolyTextureLightingMode; directionalLight?: PolyDirectionalLight; + pointLights?: PolyPointLight[]; ambientLight?: PolyAmbientLight; strategies?: PolyRenderStrategiesOption; seamBleed?: PolySeamBleed; From a183d980b01fdc745099e71473d871fba6974271 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 19 Jun 2026 03:51:38 +0200 Subject: [PATCH 05/21] feat(core,polycss): radial point-light cast shadows (per-light receiver passes) --- bench/point-light-oracle.html | 70 +++++++++++---- packages/core/src/index.ts | 2 + .../core/src/shadow/computeReceiverShadows.ts | 87 +++++++++++++++++-- packages/core/src/shadow/projection.test.ts | 42 +++++++++ packages/core/src/shadow/projection.ts | 53 +++++++++++ packages/polycss/src/api/createPolyScene.ts | 48 +++++++++- .../polycss/src/api/scene/receiverShadow.ts | 58 +++++++++---- 7 files changed, 315 insertions(+), 45 deletions(-) diff --git a/bench/point-light-oracle.html b/bench/point-light-oracle.html index 0672c9dd..7718b6f4 100644 --- a/bench/point-light-oracle.html +++ b/bench/point-light-oracle.html @@ -154,8 +154,8 @@ // Two point lights (world-space positions). Direction-only per-face // Lambert (no falloff) — emulated in three.js by PointLight(...,0,0). points: [ - { x: qn("p0x", -4), y: qn("p0y", 4), z: qn("p0z", 5), intensity: qn("p0i", 1), color: qs("p0c", "#ff7755"), on: qs("p0", "1") !== "0" }, - { x: qn("p1x", 5), y: qn("p1y", -3), z: qn("p1z", 4), intensity: qn("p1i", 1), color: qs("p1c", "#5599ff"), on: qs("p1", "1") !== "0" }, + { x: qn("p0x", -4), y: qn("p0y", 4), z: qn("p0z", 5), intensity: qn("p0i", 1), color: qs("p0c", "#ff7755"), on: qs("p0", "1") !== "0", shadow: qs("sh0", "1") !== "0" }, + { x: qn("p1x", 5), y: qn("p1y", -3), z: qn("p1z", 4), intensity: qn("p1i", 1), color: qs("p1c", "#5599ff"), on: qs("p1", "1") !== "0", shadow: qs("sh1", "1") !== "0" }, ], amb: { intensity: qn("ai", 0.3), color: qs("ac", "#ffffff") }, shadow:{ opacity: qn("so", 1.0) }, @@ -164,6 +164,11 @@ // shadow elements remain visible. Lets you compare shadow geometry // between engines without the mesh getting in the way. shadowsOnly: qs("sx", "0") === "1", + // Floor-only shadow comparison (see the shadowsOnly material swap): + // hides caster bodies in three so only the received floor shadow is + // masked. Use for convex casters where three's shadow-map self-shadows + // the body back-faces but PolyCSS (geometric) correctly does not. + floorOnly: qs("fo", "0") === "1", // "Self-shadow" toggle: when true, the active mesh receives shadows // onto its own faces (matches Three.js mesh.receiveShadow on the // mesh itself). When false, the mesh only casts onto the floor. @@ -190,12 +195,26 @@ autoCenter: qs("ax", "0") === "1", }; + // Directional light for PolyCSS — omitted entirely when intensity ≤ 0 so + // its (intensity-independent) cast-shadow SVG doesn't appear while three.js + // shows nothing. Lets `di=0` isolate the point-light shadows for parity. + function polyDirectional() { + if (S.dir.intensity <= 0) return undefined; + return { + direction: [S.dir.x, S.dir.y, S.dir.z], + intensity: S.dir.intensity, + color: S.dir.color, + castShadow: true, + }; + } + // PolyCSS point lights (world positions) from enabled S.points. function polyPointLights() { return S.points.filter((p) => p.on).map((p) => ({ position: [p.x, p.y, p.z], color: p.color, intensity: p.intensity, + castShadow: p.shadow, })); } @@ -208,12 +227,7 @@ // Bench-only: always emit data-poly-shadow-* attribution attrs so the // oracle diff can map mis-rendered pixels back to source polys. debugShadowAttrs: true, - directionalLight: { - direction: [S.dir.x, S.dir.y, S.dir.z], - intensity: S.dir.intensity, - color: S.dir.color, - castShadow: true, - }, + directionalLight: polyDirectional(), pointLights: polyPointLights(), ambientLight: { color: S.amb.color, intensity: S.amb.intensity }, // lift=0 so the shadow projects exactly onto the floor plane (z=0) @@ -721,6 +735,13 @@ // face, an accepted approximation). One per S.points entry. const threePointLights = S.points.map(() => { const pl = new THREE.PointLight(0xffffff, 1, 0, 0); + // Cube shadow map for the point light. near/far cover the bench scene; + // bias trims acne on the floor. PolyCSS projects radial SVG paths, so + // this is the parity oracle's reference shadow. + pl.shadow.mapSize.set(2048, 2048); + pl.shadow.camera.near = 0.1; + pl.shadow.camera.far = 200; + pl.shadow.bias = -0.0015; threeScene.add(pl); return pl; }); @@ -728,6 +749,7 @@ S.points.forEach((p, i) => { const pl = threePointLights[i]; pl.visible = !!p.on; + pl.castShadow = !!p.shadow; pl.position.set(p.x, p.y, p.z); pl.color.set(p.color); pl.intensity = p.intensity; @@ -982,12 +1004,7 @@ // 1-screen-pixel target lift = 1 / zoom world units. const shadowLift = 1 / Math.max(1, S.cam.zoom); polyScene.setOptions({ - directionalLight: { - direction: [S.dir.x, S.dir.y, S.dir.z], - intensity: S.dir.intensity, - color: S.dir.color, - castShadow: true, - }, + directionalLight: polyDirectional(), pointLights: polyPointLights(), ambientLight: { color: S.amb.color, intensity: S.amb.intensity }, textureLighting: S.lighting, @@ -1104,6 +1121,7 @@ // scene shows only the shadow-map contribution. Original // materials are restored when the toggle is turned off. const _origThreeMats = new WeakMap(); + const _origReceive = new WeakMap(); function applyShadowsOnly() { for (const m of polyHost.querySelectorAll(".polycss-mesh")) { // display:none beats visibility:hidden here — leaves inside the @@ -1200,14 +1218,32 @@ path.after(wrap); } } - const threeObjs = [cubeMesh, eGroup, cottageGroup, castleGroup, coliseumGroup, appleGroup, flightGroup, extinguisherGroup, floorMesh].filter(Boolean); + const casterObjs = [cubeMesh, eGroup, cottageGroup, castleGroup, coliseumGroup, appleGroup, flightGroup, extinguisherGroup].filter(Boolean); + const threeObjs = [...casterObjs, floorMesh].filter(Boolean); + // Floor-only mode: in shadows-only, give caster BODIES an invisible + // material (still casts into the shadow map via the depth override) so + // only the floor's received shadow is compared. This excludes the + // shadow-MAP self-shadowing of convex caster back-faces, which PolyCSS's + // geometric projection correctly does not reproduce — otherwise those + // back-faces read as a false "missing shadow" hole. Off by default so + // self-shadow-on-body validation (concave meshes) still works. + const casterSet = new Set(); + if (S.floorOnly) for (const c of casterObjs) c.traverse(o => casterSet.add(o)); for (const obj of threeObjs) { obj.traverse(o => { if (!o.material) return; if (S.shadowsOnly) { if (!_origThreeMats.has(o)) _origThreeMats.set(o, o.material); - o.material = new THREE.ShadowMaterial({ color: 0x000000, opacity: S.shadow.opacity }); - o.material.transparent = true; + if (casterSet.has(o)) { + // Floor-only: the caster body writes NO color (and no depth), so + // it's invisible and doesn't occlude the floor shadow behind it, + // while castShadow (shadow-map depth override) is untouched — it + // still casts. Only the floor's received shadow reaches the mask. + o.material = new THREE.MeshBasicMaterial({ colorWrite: false, depthWrite: false }); + } else { + o.material = new THREE.ShadowMaterial({ color: 0x000000, opacity: S.shadow.opacity }); + o.material.transparent = true; + } } else if (_origThreeMats.has(o)) { o.material = _origThreeMats.get(o); _origThreeMats.delete(o); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d8cd422e..2d0c9211 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -179,8 +179,10 @@ export { convexHull2D, ensureCcw2D, isBakedShadowCaster, + isPointShadowCaster, polygonSignedArea2D, projectCssVertexToGround, + projectCssVertexToGroundFromPoint, } from "./shadow/projection"; export { clipPolygonToConvex2D } from "./shadow/clipping"; export { diff --git a/packages/core/src/shadow/computeReceiverShadows.ts b/packages/core/src/shadow/computeReceiverShadows.ts index 0e98cbd4..c44e57c3 100644 --- a/packages/core/src/shadow/computeReceiverShadows.ts +++ b/packages/core/src/shadow/computeReceiverShadows.ts @@ -442,8 +442,15 @@ export interface ComputeReceiverShadowFacesInput { receiverHasTexture: boolean; /** Per-caster items + caller id, in caller-defined order. */ casters: ReceiverCasterInput[]; - /** Directional light vector in CSS frame. */ + /** Light direction in CSS frame, pointing TOWARD the light (to-source). + * For a point light this is a representative direction (used only for the + * textured-receiver opacity ratio); per-vertex directions come from + * `lightPos`. */ lightDir: Vec3; + /** When set, the light is a point light at this CSS-frame position. The + * shadow projection becomes radial (per-vertex direction from the light) + * and the silhouette fast path is disabled (facing is per-polygon). */ + lightPos?: Vec3; /** Camera cull rotation (rotX/rotY + receiver mesh rotation) so back- * facing receiver faces can be skipped. */ cameraRot: CameraCullRotation; @@ -465,13 +472,27 @@ export function computeReceiverShadowFaces( ): ReceiverShadowFaceSpec[] { const { receiverPlanes, receiverPolygons, receiverHasTexture, casters, - lightDir, cameraRot, ambientLight, directionalLight, shadow, + lightDir, lightPos, cameraRot, ambientLight, directionalLight, shadow, } = input; const llen = Math.hypot(lightDir[0], lightDir[1], lightDir[2]) || 1; const Lx = lightDir[0] / llen; const Ly = lightDir[1] / llen; const Lz = lightDir[2] / llen; + // Per-point to-source direction. Directional lights ignore the argument + // and return the constant `[Lx, Ly, Lz]` so the directional code path is + // byte-identical; point lights return the normalized vector from the + // shading point toward the light position. + const isPoint = !!lightPos; + const dirAt = isPoint + ? (p: Vec3): Vec3 => { + const dx = lightPos![0] - p[0]; + const dy = lightPos![1] - p[1]; + const dz = lightPos![2] - p[2]; + const len = Math.hypot(dx, dy, dz) || 1; + return [dx / len, dy / len, dz / len]; + } + : (_p: Vec3): Vec3 => [Lx, Ly, Lz]; const ambColor = ambientLight?.color ?? "#ffffff"; const ambIntensity = ambientLight?.intensity ?? 0.4; const dirIntensity = directionalLight?.intensity ?? 1; @@ -507,6 +528,35 @@ 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) => { + // 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 + // only produces a thin frame (side faces are edge-on to an overhead + // light, the contact face is coplanar and culled), leaving the interior + // unshadowed. Facing is per-polygon here (each face's normal vs the + // direction to the light position), unlike the directional constant. + if (isPoint) { + if (casterEntry.selfShadowEdgeMap) { doubleSidedByCaster[casterIdx] = true; return null; } + const edgeOwners = casterEntry.edgeOwners; + if (!edgeOwners) return null; + const N = casterEntry.casterPolygonCount ?? 0; + if (N === 0) return null; + const facing: boolean[] = new Array(N).fill(true); + for (const item of casterEntry.items) { + if (item.polygonIndex >= N) continue; + const nrm = item.planeN; + if (!nrm) { facing[item.polygonIndex] = true; continue; } + let cx = 0, cy = 0, cz = 0; + for (const w of item.wv) { cx += w[0]; cy += w[1]; cz += w[2]; } + const inv = item.wv.length > 0 ? 1 / item.wv.length : 0; + const d = dirAt([cx * inv, cy * inv, cz * inv]); + // Caster (inside silhouette) when the normal points away from the + // light, matching classifyFacing's directional convention. + facing[item.polygonIndex] = nrm[0] * d[0] + nrm[1] * d[1] + nrm[2] * d[2] < -1e-6; + } + if (!silhouetteReliable(edgeOwners, facing)) { doubleSidedByCaster[casterIdx] = true; return null; } + return extractSilhouetteLoops(edgeOwners, facing); + } const edgeOwners = casterEntry.edgeOwners; // Self-shadow (caster IS the receiver mesh): use the per-poly path AND // cast double-sided. Imported meshes have occluder faces that point away @@ -600,8 +650,11 @@ export function computeReceiverShadowFaces( const cameraOnlyRot: CameraCullRotation = { rotX: cameraRot.rotX, rotY: cameraRot.rotY }; for (const group of receiverPlanes) { const { O, n, u, v, outlineUv, minU, minV, width, height } = group; - // Back-facing receiver face → can't receive light → skip. - const Ldotn = Lx * n[0] + Ly * n[1] + Lz * n[2]; + // Back-facing receiver face → can't receive light → skip. For point + // lights the representative direction is the to-source vector at the + // face origin. + const faceDir = dirAt(O); + const Ldotn = faceDir[0] * n[0] + faceDir[1] * n[1] + faceDir[2] * n[2]; if (Ldotn <= 1e-6) continue; // Camera back-face cull. SVGs don't honor CSS backface-visibility // reliably, so a back-of-camera receiver-face SVG would still paint. @@ -617,10 +670,17 @@ export function computeReceiverShadowFaces( const VmOx = w[0] - O[0]; const VmOy = w[1] - O[1]; const VmOz = w[2] - O[2]; - const t = (VmOx * n[0] + VmOy * n[1] + VmOz * n[2]) / Ldotn; - const Px = w[0] - t * Lx; - const Py = w[1] - t * Ly; - const Pz = w[2] - t * Lz; + // Directional: constant to-source L, denom = Ldotn. Point: per-vertex + // to-source direction, denom = d·n. Clamp denom to a small positive so + // a grazing point-light vertex projects far (then gets reach/outline + // clipped) instead of dividing by ~0. + const d = isPoint ? dirAt(w) : ([Lx, Ly, Lz] as Vec3); + const denomRaw = isPoint ? d[0] * n[0] + d[1] * n[1] + d[2] * n[2] : Ldotn; + const denom = denomRaw > 1e-3 ? denomRaw : 1e-3; + const t = (VmOx * n[0] + VmOy * n[1] + VmOz * n[2]) / denom; + const Px = w[0] - t * d[0]; + const Py = w[1] - t * d[1]; + const Pz = w[2] - t * d[2]; const dx = Px - O[0]; const dy = Py - O[1]; const dz = Pz - O[2]; @@ -765,7 +825,16 @@ export function computeReceiverShadowFaces( // (grazing the light) still cast — they're the silhouette edge // of a closed mesh. if (item.planeN && !doubleSidedByCaster[casterIdx]) { - const cnDotL = item.planeN[0] * Lx + item.planeN[1] * Ly + item.planeN[2] * Lz; + let lx = Lx, ly = Ly, lz = Lz; + if (isPoint) { + // To-source direction from the caster polygon's centroid. + let cx = 0, cy = 0, cz = 0; + for (const w of item.wv) { cx += w[0]; cy += w[1]; cz += w[2]; } + const inv = item.wv.length > 0 ? 1 / item.wv.length : 0; + const d = dirAt([cx * inv, cy * inv, cz * inv]); + lx = d[0]; ly = d[1]; lz = d[2]; + } + const cnDotL = item.planeN[0] * lx + item.planeN[1] * ly + item.planeN[2] * lz; if (cnDotL > -1e-6) continue; } // Coplanar caster skip. diff --git a/packages/core/src/shadow/projection.test.ts b/packages/core/src/shadow/projection.test.ts index 5fc10fe2..579682db 100644 --- a/packages/core/src/shadow/projection.test.ts +++ b/packages/core/src/shadow/projection.test.ts @@ -5,8 +5,10 @@ import { buildBakedShadowProjectionMatrix, ensureCcw2D, isBakedShadowCaster, + isPointShadowCaster, polygonSignedArea2D, projectCssVertexToGround, + projectCssVertexToGroundFromPoint, } from "./projection"; describe("buildBakedShadowProjectionMatrix", () => { @@ -121,6 +123,46 @@ describe("projectCssVertexToGround", () => { }); }); +describe("projectCssVertexToGroundFromPoint", () => { + it("projects radially: a point halfway between light and ground doubles its offset", () => { + // Light directly above origin at z=100; vertex at (10,0,50) sits halfway + // down. The shadow ray from (0,0,100) through (10,0,50) hits z=0 at + // x=20 (similar triangles: 10 grows to 20 as height halves to zero). + const p = projectCssVertexToGroundFromPoint([10, 0, 50], [0, 0, 100], 0); + expect(p).not.toBeNull(); + expect(p![0]).toBeCloseTo(20, 6); + expect(p![1]).toBeCloseTo(0, 6); + }); + + it("returns the vertex XY when the vertex sits on the ground plane", () => { + const p = projectCssVertexToGroundFromPoint([10, 20, 0], [0, 0, 100], 0); + expect(p).not.toBeNull(); + expect(p![0]).toBeCloseTo(10, 6); + expect(p![1]).toBeCloseTo(20, 6); + }); + + it("returns null when the vertex is above the light (no forward intersection)", () => { + // Light at z=10, vertex at z=50 → ground (z=0) is on the light's side. + expect(projectCssVertexToGroundFromPoint([5, 5, 50], [0, 0, 10], 0)).toBeNull(); + }); + + it("returns null when the shadow ray runs parallel to the ground", () => { + expect(projectCssVertexToGroundFromPoint([5, 5, 50], [0, 0, 50], 0)).toBeNull(); + }); +}); + +describe("isPointShadowCaster", () => { + it("is true when the face normal points away from the light", () => { + // Light above at z=100; face centroid below it with normal pointing down + // (away from the light) → casts. + expect(isPointShadowCaster([0, 0, 0], [0, 0, -1], [0, 0, 100])).toBe(true); + }); + + it("is false when the face normal points toward the light", () => { + expect(isPointShadowCaster([0, 0, 0], [0, 0, 1], [0, 0, 100])).toBe(false); + }); +}); + describe("polygonSignedArea2D", () => { it("returns +1 for a unit square in CCW order", () => { expect(polygonSignedArea2D([[0, 0], [1, 0], [1, 1], [0, 1]])).toBeCloseTo(1, 9); diff --git a/packages/core/src/shadow/projection.ts b/packages/core/src/shadow/projection.ts index f706f98d..90d4dbd7 100644 --- a/packages/core/src/shadow/projection.ts +++ b/packages/core/src/shadow/projection.ts @@ -53,6 +53,59 @@ export function buildBakedShadowProjectionMatrix( ]; } +/** + * Radial variant of `projectCssVertexToGround` for a point light at a fixed + * CSS-frame position. The shadow ray travels FROM the light THROUGH the + * vertex and onto the ground plane `z = groundCssZ`, so each vertex projects + * along its own direction (a true perspective projection) rather than the + * single parallel direction a directional light uses. + * + * Returns `null` when no valid forward intersection exists — the vertex sits + * on the light's side of the ground plane, or the ray runs parallel to it. + * The caller drops that vertex (and any polygon that loses ≥1 vertex falls + * back to a degenerate projection it can skip). + * + * `lightPos` and `cssVertex` are both in the dimensionless CSS frame (after + * the world→CSS axis swap + tile scale), matching `projectCssVertexToGround`. + */ +export function projectCssVertexToGroundFromPoint( + cssVertex: Vec3, + lightPos: Vec3, + groundCssZ: number, +): [number, number] | null { + const dz = cssVertex[2] - lightPos[2]; + // Ray parallel to the ground plane → never lands on it. + if (Math.abs(dz) < 1e-9) return null; + // point = vertex + s · (vertex − light); solve for ground z. + const s = (groundCssZ - cssVertex[2]) / dz; + // s < 0 means the ground is between the light and the vertex (or behind + // the light) — no cast shadow from this vertex. s = 0 is the vertex + // already sitting on the ground plane (projects to itself). + if (s < 0) return null; + return [ + cssVertex[0] + s * (cssVertex[0] - lightPos[0]), + cssVertex[1] + s * (cssVertex[1] - lightPos[1]), + ]; +} + +/** + * Point-light variant of `isBakedShadowCaster`. A polygon casts shadow when + * its outward normal points away from the light — i.e. the ray from the + * light to the polygon centroid runs into the back of the face. `centroid` + * and `lightPos` are CSS-frame; `normal` is the CSS-frame outward normal. + */ +export function isPointShadowCaster( + centroid: Vec3, + normal: Vec3, + lightPos: Vec3, +): boolean { + const dx = centroid[0] - lightPos[0]; + const dy = centroid[1] - lightPos[1]; + const dz = centroid[2] - lightPos[2]; + const len = Math.hypot(dx, dy, dz) || 1; + return (normal[0] * dx + normal[1] * dy + normal[2] * dz) / len > 0; +} + /** * Decides whether a polygon should cast a shadow given its outward * normal and the light's travel direction. diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index 384ff580..abf48ec1 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -78,6 +78,7 @@ import { quantizeNormalKey, worldDirectionToCss, worldDirectionalLightToCss, + worldPositionToCss, } from "./scene/transforms"; import { shadowOptsEqual, @@ -979,7 +980,21 @@ export function createPolyScene( // frame. invalidateShadowLightCache() is called by every code path that // mutates caster/receiver geometry or shadow appearance, so a cache hit // here means "same light, same scene → previous SVG content is still valid". - const lightKey = quantizeLightDirKey(lightDir); + // Shadow-casting point lights, converted to CSS-frame positions. Only + // lights with castShadow:true project (Three.js parity); shading-only + // point lights never reach the shadow path. + const shadowPointLights = (currentOptions.pointLights ?? []).filter((pl) => pl.castShadow); + const cssPointPositions = shadowPointLights.map((pl) => worldPositionToCss(pl.position)); + const runDirectionalShadow = + !!currentOptions.directionalLight?.direction || shadowPointLights.length === 0; + const dirKey = quantizeLightDirKey(lightDir); + // Fold point-light positions into the short-circuit key so moving (or + // toggling) a shadow point light re-emits even when the directional + // vector is unchanged. + const pointKey = cssPointPositions + .map((p) => `${Math.round(p[0])},${Math.round(p[1])},${Math.round(p[2])}`) + .join(";"); + const lightKey = dirKey === null && pointKey === "" ? null : `${dirKey ?? ""}|${pointKey}`; if (lightKey !== null && lightKey === lastEmittedShadowLightKey) return; // Per-caster shadow dedup (independent meshes can't dedup against @@ -1045,7 +1060,31 @@ export function createPolyScene( hideGroundShadow(); for (const receiver of meshes) { if (receiver.disposed || !receiver.receiveShadow) continue; - emitReceiverShadowsImpl(ctx, casters, dedupByCaster, receiver, dedupByReceiver.get(receiver) ?? new Set(), lightDir, r, g, b, shadowOpacity); + const dedup = dedupByReceiver.get(receiver) ?? new Set(); + // Directional pass (mount namespace ""). Runs whenever a directional + // light is configured, or — to preserve the legacy implicit-sun + // shadow — when there are no shadow-casting point lights. A scene with + // ONLY point shadow lights skips this so no phantom default-sun shadow + // appears. + if (runDirectionalShadow) { + emitReceiverShadowsImpl(ctx, casters, dedupByCaster, receiver, dedup, lightDir, r, g, b, shadowOpacity); + } else { + // Ensure any directional SVGs from a prior tick are cleared. + emitReceiverShadowsImpl(ctx, [], dedupByCaster, receiver, dedup, lightDir, r, g, b, shadowOpacity); + } + // One radial pass per shadow-casting point light. Each light projects + // into its own SVG namespace ("p0".."pN") so overlapping shadows stack + // independently — a point in shadow from two lights reads as darker, + // matching multi-shadow-map occlusion. `lightDir` here is only used for + // the textured-receiver opacity ratio; the radial projection uses the + // CSS-frame `lightPos`. + for (let li = 0; li < cssPointPositions.length; li++) { + emitReceiverShadowsImpl( + ctx, casters, dedupByCaster, receiver, dedup, + lightDir, r, g, b, shadowOpacity, + cssPointPositions[li], `p${li}`, + ); + } } lastEmittedShadowLightKey = lightKey; } @@ -2203,7 +2242,10 @@ export function createPolyScene( const nextShadow = currentOptions.shadow; const shadowAppearanceChanged = partial.shadow !== undefined && !shadowOptsEqual(prevShadow, nextShadow); - const shadowReemitNeeded = lightDirChanged || shadowAppearanceChanged; + // Point-light changes also re-emit: the emit short-circuit key folds in + // each shadow point light's CSS position, so a moved/toggled point light + // produces a different key and re-projects its radial shadow. + const shadowReemitNeeded = lightDirChanged || shadowAppearanceChanged || pointLightsChanged; if (textureLightingChanged) { // Every mesh needs a full re-render to swap baked/dynamic leaf // emission. Baked leaves carry inline `color: rgb(...)`; dynamic diff --git a/packages/polycss/src/api/scene/receiverShadow.ts b/packages/polycss/src/api/scene/receiverShadow.ts index f3c3d56a..5e3aa1cd 100644 --- a/packages/polycss/src/api/scene/receiverShadow.ts +++ b/packages/polycss/src/api/scene/receiverShadow.ts @@ -41,7 +41,11 @@ interface MountedFace { /** Last applied matrix3d string — only re-set on change to dodge style work. */ matrixCss: string; } -const mountedFacesByMesh = new WeakMap>(); +// Mounts are namespaced per light so a directional pass and one pass per +// shadow-casting point light can coexist on the same receiver mesh without +// colliding on faceIndex. Outer key: a per-light identity ("" = directional, +// "p0".."pN" = point lights). Inner key: faceIndex. +const mountedFacesByMesh = new WeakMap>>(); /** Cached shared-edge adjacency per mesh. Invalidated when the polygon * array reference changes (cheap identity check, no deep diff). */ @@ -55,12 +59,32 @@ const sharedEdgeMapCacheKey = new WeakMap(); const edgeOwnersCache = new WeakMap>(); const edgeOwnersCacheKey = new WeakMap(); -function mountedFacesFor(entry: MeshEntry): Map { +function lightMapFor(entry: MeshEntry): Map> { let m = mountedFacesByMesh.get(entry); if (!m) { m = new Map(); mountedFacesByMesh.set(entry, m); } return m; } +function mountedFacesFor(entry: MeshEntry, lightKey: string): Map { + const byLight = lightMapFor(entry); + let m = byLight.get(lightKey); + if (!m) { m = new Map(); byLight.set(lightKey, m); } + return m; +} + +/** Detach + clear every mounted face across every light namespace for a mesh. */ +function detachAllFaces(entry: MeshEntry): void { + const byLight = mountedFacesByMesh.get(entry); + if (!byLight) return; + for (const faces of byLight.values()) { + for (const face of faces.values()) { + if (face.svg && face.svg.parentNode) face.svg.parentNode.removeChild(face.svg); + } + faces.clear(); + } + byLight.clear(); +} + /** * Detach every receiver-shadow SVG previously mounted for this mesh and * clear the local mount bookkeeping. Call when a mesh stops being a @@ -69,12 +93,7 @@ function mountedFacesFor(entry: MeshEntry): Map { * a receiver would linger in the DOM. */ export function disposeReceiverShadowMounts(entry: MeshEntry): void { - const mounted = mountedFacesByMesh.get(entry); - if (!mounted) return; - for (const face of mounted.values()) { - if (face.svg && face.svg.parentNode) face.svg.parentNode.removeChild(face.svg); - } - mounted.clear(); + detachAllFaces(entry); mountedFacesByMesh.delete(entry); } @@ -98,6 +117,11 @@ export function emitReceiverShadows( lightDir: Vec3, _r: number, _g: number, _b: number, opacity: number, + /** When set, the light is a point light at this CSS-frame position; the + * projection becomes radial. */ + lightPos?: Vec3, + /** Per-light SVG-mount namespace ("" = directional). */ + lightKey = "", ): void { const options = ctx.options.current; const { receiverShadowCache, receiverShadowCacheKey, casterItemsCache, casterItemsCacheKey } = ctx; @@ -122,12 +146,8 @@ export function emitReceiverShadows( receiverShadowCache.set(receiverEntry, cachedPlanes); receiverShadowCacheKey.set(receiverEntry, cacheKey); // Reset mounted state when the plane list changes (face indices may - // have shifted). Detach any orphan SVGs. - const mounted = mountedFacesFor(receiverEntry); - for (const face of mounted.values()) { - if (face.svg && face.svg.parentNode) face.svg.parentNode.removeChild(face.svg); - } - mounted.clear(); + // have shifted). Detach any orphan SVGs across every light namespace. + detachAllFaces(receiverEntry); } // Per-caster items. @@ -176,8 +196,12 @@ export function emitReceiverShadows( // path. Skip on self-shadow (caster IS receiver — the per-poly path // is geometrically required there) and on tiny meshes (silhouette // overhead exceeds the per-poly cost below ~40 polys). + // Point-light passes always need edgeOwners: the radial shadow projects + // the caster silhouette (not per-face back-faces), so even small meshes + // (a 6-quad cube) require the outline. Directional keeps the ≥40-poly + // gate where the per-poly path is cheaper. let edgeOwners: ReadonlyMap | undefined; - if (caster !== receiverEntry && caster.polygons.length >= 40) { + if (caster !== receiverEntry && (caster.polygons.length >= 40 || lightPos)) { let cachedOwners = edgeOwnersCache.get(caster); if (cachedOwners === undefined || edgeOwnersCacheKey.get(caster) !== ckey) { cachedOwners = prepareCasterEdgeOwners( @@ -212,6 +236,7 @@ export function emitReceiverShadows( receiverHasTexture: hasTexture, casters: casterInputs, lightDir, + lightPos, cameraRot, ambientLight: options.ambientLight, directionalLight: options.directionalLight, @@ -220,7 +245,7 @@ export function emitReceiverShadows( // Mount/update SVGs from specs. Faces NOT in specs (back-facing, no // shadow content, etc.) get hidden. - const mounted = mountedFacesFor(receiverEntry); + const mounted = mountedFacesFor(receiverEntry, lightKey); const seen = new Set(); for (const spec of specs) { seen.add(spec.faceIndex); @@ -235,6 +260,7 @@ export function emitReceiverShadows( svg.setAttribute("class", "polycss-shadow polycss-shadow-svg polycss-shadow-receiver"); if (options.debugShadowAttrs) { svg.setAttribute("data-poly-shadow-type", "receiver"); + if (lightKey) svg.setAttribute("data-poly-shadow-light", lightKey); svg.setAttribute("data-poly-shadow-receiver", meshShadowId(receiverEntry)); svg.setAttribute("data-poly-shadow-receiver-face", String(spec.faceIndex)); svg.setAttribute("data-poly-shadow-receiver-polys", JSON.stringify(spec.memberPolyIndices)); From 990bfc882cbcef6448d0a47faa6e86df545bf035 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 19 Jun 2026 03:55:41 +0200 Subject: [PATCH 06/21] feat(react,vue): mirror radial point-light cast shadows (per-light receiver passes) --- packages/react/src/scene/PolyMesh.tsx | 134 ++++++++++++++++---------- packages/vue/src/scene/PolyMesh.ts | 128 ++++++++++++++---------- 2 files changed, 160 insertions(+), 102 deletions(-) diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx index 2f35dca8..471ced05 100644 --- a/packages/react/src/scene/PolyMesh.tsx +++ b/packages/react/src/scene/PolyMesh.tsx @@ -54,6 +54,7 @@ import { projectCssVertexToGround, resolvePolyTextureLeafGeometry, worldDirectionToCss, + worldPositionToCss, type CameraCullRotation, type EdgeOwners, type ReceiverCasterInput, @@ -978,6 +979,15 @@ export const PolyMesh = forwardRef(function PolyM // vanilla emitGround/emitReceiverShadows pipelines.) const userLightDir = sceneDirectionalLight?.direction ?? ([0.4, -0.7, 0.59] as Vec3); const lightDir = worldDirectionToCss(userLightDir); + // Shadow-casting point lights, converted to CSS-frame absolute positions + // (same world-CSS frame as the caster/receiver vertices). Only lights with + // castShadow:true project (Three.js parity). Mirrors vanilla emitSceneShadows. + const shadowPointLights = (sceneCtx?.pointLights ?? []).filter((pl) => pl.castShadow); + const cssPointPositions = shadowPointLights.map((pl) => worldPositionToCss(pl.position)); + // Directional pass runs when a directional light is configured, or — to + // preserve the implicit-sun shadow — when there are no point shadow lights. + const runDirectionalShadow = !!sceneDirectionalLight?.direction || shadowPointLights.length === 0; + const hasShadowPoints = shadowPointLights.length > 0; const shadowLift = sceneShadow?.lift ?? 0.001; const planes = prepareReceiverFacePlanes( polygons, @@ -1014,8 +1024,12 @@ export const PolyMesh = forwardRef(function PolyM // non-self casters with enough polygons. The cache key matches the // transform fields fed into `prepareCasterPolyItems` so the world- // frame owners stay consistent with the matching items. + // Point-light passes always need edgeOwners (the radial shadow projects + // the caster silhouette, even for a small cube). Directional keeps the + // ≥40-poly gate; core's directional branch ignores edgeOwners below that + // threshold, so providing it here doesn't change directional behavior. let edgeOwners: ReadonlyMap | undefined; - if (!isSelf && data.polygons.length >= 40) { + if (!isSelf && (data.polygons.length >= 40 || hasShadowPoints)) { const dposArr = data.position; const drot = data.rotation ?? null; const dsKey = JSON.stringify(data.scale ?? null); @@ -1042,58 +1056,72 @@ export const PolyMesh = forwardRef(function PolyM rotY: cameraState?.rotY ?? 45, meshRotation: rotation, }; - const specs = computeReceiverShadowFaces({ - receiverPlanes: planes, - receiverPolygons: polygons, - receiverHasTexture: polygons.some((p) => p.texture !== undefined), - casters: casterInputs, - lightDir, - cameraRot, - ambientLight: sceneCtx?.ambientLight, - directionalLight: sceneDirectionalLight, - shadow: { color: sceneShadow?.color, opacity: sceneShadow?.opacity ?? 0.25, maxExtend: sceneShadow?.maxExtend }, - }); - return ( - <> - {specs.map((spec) => ( - - {spec.paths.map((p, i) => ( - - ))} - - ))} - - ); - }, [receiveShadow, shadowCasters, shadowCastersVersion, polygons, position, scale, rotation, sceneDirectionalLight, sceneShadow, sceneCtx?.ambientLight, cameraCtx?.store, cameraTick, selfShadowEdgeMap]); + // One pass per light: directional (namespace "d") + one per shadow point + // light (namespace "p0".."pN"). Each pass projects into its own SVG keys so + // overlapping shadows from different lights stack independently — a point + // shadowed by two lights reads darker, matching multi-shadow-map occlusion. + const runPass = ( + lightKey: string, + passLightDir: Vec3, + lightPos: Vec3 | undefined, + ): ReactNode[] => { + const specs = computeReceiverShadowFaces({ + receiverPlanes: planes, + receiverPolygons: polygons, + receiverHasTexture: polygons.some((p) => p.texture !== undefined), + casters: casterInputs, + lightDir: passLightDir, + lightPos, + cameraRot, + ambientLight: sceneCtx?.ambientLight, + directionalLight: sceneDirectionalLight, + shadow: { color: sceneShadow?.color, opacity: sceneShadow?.opacity ?? 0.25, maxExtend: sceneShadow?.maxExtend }, + }); + return specs.map((spec) => ( + + {spec.paths.map((p, i) => ( + + ))} + + )); + }; + const nodes: ReactNode[] = []; + if (runDirectionalShadow) nodes.push(...runPass("d", lightDir, undefined)); + for (let li = 0; li < cssPointPositions.length; li++) { + nodes.push(...runPass(`p${li}`, lightDir, cssPointPositions[li])); + } + return <>{nodes}; + }, [receiveShadow, shadowCasters, shadowCastersVersion, polygons, position, scale, rotation, sceneDirectionalLight, sceneCtx?.pointLights, sceneShadow, sceneCtx?.ambientLight, cameraCtx?.store, cameraTick, selfShadowEdgeMap]); // Portal receiver shadow SVGs OUT of `.polycss-mesh` into `.polycss-scene`. // The SVG `matrix3d(...)` already includes this mesh's `position` (baked diff --git a/packages/vue/src/scene/PolyMesh.ts b/packages/vue/src/scene/PolyMesh.ts index 59715981..869499cf 100644 --- a/packages/vue/src/scene/PolyMesh.ts +++ b/packages/vue/src/scene/PolyMesh.ts @@ -51,6 +51,7 @@ import { prepareReceiverFacePlanes, projectCssVertexToGround, worldDirectionToCss, + worldPositionToCss, type CameraCullRotation, type EdgeOwners, type ReceiverCasterInput, @@ -656,6 +657,15 @@ export const PolyMesh = defineComponent({ // same frame for the shadow projection math to land correctly. const userLightDir = ctx?.directionalLight?.direction ?? ([0.4, -0.7, 0.59] as Vec3); const lightDir = worldDirectionToCss(userLightDir); + // Shadow-casting point lights → CSS-frame absolute positions (same + // world-CSS frame as caster/receiver vertices). Only castShadow:true + // lights project (Three.js parity). Mirrors vanilla emitSceneShadows. + const shadowPointLights = (ctx?.pointLights ?? []).filter((pl) => pl.castShadow); + const cssPointPositions = shadowPointLights.map((pl) => worldPositionToCss(pl.position)); + // Directional pass runs when a directional light is configured, or — to + // preserve the implicit-sun shadow — when there are no point shadow lights. + const runDirectionalShadow = !!ctx?.directionalLight?.direction || shadowPointLights.length === 0; + const hasShadowPoints = shadowPointLights.length > 0; const shadowLift = ctx?.shadow?.lift ?? 0.001; const planes = prepareReceiverFacePlanes( polygons.value, @@ -691,8 +701,11 @@ export const PolyMesh = defineComponent({ // shadows on smooth GLB meshes — apple, sphere, teapot). // H9 silhouette path: build/reuse world-frame edge owners for // non-self casters with enough polygons. + // Point-light passes always need edgeOwners (radial shadow projects the + // caster silhouette, even for a small cube). Directional keeps the ≥40 + // gate; core's directional branch ignores edgeOwners below that. let edgeOwners: ReadonlyMap | undefined; - if (!isSelf && data.polygons.length >= 40) { + if (!isSelf && (data.polygons.length >= 40 || hasShadowPoints)) { const drot = data.rotation ?? null; const dposArr = data.position; const dsKey = JSON.stringify(data.scale ?? null); @@ -719,55 +732,72 @@ export const PolyMesh = defineComponent({ rotY: cameraState?.rotY ?? 45, meshRotation: props.rotation, }; - const specs = computeReceiverShadowFaces({ - receiverPlanes: planes, - receiverPolygons: polygons.value, - receiverHasTexture: polygons.value.some((p) => p.texture !== undefined), - casters: casterInputs, - lightDir, - cameraRot, - ambientLight: ctx?.ambientLight, - directionalLight: ctx?.directionalLight, - shadow: { color: ctx?.shadow?.color, opacity: ctx?.shadow?.opacity ?? 0.25, maxExtend: ctx?.shadow?.maxExtend }, - }); - return specs.map((spec) => - h( - "svg", - { - key: `receiver-${spec.faceIndex}`, - class: "polycss-shadow polycss-shadow-svg polycss-shadow-receiver", - "data-poly-shadow-type": "receiver", - "data-poly-shadow-receiver-face": String(spec.faceIndex), - "data-poly-shadow-receiver-polys": JSON.stringify(spec.memberPolyIndices), - width: String(spec.width), - height: String(spec.height), - viewBox: `0 0 ${spec.width} ${spec.height}`, - style: { - position: "absolute", - top: "0", - left: "0", - display: "block", - overflow: "hidden", - transformOrigin: "0 0", - pointerEvents: "none", - willChange: "transform", - transform: spec.matrixCss, - } as CSSProperties, - }, - spec.paths.map((p, idx) => - h("path", { - key: idx, - d: p.d, - fill: spec.fill, - stroke: spec.fill, - "stroke-width": "3", - "stroke-linejoin": "round", - opacity: spec.opacity.toFixed(4), - "data-poly-shadow-caster-polys": JSON.stringify(p.casterPolygonIndices), - }), + // One pass per light: directional (namespace "d") + one per shadow point + // light ("p0".."pN"), each into its own SVG keys so overlapping shadows + // stack independently — matching multi-shadow-map occlusion. + const runPass = ( + lightKey: string, + passLightDir: Vec3, + lightPos: Vec3 | undefined, + ): VNode[] => { + const specs = computeReceiverShadowFaces({ + receiverPlanes: planes, + receiverPolygons: polygons.value, + receiverHasTexture: polygons.value.some((p) => p.texture !== undefined), + casters: casterInputs, + lightDir: passLightDir, + lightPos, + cameraRot, + ambientLight: ctx?.ambientLight, + directionalLight: ctx?.directionalLight, + shadow: { color: ctx?.shadow?.color, opacity: ctx?.shadow?.opacity ?? 0.25, maxExtend: ctx?.shadow?.maxExtend }, + }); + return specs.map((spec) => + h( + "svg", + { + key: `receiver-${lightKey}-${spec.faceIndex}`, + class: "polycss-shadow polycss-shadow-svg polycss-shadow-receiver", + "data-poly-shadow-type": "receiver", + "data-poly-shadow-light": lightKey, + "data-poly-shadow-receiver-face": String(spec.faceIndex), + "data-poly-shadow-receiver-polys": JSON.stringify(spec.memberPolyIndices), + width: String(spec.width), + height: String(spec.height), + viewBox: `0 0 ${spec.width} ${spec.height}`, + style: { + position: "absolute", + top: "0", + left: "0", + display: "block", + overflow: "hidden", + transformOrigin: "0 0", + pointerEvents: "none", + willChange: "transform", + transform: spec.matrixCss, + } as CSSProperties, + }, + spec.paths.map((p, idx) => + h("path", { + key: idx, + d: p.d, + fill: spec.fill, + stroke: spec.fill, + "stroke-width": "3", + "stroke-linejoin": "round", + opacity: spec.opacity.toFixed(4), + "data-poly-shadow-caster-polys": JSON.stringify(p.casterPolygonIndices), + }), + ), ), - ), - ); + ); + }; + const nodes: VNode[] = []; + if (runDirectionalShadow) nodes.push(...runPass("d", lightDir, undefined)); + for (let li = 0; li < cssPointPositions.length; li++) { + nodes.push(...runPass(`p${li}`, lightDir, cssPointPositions[li])); + } + return nodes; }); // Register this mesh with the shadow registry when castShadow=true in From e1c9b32440ff3ef3e85ddefa3348ebdf231dd491 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 19 Jun 2026 03:58:49 +0200 Subject: [PATCH 07/21] docs: document pointLights (direction-only shading + radial cast shadows) --- AGENTS.md | 12 ++++++---- README.md | 2 +- packages/core/README.md | 2 +- packages/polycss/README.md | 2 +- packages/react/README.md | 2 +- packages/vue/README.md | 2 +- website/src/content/docs/api/types.mdx | 24 +++++++++++++++++++ .../content/docs/components/poly-scene.mdx | 1 + 8 files changed, 38 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a99c35b5..9cb84d39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,16 +41,20 @@ Strategies are ordered cheapest → most expensive. The mesher's job is to maxim 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 ``, because it belongs to the non-triangle clipped-solid family. `` 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. -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. +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. The directional light projects in parallel; each `pointLights` entry with `castShadow: true` casts an additional **radial** shadow (each vertex projected along its own ray from the light position), and point-light passes always project the caster *silhouette* — projecting individual back-faces leaves the contact footprint unshadowed under radial divergence. Each light renders into its own SVG namespace (`"d"` for directional, `"p0".."pN"` for point lights) so overlapping shadows stack independently rather than colliding. 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. 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. 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 + +The scene takes one `directionalLight`, one `ambientLight`, and zero or more `pointLights` (`PolyPointLight[]`). Point lights are **direction-only** — no distance falloff. Per polygon the contribution is `color · intensity · max(0, n · L̂)`, where `L̂` is the unit direction from the surface to the light position; multiple colored lights accumulate per-channel alongside the directional + ambient terms. This deliberately omits CSS gradients: point lights shade flat-per-face (an accepted approximation vs three.js's per-fragment `PointLight(distance:0, decay:0)`; exact for small faces / distant lights). Point lights are **baked-mode only** — the dynamic mode's zero-JS light move can't express a per-face direction that varies with position, so dynamic scenes ignore `pointLights` for surface shading. + ### Lighting modes (`PolyTextureLightingMode = "baked" | "dynamic"`) -- **Baked.** Lambert is computed once on the CPU per polygon, multiplied into the inline `color` (for ``/``/``) or into the rasterised atlas pixels (for atlas-backed ``). Direct image `` leaves preserve source pixels and use `texturePresentation.lighting="source"`; scene-lit direct images fall back to the atlas path. Moving a light requires explicit re-rasterising of affected lit atlas polys via `mesh.rebakeAtlas()`. Cast shadows are independent SVG projections and can re-emit without atlas redraw, so shadows can follow the light interactively even when lit-side shading stays frozen. -- **Dynamic.** Scene root carries the light setup as custom properties (`--plx/y/z`, `--plr/g/b`, `--pli`, `--par/g/b`, `--pai`). Each leaf embeds its surface normal (`--pnx/y/z`) and base color (`--psr/g/b`) inline. CSS `calc()` resolves the Lambert dot product and per-channel tint at paint time. Moving a light mutates scene-root vars for surface lighting — zero JS, no atlas redraw. Cast shadows still use CPU-projected SVG paths and re-emit when the directional light changes. +- **Baked.** Lambert (directional + each point light + ambient) is computed once on the CPU per polygon, multiplied into the inline `color` (for ``/``/``) or into the rasterised atlas pixels (for atlas-backed ``). Direct image `` leaves preserve source pixels and use `texturePresentation.lighting="source"`; scene-lit direct images fall back to the atlas path. Moving a light requires explicit re-rasterising of affected lit atlas polys via `mesh.rebakeAtlas()`. Cast shadows are independent SVG projections and can re-emit without atlas redraw, so shadows can follow the light interactively even when lit-side shading stays frozen. +- **Dynamic.** Scene root carries the directional + ambient setup as custom properties (`--plx/y/z`, `--plr/g/b`, `--pli`, `--par/g/b`, `--pai`). Each leaf embeds its surface normal (`--pnx/y/z`) and base color (`--psr/g/b`) inline. CSS `calc()` resolves the Lambert dot product and per-channel tint at paint time. Moving a light mutates scene-root vars for surface lighting — zero JS, no atlas redraw. Point lights are not represented in dynamic surface shading (see above). Cast shadows still use CPU-projected SVG paths and re-emit when a directional or point light changes. All solid and atlas-backed tags work in both modes. Direct image `` leaves are source-lit only; callers that need scene lighting use the atlas backend. The `.vox` direct-matrix fast path is baked-only for now; dynamic mode uses the polygon path so lighting semantics stay correct. The full coverage matrix is in `packages/polycss/src/styles/styles.ts`. @@ -89,7 +93,7 @@ If you find yourself wanting a `requestAnimationFrame` loop to update many DOM n - Every public export gets a `Poly` prefix. Exceptions are generic math types: `Vec2`, `Vec3`, `Polygon`, `PolyMaterial` (already prefixed). - **Hooks/composables:** `usePolyCamera`, `usePolyMesh`, `usePolySceneContext`, `usePolySelect`, `usePolySelectionApi`, `usePolyAnimation`. - **Components:** `PolyPerspectiveCamera`, `PolyOrthographicCamera`, `PolyOrbitControls`, `PolyMapControls`, `PolyTransformControls`, `PolySelect`, `PolyAxesHelper`, `PolyDirectionalLightHelper`, `PolyIframe`. -- **Types:** `PolyDirectionalLight`, `PolyAmbientLight`, `PolyTextureLightingMode`, `PolyTextureLeafSizing`, `PolyTextureBackend`, `PolyTextureImageRendering`, `PolyTextureImageLighting`, `PolyTextureProjection`, `PolyTexturePresentation`, `PolyTextureImageSource`, `PolyCameraProjection`, `PolyCameraSnapshot`, `PolyCameraSnapshotStats`, `PolyMeshTransformInput`, `PolySceneTransformInput`, `PolyAnimationMixer`, `PolyRenderStats`. +- **Types:** `PolyDirectionalLight`, `PolyPointLight`, `PolyAmbientLight`, `PolyTextureLightingMode`, `PolyTextureLeafSizing`, `PolyTextureBackend`, `PolyTextureImageRendering`, `PolyTextureImageLighting`, `PolyTextureProjection`, `PolyTexturePresentation`, `PolyTextureImageSource`, `PolyCameraProjection`, `PolyCameraSnapshot`, `PolyCameraSnapshotStats`, `PolyMeshTransformInput`, `PolySceneTransformInput`, `PolyAnimationMixer`, `PolyRenderStats`. - **Functions:** `findPolyMeshHandle`, `injectPolyBaseStyles`, `collectPolyRenderStats`, `collectPolyTextureReadiness`, `queryPolyLeaves`, `resolvePolyTextureLeafGeometry`, `resolvePolyTextureImageSource`, `resolvePolyTexturePresentation`, `resolvePolyTextureImageRendering`, `buildPolyCameraSceneTransform`, `buildPolyMeshTransform`, `buildPolySceneTransform`, `capturePolyCameraSnapshot`, `polyCameraTargetToCss`, `resolvePolyCameraAppliedPerspectiveStyle`, `worldPositionToCss`, `worldPositionToPolyCss`, `cssPositionToWorld`, `polyCssPositionToWorld`, `worldDistanceToCss`, `worldDistanceToPolyCss`, `cssDistanceToWorld`, `polyCssDistanceToWorld`, `worldDirectionToCss`, `worldDirectionToPolyCss`, `worldDirectionalLightToCss`, `worldDirectionalLightToPolyCss`, `exportPolySceneSnapshot`. - **Vanilla factories:** `create*` names stay as-is (`createPolyScene`, `createTransformControls`, `createSelect`). - **HTML custom elements:** `poly-` prefix + kebab-case. Existing tags: ``, ``, ``, ``, ``, ``, ``, ``. Any new element follows the same shape (e.g. ``, ``). diff --git a/README.md b/README.md index 12ca2852..d2f0217c 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ export default function App() { ### PolyScene - `polygons` renders a static `Polygon[]` directly. -- `directionalLight` and `ambientLight` control scene lighting. +- `directionalLight`, `pointLights` (direction-only, baked mode; optional per-light `castShadow`), and `ambientLight` control scene lighting. - `textureLighting` chooses `"baked"` or `"dynamic"`. - `textureQuality` controls atlas raster budget. - `strategies` can disable selected render strategies for diagnostics. diff --git a/packages/core/README.md b/packages/core/README.md index 5b81b765..f4d3bc19 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -85,7 +85,7 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po ### PolyScene - `polygons` renders a static `Polygon[]` directly. -- `directionalLight` and `ambientLight` control scene lighting. +- `directionalLight`, `pointLights` (direction-only, baked mode; optional per-light `castShadow`), and `ambientLight` control scene lighting. - `textureLighting` chooses `"baked"` or `"dynamic"`. - `textureQuality` controls atlas raster budget. - Solid seam bleed is automatic on detected shared solid edges. diff --git a/packages/polycss/README.md b/packages/polycss/README.md index ca7eb4b0..178f4e1d 100644 --- a/packages/polycss/README.md +++ b/packages/polycss/README.md @@ -85,7 +85,7 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po ### PolyScene - `polygons` renders a static `Polygon[]` directly. -- `directionalLight` and `ambientLight` control scene lighting. +- `directionalLight`, `pointLights` (direction-only, baked mode; optional per-light `castShadow`), and `ambientLight` control scene lighting. - `textureLighting` chooses `"baked"` or `"dynamic"`. - `textureQuality` controls atlas raster budget. - Solid seam bleed is automatic on detected shared solid edges. diff --git a/packages/react/README.md b/packages/react/README.md index 5b81b765..f4d3bc19 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -85,7 +85,7 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po ### PolyScene - `polygons` renders a static `Polygon[]` directly. -- `directionalLight` and `ambientLight` control scene lighting. +- `directionalLight`, `pointLights` (direction-only, baked mode; optional per-light `castShadow`), and `ambientLight` control scene lighting. - `textureLighting` chooses `"baked"` or `"dynamic"`. - `textureQuality` controls atlas raster budget. - Solid seam bleed is automatic on detected shared solid edges. diff --git a/packages/vue/README.md b/packages/vue/README.md index 5b81b765..f4d3bc19 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -85,7 +85,7 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po ### PolyScene - `polygons` renders a static `Polygon[]` directly. -- `directionalLight` and `ambientLight` control scene lighting. +- `directionalLight`, `pointLights` (direction-only, baked mode; optional per-light `castShadow`), and `ambientLight` control scene lighting. - `textureLighting` chooses `"baked"` or `"dynamic"`. - `textureQuality` controls atlas raster budget. - Solid seam bleed is automatic on detected shared solid edges. diff --git a/website/src/content/docs/api/types.mdx b/website/src/content/docs/api/types.mdx index 14141067..8bb221f2 100644 --- a/website/src/content/docs/api/types.mdx +++ b/website/src/content/docs/api/types.mdx @@ -93,6 +93,29 @@ interface PolyDirectionalLight { --- +## `PolyPointLight` + +A positional light source. **Direction-only** — there is no distance falloff; +per polygon the contribution is `color · intensity · max(0, n · L̂)` where `L̂` +is the unit direction from the surface to `position`. Shading is flat per face +(an approximation of three.js's `PointLight(distance: 0, decay: 0)`), and is +**baked-mode only** — dynamic lighting ignores `pointLights` for surface shading. + +```ts +interface PolyPointLight { + /** World-space position of the light. */ + position: [number, number, number]; + /** Point light color hex (default: "#ffffff"). */ + color?: string; + /** Point light intensity (default: 1). */ + intensity?: number; + /** When true, this light casts radial SVG shadows (default: false). */ + castShadow?: boolean; +} +``` + +--- + ## `PolyAmbientLight` Ambient-only light (no directional component). @@ -214,6 +237,7 @@ Core imperative handles returned by `createPolyScene()` and `scene.add()`. interface PolySceneOptions { camera: PolyPerspectiveCameraHandle | PolyOrthographicCameraHandle; directionalLight?: PolyDirectionalLight; + pointLights?: PolyPointLight[]; ambientLight?: PolyAmbientLight; textureLighting?: PolyTextureLightingMode; textureQuality?: TextureQuality; diff --git a/website/src/content/docs/components/poly-scene.mdx b/website/src/content/docs/components/poly-scene.mdx index 0624c0a5..b255c9ee 100644 --- a/website/src/content/docs/components/poly-scene.mdx +++ b/website/src/content/docs/components/poly-scene.mdx @@ -18,6 +18,7 @@ The React/Vue components and `createPolyScene()` support the full table. The `

Date: Fri, 19 Jun 2026 22:05:00 +0200 Subject: [PATCH 08/21] feat(shadows): shaded per-light colored shadows + point-light bench controls --- AGENTS.md | 2 +- bench/point-light-oracle.html | 53 +++++++++--- .../core/src/shadow/computeReceiverShadows.ts | 81 ++++++++++++++++--- packages/polycss/src/api/createPolyScene.ts | 27 ++++--- .../polycss/src/api/scene/receiverShadow.ts | 6 ++ packages/react/src/scene/PolyMesh.tsx | 25 ++++-- packages/vue/src/scene/PolyMesh.ts | 25 ++++-- 7 files changed, 176 insertions(+), 43 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9cb84d39..79351364 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ Strategies are ordered cheapest → most expensive. The mesher's job is to maxim 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 ``, because it belongs to the non-triangle clipped-solid family. `` 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. -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. The directional light projects in parallel; each `pointLights` entry with `castShadow: true` casts an additional **radial** shadow (each vertex projected along its own ray from the light position), and point-light passes always project the caster *silhouette* — projecting individual back-faces leaves the contact footprint unshadowed under radial divergence. Each light renders into its own SVG namespace (`"d"` for directional, `"p0".."pN"` for point lights) so overlapping shadows stack independently rather than colliding. 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. +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. The directional light projects in parallel; each `pointLights` entry with `castShadow: true` casts an additional **radial** shadow (each vertex projected along its own ray from the light position), and point-light passes always project the caster *silhouette* — projecting individual back-faces leaves the contact footprint unshadowed under radial divergence. Each light renders into its own SVG namespace (`"d"` for directional, `"p0".."pN"` for point lights) so overlapping shadows stack independently rather than colliding. Shadows are **shaded, not flat black**: each light's shadow is filled with the receiver lit by every OTHER light (the blocked light removed), so a region shadowed from one colored light still shows the remaining lights' color (Three.js colored shadows). A lone directional light reduces this to the ambient-only fill (unchanged). `mix-blend-mode` can't be used — `preserve-3d` isolates each SVG so a blend composites against a transparent backdrop, not the receiver — so shadows use plain alpha at `shadow.opacity`; overlapping per-light shadows therefore approximate the "both lights blocked" region rather than compositing it exactly. 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. 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. diff --git a/bench/point-light-oracle.html b/bench/point-light-oracle.html index 7718b6f4..b3943e3a 100644 --- a/bench/point-light-oracle.html +++ b/bench/point-light-oracle.html @@ -1589,6 +1589,19 @@ { sec: "Ambient" }, { k: ["amb", "intensity"], min: 0, max: 1, step: 0.01, fmt: (v) => v.toFixed(2) }, { k: ["amb", "color"], color: true, label: "color" }, + // Point lights — one section per entry in S.points. Custom get/set bind + // to the array entry; `light:true` routes through the baked-mode rebake + // debounce (moving a point light re-bakes shading + re-emits shadows). + ...S.points.flatMap((_, i) => [ + { sec: `Point light ${i}` }, + { light: true, check: true, label: "on", get: () => S.points[i].on, set: (v) => { S.points[i].on = v; } }, + { light: true, check: true, label: "castShadow", get: () => S.points[i].shadow, set: (v) => { S.points[i].shadow = v; } }, + { light: true, label: "x", min: -10, max: 10, step: 0.1, fmt: (v) => v.toFixed(2), get: () => S.points[i].x, set: (v) => { S.points[i].x = v; } }, + { light: true, label: "y", min: -10, max: 10, step: 0.1, fmt: (v) => v.toFixed(2), get: () => S.points[i].y, set: (v) => { S.points[i].y = v; } }, + { light: true, label: "z", min: -10, max: 10, step: 0.1, fmt: (v) => v.toFixed(2), get: () => S.points[i].z, set: (v) => { S.points[i].z = v; } }, + { light: true, label: "intensity", min: 0, max: 3, step: 0.05, fmt: (v) => v.toFixed(2), get: () => S.points[i].intensity, set: (v) => { S.points[i].intensity = v; } }, + { light: true, color: true, label: "color", get: () => S.points[i].color, set: (v) => { S.points[i].color = v; } }, + ]), { sec: "Shadow" }, { k: ["shadow", "opacity"], min: 0, max: 1, step: 0.01, fmt: (v) => v.toFixed(2) }, { sec: "Camera" }, @@ -1618,7 +1631,9 @@ inp.style.height = "20px"; inp.style.border = "1px solid #cbd5e1"; inp.style.background = "#fff"; - inp.value = S[r.k[0]][r.k[1]]; + const cGet = r.get ?? (() => S[r.k[0]][r.k[1]]); + const cSet = r.set ?? ((v) => { S[r.k[0]][r.k[1]] = v; }); + inp.value = cGet(); let dragging = false; let endTimer = null; const finalize = () => { @@ -1634,7 +1649,7 @@ inp.addEventListener("pointerup", finalize); inp.addEventListener("change", finalize); inp.addEventListener("input", () => { - S[r.k[0]][r.k[1]] = inp.value; + cSet(inp.value); const lightsChanged = !dragging && S.lighting === "baked"; update(lightsChanged); }); @@ -1642,27 +1657,47 @@ p.appendChild(row); continue; } + if (r.check) { + // Boolean toggle (point-light on / castShadow). Commits immediately + // and re-bakes in baked mode (a light enable/disable changes shading). + const row = document.createElement("div"); row.className = "row"; + const lab = document.createElement("label"); + lab.textContent = r.label ?? r.k.join("."); + row.appendChild(lab); + const inp = document.createElement("input"); + inp.type = "checkbox"; + inp.style.gridColumn = "2 / span 2"; + inp.style.justifySelf = "start"; + inp.checked = !!r.get(); + inp.addEventListener("change", () => { + r.set(inp.checked); + update(S.lighting === "baked"); + }); + row.appendChild(inp); + p.appendChild(row); + continue; + } const row = document.createElement("div"); row.className = "row"; const lab = document.createElement("label"); - lab.textContent = r.obj ? r.k[1] : r.k.join("."); + lab.textContent = r.label ?? (r.obj ? r.k[1] : r.k.join(".")); row.appendChild(lab); const inp = document.createElement("input"); inp.type = "range"; inp.min = r.min; inp.max = r.max; inp.step = r.step; // Object-transform rows read S.obj[S.mesh][field] so the slider - // value follows the active mesh; everything else reads its own - // [section][field] directly. - const getVal = r.obj ? () => S.obj[S.mesh][r.k[1]] : () => S[r.k[0]][r.k[1]]; - const setVal = r.obj ? (v) => { S.obj[S.mesh][r.k[1]] = v; } : (v) => { S[r.k[0]][r.k[1]] = v; }; + // value follows the active mesh; point-light rows pass explicit + // get/set; everything else reads its own [section][field] directly. + const getVal = r.get ? r.get : (r.obj ? () => S.obj[S.mesh][r.k[1]] : () => S[r.k[0]][r.k[1]]); + const setVal = r.set ? r.set : (r.obj ? (v) => { S.obj[S.mesh][r.k[1]] = v; } : (v) => { S[r.k[0]][r.k[1]] = v; }); inp.value = getVal(); const val = document.createElement("span"); val.className = "val"; val.textContent = r.fmt(getVal()); if (r.obj) objectRowRefreshers.push(() => { const v = getVal(); inp.value = v; val.textContent = r.fmt(v); }); // Light-direction sliders (dir.x/y/z) also need a refresher hook // so the gizmo drag can sync the slider UI as the user drags the // light marker around in 3D. - if (r.k[0] === "dir" && (r.k[1] === "x" || r.k[1] === "y" || r.k[1] === "z")) { + if (r.k?.[0] === "dir" && (r.k[1] === "x" || r.k[1] === "y" || r.k[1] === "z")) { lightSliderRefreshers.push(() => { const v = getVal(); inp.value = v; val.textContent = r.fmt(v); }); } - const isLightSlider = (r.k[0] === "dir" || r.k[0] === "amb"); + const isLightSlider = (r.light === true || r.k?.[0] === "dir" || r.k?.[0] === "amb"); // Baked mode is treated as truly static: while a light slider is // being dragged, the meshes don't repaint. A single rebake fires // 150ms after the last input event so the final state lands on diff --git a/packages/core/src/shadow/computeReceiverShadows.ts b/packages/core/src/shadow/computeReceiverShadows.ts index c44e57c3..5952f136 100644 --- a/packages/core/src/shadow/computeReceiverShadows.ts +++ b/packages/core/src/shadow/computeReceiverShadows.ts @@ -451,6 +451,16 @@ export interface ComputeReceiverShadowFacesInput { * shadow projection becomes radial (per-vertex direction from the light) * and the silhouette fast path is disabled (facing is per-polygon). */ lightPos?: Vec3; + /** Every point light in the scene (CSS-frame absolute positions), used to + * compute the SHADED shadow color — a shadow shows the receiver lit by all + * lights EXCEPT the blocked one, so a spot shadowed from one colored light + * still shows the others' color (Three.js parity). Includes non-shadow- + * casting lights, since they still illuminate the shadowed region. */ + allPointLights?: ReadonlyArray<{ position: Vec3; color?: string; intensity?: number }>; + /** For a point-light pass, the index into `allPointLights` of the light + * being shadowed (excluded from the remaining-light fill). Undefined for + * the directional pass (which instead excludes the directional light). */ + thisPointIndex?: number; /** Camera cull rotation (rotX/rotY + receiver mesh rotation) so back- * facing receiver faces can be skipped. */ cameraRot: CameraCullRotation; @@ -472,7 +482,8 @@ export function computeReceiverShadowFaces( ): ReceiverShadowFaceSpec[] { const { receiverPlanes, receiverPolygons, receiverHasTexture, casters, - lightDir, lightPos, cameraRot, ambientLight, directionalLight, shadow, + lightDir, lightPos, allPointLights, thisPointIndex, + cameraRot, ambientLight, directionalLight, shadow, } = input; const llen = Math.hypot(lightDir[0], lightDir[1], lightDir[2]) || 1; @@ -496,8 +507,16 @@ export function computeReceiverShadowFaces( const ambColor = ambientLight?.color ?? "#ffffff"; const ambIntensity = ambientLight?.intensity ?? 0.4; const dirIntensity = directionalLight?.intensity ?? 1; + const dirColor = directionalLight?.color ?? "#ffffff"; const userShadowColor = shadow?.color ?? "#000000"; const opacity = shadow?.opacity ?? 0.25; + // This pass excludes one light from the receiver's lit color: the directional + // light when it's the directional pass (no lightPos), else the point light at + // `thisPointIndex`. The shadow then paints the "remaining light" color so a + // region blocked from one colored light still shows the others (Three.js + // colored shadows). A lone directional light → remaining = ambient only, + // identical to the previous flat fill (no regression). + const isDirPass = !isPoint; // `maxExtend` ("shadow reach") caps how far a shadow can stretch beyond // the caster's perpendicular footprint on the receiver plane — matches // the semantic already applied in groundShadow.ts to the infinite-ground @@ -660,6 +679,25 @@ export function computeReceiverShadowFaces( // reliably, so a back-of-camera receiver-face SVG would still paint. if (!normalFacesCamera(n, cameraOnlyRot)) continue; + // Per-face light scalars for the SHADED shadow color. `dirDot` is the + // directional Lambert term (always vs the directional vector, not the + // pass's faceDir). Point dots come from each light's to-source direction. + const dirDot = Math.max(0, Lx * n[0] + Ly * n[1] + Lz * n[2]); + const pointDots: number[] = []; + let pointTotalScale = 0; + if (allPointLights) { + for (let k = 0; k < allPointLights.length; k++) { + const pl = allPointLights[k]!; + const dx = pl.position[0] - O[0]; + const dy = pl.position[1] - O[1]; + const dz = pl.position[2] - O[2]; + const len = Math.hypot(dx, dy, dz) || 1; + const dot = Math.max(0, (dx / len) * n[0] + (dy / len) * n[1] + (dz / len) * n[2]); + pointDots[k] = dot; + pointTotalScale += (pl.intensity ?? 1) * dot; + } + } + const planeDist = (w: Vec3): number => (w[0] - O[0]) * n[0] + (w[1] - O[1]) * n[1] + (w[2] - O[2]) * n[2]; const planeCross = (a: Vec3, b: Vec3, da: number, db: number): Vec3 => { @@ -947,29 +985,48 @@ export function computeReceiverShadowFaces( if (totalClipped === 0 || !(width > 0) || !(height > 0)) continue; - // Per-group opacity for textured receivers. Three.js darkens linearly: - // shadow_linear = lit_linear × ambient/(direct + ambient). CSS opacity - // blends in sRGB, so apply a gamma-2.4 approximation to match. + // Intensity removed by THIS pass (the blocked light) and the scene total. + const thisScale = isDirPass + ? dirIntensity * dirDot + : (allPointLights?.[thisPointIndex ?? -1]?.intensity ?? 1) * (pointDots[thisPointIndex ?? -1] ?? 0); + const totalScale = dirIntensity * dirDot + pointTotalScale + Math.max(0, ambIntensity); + + // Per-group opacity for textured receivers. The shadow leaves the receiver + // lit by everything EXCEPT this pass's light: shadow_linear = + // lit_linear × remaining/total. A black overlay at sRGB opacity o gives + // lit_srgb × (1 − o), so o = 1 − (remaining/total)^(1/2.4). For a lone + // directional light remaining = ambient, matching the previous formula. let effOp = opacity; if (receiverHasTexture) { - const direct = dirIntensity * Math.max(0, Ldotn); - const total = direct + Math.max(0, ambIntensity); - if (total > 0) { - const ratioLinear = Math.max(0, ambIntensity) / total; - const ratioSrgb = Math.pow(ratioLinear, 1 / 2.4); - effOp = opacity * (1 - ratioSrgb); + if (totalScale > 0) { + const remainingRatio = Math.max(0, (totalScale - thisScale) / totalScale); + effOp = opacity * (1 - Math.pow(remainingRatio, 1 / 2.4)); } else { effOp = 0; } } // Per-face shadow tint. Each receiver face uses ITS OWN polygon color - // for the ambient-only fill (one material per coplanar group). + // (one material per coplanar group). The fill is the receiver lit by + // every light EXCEPT this pass's blocked light — so a region shadowed + // from one colored light still shows the others' color. A lone + // directional light → remaining = ambient only (unchanged from before). const groupPolyIdx = group.memberPolyIndices[0] ?? 0; const groupColor = receiverPolygons[groupPolyIdx]?.color ?? "#cccccc"; + const remDirScale = isDirPass ? 0 : dirIntensity * dirDot; + const remPointContribs: { color: string; scale: number }[] = []; + if (allPointLights) { + for (let k = 0; k < allPointLights.length; k++) { + if (isPoint && k === thisPointIndex) continue; + const dot = pointDots[k] ?? 0; + if (dot <= 0) continue; + const pl = allPointLights[k]!; + remPointContribs.push({ color: pl.color ?? "#ffffff", scale: (pl.intensity ?? 1) * dot }); + } + } const fillColor = receiverHasTexture ? userShadowColor - : shadePolygon(groupColor, 0, "#000000", ambColor, ambIntensity); + : shadePolygon(groupColor, remDirScale, dirColor, ambColor, ambIntensity, remPointContribs); // Tight shadow-content bbox in (u, v). The receiver face outline can be // huge (e.g. a 17500×17500 CSS-px floor at world units × BASE_TILE), but diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index abf48ec1..b07cb952 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -983,10 +983,20 @@ export function createPolyScene( // Shadow-casting point lights, converted to CSS-frame positions. Only // lights with castShadow:true project (Three.js parity); shading-only // point lights never reach the shadow path. - const shadowPointLights = (currentOptions.pointLights ?? []).filter((pl) => pl.castShadow); - const cssPointPositions = shadowPointLights.map((pl) => worldPositionToCss(pl.position)); + // ALL point lights in CSS frame — the shaded shadow color needs every + // light that illuminates the receiver (even non-casters), minus the one + // being shadowed. `shadowPointIndices` are the entries that cast. + const allPointLightsCss = (currentOptions.pointLights ?? []).map((pl) => ({ + position: worldPositionToCss(pl.position), + color: pl.color, + intensity: pl.intensity, + })); + const shadowPointIndices = (currentOptions.pointLights ?? []) + .map((pl, i) => (pl.castShadow ? i : -1)) + .filter((i) => i >= 0); + const cssPointPositions = shadowPointIndices.map((i) => allPointLightsCss[i]!.position); const runDirectionalShadow = - !!currentOptions.directionalLight?.direction || shadowPointLights.length === 0; + !!currentOptions.directionalLight?.direction || shadowPointIndices.length === 0; const dirKey = quantizeLightDirKey(lightDir); // Fold point-light positions into the short-circuit key so moving (or // toggling) a shadow point light re-emits even when the directional @@ -1067,22 +1077,21 @@ export function createPolyScene( // ONLY point shadow lights skips this so no phantom default-sun shadow // appears. if (runDirectionalShadow) { - emitReceiverShadowsImpl(ctx, casters, dedupByCaster, receiver, dedup, lightDir, r, g, b, shadowOpacity); + emitReceiverShadowsImpl(ctx, casters, dedupByCaster, receiver, dedup, lightDir, r, g, b, shadowOpacity, undefined, "", allPointLightsCss, undefined); } else { // Ensure any directional SVGs from a prior tick are cleared. - emitReceiverShadowsImpl(ctx, [], dedupByCaster, receiver, dedup, lightDir, r, g, b, shadowOpacity); + emitReceiverShadowsImpl(ctx, [], dedupByCaster, receiver, dedup, lightDir, r, g, b, shadowOpacity, undefined, "", allPointLightsCss, undefined); } // One radial pass per shadow-casting point light. Each light projects // into its own SVG namespace ("p0".."pN") so overlapping shadows stack - // independently — a point in shadow from two lights reads as darker, - // matching multi-shadow-map occlusion. `lightDir` here is only used for - // the textured-receiver opacity ratio; the radial projection uses the - // CSS-frame `lightPos`. + // independently. The shaded fill shows the receiver lit by every OTHER + // light, so a spot blocked from this light keeps the others' color. for (let li = 0; li < cssPointPositions.length; li++) { emitReceiverShadowsImpl( ctx, casters, dedupByCaster, receiver, dedup, lightDir, r, g, b, shadowOpacity, cssPointPositions[li], `p${li}`, + allPointLightsCss, shadowPointIndices[li], ); } } diff --git a/packages/polycss/src/api/scene/receiverShadow.ts b/packages/polycss/src/api/scene/receiverShadow.ts index 5e3aa1cd..8d466e50 100644 --- a/packages/polycss/src/api/scene/receiverShadow.ts +++ b/packages/polycss/src/api/scene/receiverShadow.ts @@ -122,6 +122,10 @@ export function emitReceiverShadows( lightPos?: Vec3, /** Per-light SVG-mount namespace ("" = directional). */ lightKey = "", + /** All scene point lights (CSS-frame positions) for the shaded shadow color. */ + allPointLights?: ReadonlyArray<{ position: Vec3; color?: string; intensity?: number }>, + /** Index in `allPointLights` of the light this pass shadows (point pass only). */ + thisPointIndex?: number, ): void { const options = ctx.options.current; const { receiverShadowCache, receiverShadowCacheKey, casterItemsCache, casterItemsCacheKey } = ctx; @@ -237,6 +241,8 @@ export function emitReceiverShadows( casters: casterInputs, lightDir, lightPos, + allPointLights, + thisPointIndex, cameraRot, ambientLight: options.ambientLight, directionalLight: options.directionalLight, diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx index 471ced05..65479ee8 100644 --- a/packages/react/src/scene/PolyMesh.tsx +++ b/packages/react/src/scene/PolyMesh.tsx @@ -982,12 +982,22 @@ export const PolyMesh = forwardRef(function PolyM // Shadow-casting point lights, converted to CSS-frame absolute positions // (same world-CSS frame as the caster/receiver vertices). Only lights with // castShadow:true project (Three.js parity). Mirrors vanilla emitSceneShadows. - const shadowPointLights = (sceneCtx?.pointLights ?? []).filter((pl) => pl.castShadow); - const cssPointPositions = shadowPointLights.map((pl) => worldPositionToCss(pl.position)); + // ALL point lights in CSS frame — the shaded shadow color needs every + // light that illuminates the receiver (even non-casters), minus the one + // being shadowed. `shadowPointIndices` are the entries that cast. + const allPointLightsCss = (sceneCtx?.pointLights ?? []).map((pl) => ({ + position: worldPositionToCss(pl.position), + color: pl.color, + intensity: pl.intensity, + })); + const shadowPointIndices = (sceneCtx?.pointLights ?? []) + .map((pl, i) => (pl.castShadow ? i : -1)) + .filter((i) => i >= 0); + const cssPointPositions = shadowPointIndices.map((i) => allPointLightsCss[i]!.position); // Directional pass runs when a directional light is configured, or — to // preserve the implicit-sun shadow — when there are no point shadow lights. - const runDirectionalShadow = !!sceneDirectionalLight?.direction || shadowPointLights.length === 0; - const hasShadowPoints = shadowPointLights.length > 0; + const runDirectionalShadow = !!sceneDirectionalLight?.direction || shadowPointIndices.length === 0; + const hasShadowPoints = shadowPointIndices.length > 0; const shadowLift = sceneShadow?.lift ?? 0.001; const planes = prepareReceiverFacePlanes( polygons, @@ -1064,6 +1074,7 @@ export const PolyMesh = forwardRef(function PolyM lightKey: string, passLightDir: Vec3, lightPos: Vec3 | undefined, + thisPointIndex: number | undefined, ): ReactNode[] => { const specs = computeReceiverShadowFaces({ receiverPlanes: planes, @@ -1072,6 +1083,8 @@ export const PolyMesh = forwardRef(function PolyM casters: casterInputs, lightDir: passLightDir, lightPos, + allPointLights: allPointLightsCss, + thisPointIndex, cameraRot, ambientLight: sceneCtx?.ambientLight, directionalLight: sceneDirectionalLight, @@ -1116,9 +1129,9 @@ export const PolyMesh = forwardRef(function PolyM )); }; const nodes: ReactNode[] = []; - if (runDirectionalShadow) nodes.push(...runPass("d", lightDir, undefined)); + if (runDirectionalShadow) nodes.push(...runPass("d", lightDir, undefined, undefined)); for (let li = 0; li < cssPointPositions.length; li++) { - nodes.push(...runPass(`p${li}`, lightDir, cssPointPositions[li])); + nodes.push(...runPass(`p${li}`, lightDir, cssPointPositions[li], shadowPointIndices[li])); } return <>{nodes}; }, [receiveShadow, shadowCasters, shadowCastersVersion, polygons, position, scale, rotation, sceneDirectionalLight, sceneCtx?.pointLights, sceneShadow, sceneCtx?.ambientLight, cameraCtx?.store, cameraTick, selfShadowEdgeMap]); diff --git a/packages/vue/src/scene/PolyMesh.ts b/packages/vue/src/scene/PolyMesh.ts index 869499cf..49d88709 100644 --- a/packages/vue/src/scene/PolyMesh.ts +++ b/packages/vue/src/scene/PolyMesh.ts @@ -660,12 +660,22 @@ export const PolyMesh = defineComponent({ // Shadow-casting point lights → CSS-frame absolute positions (same // world-CSS frame as caster/receiver vertices). Only castShadow:true // lights project (Three.js parity). Mirrors vanilla emitSceneShadows. - const shadowPointLights = (ctx?.pointLights ?? []).filter((pl) => pl.castShadow); - const cssPointPositions = shadowPointLights.map((pl) => worldPositionToCss(pl.position)); + // ALL point lights in CSS frame — the shaded shadow color needs every + // light that illuminates the receiver (even non-casters), minus the one + // being shadowed. `shadowPointIndices` are the entries that cast. + const allPointLightsCss = (ctx?.pointLights ?? []).map((pl) => ({ + position: worldPositionToCss(pl.position), + color: pl.color, + intensity: pl.intensity, + })); + const shadowPointIndices = (ctx?.pointLights ?? []) + .map((pl, i) => (pl.castShadow ? i : -1)) + .filter((i) => i >= 0); + const cssPointPositions = shadowPointIndices.map((i) => allPointLightsCss[i]!.position); // Directional pass runs when a directional light is configured, or — to // preserve the implicit-sun shadow — when there are no point shadow lights. - const runDirectionalShadow = !!ctx?.directionalLight?.direction || shadowPointLights.length === 0; - const hasShadowPoints = shadowPointLights.length > 0; + const runDirectionalShadow = !!ctx?.directionalLight?.direction || shadowPointIndices.length === 0; + const hasShadowPoints = shadowPointIndices.length > 0; const shadowLift = ctx?.shadow?.lift ?? 0.001; const planes = prepareReceiverFacePlanes( polygons.value, @@ -739,6 +749,7 @@ export const PolyMesh = defineComponent({ lightKey: string, passLightDir: Vec3, lightPos: Vec3 | undefined, + thisPointIndex: number | undefined, ): VNode[] => { const specs = computeReceiverShadowFaces({ receiverPlanes: planes, @@ -747,6 +758,8 @@ export const PolyMesh = defineComponent({ casters: casterInputs, lightDir: passLightDir, lightPos, + allPointLights: allPointLightsCss, + thisPointIndex, cameraRot, ambientLight: ctx?.ambientLight, directionalLight: ctx?.directionalLight, @@ -793,9 +806,9 @@ export const PolyMesh = defineComponent({ ); }; const nodes: VNode[] = []; - if (runDirectionalShadow) nodes.push(...runPass("d", lightDir, undefined)); + if (runDirectionalShadow) nodes.push(...runPass("d", lightDir, undefined, undefined)); for (let li = 0; li < cssPointPositions.length; li++) { - nodes.push(...runPass(`p${li}`, lightDir, cssPointPositions[li])); + nodes.push(...runPass(`p${li}`, lightDir, cssPointPositions[li], shadowPointIndices[li])); } return nodes; }); From 99ca66e02b9911fc2cc62f86e2990fe14e31ccfb Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 20 Jun 2026 00:50:16 +0200 Subject: [PATCH 09/21] feat(shadows): merge a face's lights into one SVG for correct overlap (vanilla) --- AGENTS.md | 2 +- .../core/src/shadow/computeReceiverShadows.ts | 50 ++- packages/polycss/src/api/createPolyScene.ts | 34 +- .../polycss/src/api/scene/receiverShadow.ts | 301 ++++++++++++------ 4 files changed, 256 insertions(+), 131 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 79351364..58795d29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ Strategies are ordered cheapest → most expensive. The mesher's job is to maxim 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 ``, because it belongs to the non-triangle clipped-solid family. `` 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. -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. The directional light projects in parallel; each `pointLights` entry with `castShadow: true` casts an additional **radial** shadow (each vertex projected along its own ray from the light position), and point-light passes always project the caster *silhouette* — projecting individual back-faces leaves the contact footprint unshadowed under radial divergence. Each light renders into its own SVG namespace (`"d"` for directional, `"p0".."pN"` for point lights) so overlapping shadows stack independently rather than colliding. Shadows are **shaded, not flat black**: each light's shadow is filled with the receiver lit by every OTHER light (the blocked light removed), so a region shadowed from one colored light still shows the remaining lights' color (Three.js colored shadows). A lone directional light reduces this to the ambient-only fill (unchanged). `mix-blend-mode` can't be used — `preserve-3d` isolates each SVG so a blend composites against a transparent backdrop, not the receiver — so shadows use plain alpha at `shadow.opacity`; overlapping per-light shadows therefore approximate the "both lights blocked" region rather than compositing it exactly. 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. +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. The directional light projects in parallel; each `pointLights` entry with `castShadow: true` casts an additional **radial** shadow (each vertex projected along its own ray from the light position), and point-light passes always project the caster *silhouette* — projecting individual back-faces leaves the contact footprint unshadowed under radial divergence. Shadows are **shaded, not flat black**: each light's shadow is filled with the receiver lit by every OTHER light (the blocked light removed), so a region shadowed from one colored light still shows the remaining lights' color (Three.js colored shadows). A lone directional light reduces this to the ambient-only fill (unchanged). All of a receiver FACE's lights are merged into **one SVG per face** so overlapping shadows composite correctly: a single-light face paints its remaining color directly (one path); a multi-light solid face paints a base = full-lit color `C` then each light as a `mix-blend-mode: multiply` layer with factor `remaining/C`, so the both-blocked overlap becomes `C·∏factor` (ambient only). `mix-blend-mode` works *within* one SVG but NOT across SVGs (`preserve-3d` isolates each SVG against a transparent backdrop — verified), which is why the merge is per-face rather than per-light. Textured receivers (per-pixel base, no uniform multiply) fall back to per-pass alpha layers that cumulatively darken. The per-face color uses the face CENTROID direction (matching the baked per-polygon shading) so the base leaves no visible color box. **Status:** the per-face merge currently lives in the vanilla renderer only; React/Vue still emit one SVG per light (approximate overlap) pending migration. 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. 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. diff --git a/packages/core/src/shadow/computeReceiverShadows.ts b/packages/core/src/shadow/computeReceiverShadows.ts index 5952f136..4835f13b 100644 --- a/packages/core/src/shadow/computeReceiverShadows.ts +++ b/packages/core/src/shadow/computeReceiverShadows.ts @@ -207,6 +207,15 @@ export interface ReceiverShadowFaceSpec { * if applicable). */ opacity: number; paths: Array>; + /** Full-lit face color C (all lights), for the multi-light merge: callers + * multiply C by each pass's `fill/C` factor so overlaps composite to the + * both-blocked color. Empty string for textured receivers (per-pixel base, + * multiply can't be uniform — those fall back to cumulative-alpha). */ + fullLitFill: string; + /** Every clipped caster polygon for this pass in absolute face-(u,v) space + * (NOT offset by the tight bbox). The multi-light merge re-bases these to a + * shared per-face bbox so all lights' shadows live in one SVG. */ + facePolysUv: Array>; } /** @@ -681,16 +690,29 @@ export function computeReceiverShadowFaces( // Per-face light scalars for the SHADED shadow color. `dirDot` is the // directional Lambert term (always vs the directional vector, not the - // pass's faceDir). Point dots come from each light's to-source direction. + // pass's faceDir). Point dots use the to-source direction from the face + // CENTROID — matching the baked surface shading (per-polygon centroid), so + // the shadow's full-lit base color equals the painted receiver and the SVG + // leaves no visible base-color box. (Projection still uses per-vertex + // radial directions; only the flat per-face color uses the centroid.) const dirDot = Math.max(0, Lx * n[0] + Ly * n[1] + Lz * n[2]); + let cMeanU = 0, cMeanV = 0; + for (const pt of outlineUv) { cMeanU += pt[0]; cMeanV += pt[1]; } + const cInv = outlineUv.length > 0 ? 1 / outlineUv.length : 0; + cMeanU *= cInv; cMeanV *= cInv; + const cen: Vec3 = [ + O[0] + cMeanU * u[0] + cMeanV * v[0], + O[1] + cMeanU * u[1] + cMeanV * v[1], + O[2] + cMeanU * u[2] + cMeanV * v[2], + ]; const pointDots: number[] = []; let pointTotalScale = 0; if (allPointLights) { for (let k = 0; k < allPointLights.length; k++) { const pl = allPointLights[k]!; - const dx = pl.position[0] - O[0]; - const dy = pl.position[1] - O[1]; - const dz = pl.position[2] - O[2]; + const dx = pl.position[0] - cen[0]; + const dy = pl.position[1] - cen[1]; + const dz = pl.position[2] - cen[2]; const len = Math.hypot(dx, dy, dz) || 1; const dot = Math.max(0, (dx / len) * n[0] + (dy / len) * n[1] + (dz / len) * n[2]); pointDots[k] = dot; @@ -1079,6 +1101,7 @@ export function computeReceiverShadowFaces( const tightMatrixCss = `matrix3d(${tm.map((x) => x.toFixed(4)).join(",")})`; const paths: Array> = []; + const facePolysUv: Array> = []; for (const entry of clippedByCaster.values()) { let d = ""; for (const verts of entry.verts) { @@ -1087,10 +1110,27 @@ export function computeReceiverShadowFaces( d += `L${(verts[j]![0] - shMinU).toFixed(1)},${(verts[j]![1] - shMinV).toFixed(1)}`; } d += "Z"; + facePolysUv.push(verts.map((p) => [p[0], p[1]] as [number, number])); } paths.push({ casterId: entry.id, d, casterPolygonIndices: entry.subPolygonIndices }); } + // Full-lit face color C (every light) for the multi-light merge — callers + // multiply C by each pass's `fill/C` so overlapping shadows composite to + // the both-blocked color. Empty for textured (per-pixel base). + const fullPointContribs: { color: string; scale: number }[] = []; + if (allPointLights) { + for (let k = 0; k < allPointLights.length; k++) { + const dot = pointDots[k] ?? 0; + if (dot <= 0) continue; + const pl = allPointLights[k]!; + fullPointContribs.push({ color: pl.color ?? "#ffffff", scale: (pl.intensity ?? 1) * dot }); + } + } + const fullLitFill = receiverHasTexture + ? "" + : shadePolygon(groupColor, dirIntensity * dirDot, dirColor, ambColor, ambIntensity, fullPointContribs); + out.push({ faceIndex: group.faceIndex, memberPolyIndices: group.memberPolyIndices, @@ -1100,6 +1140,8 @@ export function computeReceiverShadowFaces( fill: fillColor, opacity: effOp, paths, + fullLitFill, + facePolysUv, }); } diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index b07cb952..ba31c924 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -1071,29 +1071,17 @@ export function createPolyScene( for (const receiver of meshes) { if (receiver.disposed || !receiver.receiveShadow) continue; const dedup = dedupByReceiver.get(receiver) ?? new Set(); - // Directional pass (mount namespace ""). Runs whenever a directional - // light is configured, or — to preserve the legacy implicit-sun - // shadow — when there are no shadow-casting point lights. A scene with - // ONLY point shadow lights skips this so no phantom default-sun shadow - // appears. - if (runDirectionalShadow) { - emitReceiverShadowsImpl(ctx, casters, dedupByCaster, receiver, dedup, lightDir, r, g, b, shadowOpacity, undefined, "", allPointLightsCss, undefined); - } else { - // Ensure any directional SVGs from a prior tick are cleared. - emitReceiverShadowsImpl(ctx, [], dedupByCaster, receiver, dedup, lightDir, r, g, b, shadowOpacity, undefined, "", allPointLightsCss, undefined); - } - // One radial pass per shadow-casting point light. Each light projects - // into its own SVG namespace ("p0".."pN") so overlapping shadows stack - // independently. The shaded fill shows the receiver lit by every OTHER - // light, so a spot blocked from this light keeps the others' color. - for (let li = 0; li < cssPointPositions.length; li++) { - emitReceiverShadowsImpl( - ctx, casters, dedupByCaster, receiver, dedup, - lightDir, r, g, b, shadowOpacity, - cssPointPositions[li], `p${li}`, - allPointLightsCss, shadowPointIndices[li], - ); - } + // All of this receiver's light passes are merged into one SVG per face + // (base = full-lit color, each pass a multiply layer) so overlapping + // shadows composite to the both-blocked color. The directional pass runs + // whenever a directional light is configured, or — to preserve the + // implicit-sun shadow — when there are no shadow-casting point lights; + // a point-only scene skips it so no phantom default-sun shadow appears. + emitReceiverShadowsImpl(ctx, casters, dedupByCaster, receiver, dedup, lightDir, r, g, b, shadowOpacity, { + runDirectional: runDirectionalShadow, + points: cssPointPositions.map((lightPos, li) => ({ lightPos, index: shadowPointIndices[li]! })), + allPointLights: allPointLightsCss, + }); } lastEmittedShadowLightKey = lightKey; } diff --git a/packages/polycss/src/api/scene/receiverShadow.ts b/packages/polycss/src/api/scene/receiverShadow.ts index 8d466e50..d9575912 100644 --- a/packages/polycss/src/api/scene/receiverShadow.ts +++ b/packages/polycss/src/api/scene/receiverShadow.ts @@ -12,6 +12,7 @@ import { buildSharedEdgeMap, computeReceiverShadowFaces, meshScaleVec3, + parseHexColor, prepareCasterEdgeOwners, prepareCasterPolyItems, prepareReceiverFacePlanes, @@ -22,7 +23,7 @@ import { type ReceiverFacePlane, type Vec3, } from "@layoutit/polycss-core"; -import { ensureShadowRoot, syncShadowPaths } from "./shadowSvg"; +import { ensureShadowRoot } from "./shadowSvg"; import { meshShadowId } from "./shadowCache"; import type { SceneContext } from "./sceneContext"; import type { MeshEntry } from "./internalTypes"; @@ -117,15 +118,18 @@ export function emitReceiverShadows( lightDir: Vec3, _r: number, _g: number, _b: number, opacity: number, - /** When set, the light is a point light at this CSS-frame position; the - * projection becomes radial. */ - lightPos?: Vec3, - /** Per-light SVG-mount namespace ("" = directional). */ - lightKey = "", - /** All scene point lights (CSS-frame positions) for the shaded shadow color. */ - allPointLights?: ReadonlyArray<{ position: Vec3; color?: string; intensity?: number }>, - /** Index in `allPointLights` of the light this pass shadows (point pass only). */ - thisPointIndex?: number, + /** Every light's shadow pass for this receiver. All of a face's passes are + * merged into ONE SVG so overlapping shadows composite correctly (the base + * is the full-lit face color, each pass a multiply layer). */ + passes: { + /** Run the directional pass (mount namespace aside, always merged). */ + runDirectional: boolean; + /** One entry per shadow-casting point light: its CSS position + index + * into `allPointLights`. */ + points: ReadonlyArray<{ lightPos: Vec3; index: number }>; + /** All scene point lights (CSS positions) for the shaded shadow color. */ + allPointLights?: ReadonlyArray<{ position: Vec3; color?: string; intensity?: number }>; + }, ): void { const options = ctx.options.current; const { receiverShadowCache, receiverShadowCacheKey, casterItemsCache, casterItemsCacheKey } = ctx; @@ -205,7 +209,7 @@ export function emitReceiverShadows( // (a 6-quad cube) require the outline. Directional keeps the ≥40-poly // gate where the per-poly path is cheaper. let edgeOwners: ReadonlyMap | undefined; - if (caster !== receiverEntry && (caster.polygons.length >= 40 || lightPos)) { + if (caster !== receiverEntry && (caster.polygons.length >= 40 || passes.points.length > 0)) { let cachedOwners = edgeOwnersCache.get(caster); if (cachedOwners === undefined || edgeOwnersCacheKey.get(caster) !== ckey) { cachedOwners = prepareCasterEdgeOwners( @@ -234,116 +238,147 @@ export function emitReceiverShadows( meshRotation: receiverEntry.handle.transform.rotation, }; - const specs = computeReceiverShadowFaces({ - receiverPlanes: cachedPlanes, - receiverPolygons: receiverEntry.polygons, - receiverHasTexture: hasTexture, - casters: casterInputs, - lightDir, - lightPos, - allPointLights, - thisPointIndex, - cameraRot, - ambientLight: options.ambientLight, - directionalLight: options.directionalLight, - shadow: { color: options.shadow?.color, opacity, maxExtend: options.shadow?.maxExtend }, - }); + // Plane basis lookup by faceIndex (cachedPlanes is occlusion-filtered). + const planeByFace = new Map(); + for (const pl of cachedPlanes) planeByFace.set(pl.faceIndex, pl); - // Mount/update SVGs from specs. Faces NOT in specs (back-facing, no - // shadow content, etc.) get hidden. - const mounted = mountedFacesFor(receiverEntry, lightKey); + // Aggregate every light pass per receiver FACE, so a face's coplanar shadows + // share one SVG and overlaps composite correctly. Solid receivers paint a + // base = full-lit color C, then each pass as a `multiply` layer with factor + // (remaining/C) — overlaps become C·fA·fB (both lights removed). Textured + // receivers (per-pixel base, no uniform multiply) fall back to per-pass dark + // alpha layers, which cumulatively darken. + // `fill` is the per-pass remaining-light color (receiver lit by all OTHER + // lights). Single-layer faces paint it directly (one path, like before); + // multi-layer faces multiply it against the base as `fill/base`. + type Layer = { polys: Array>; fill: string; opacity: number }; + type FaceAgg = { memberPolyIndices: number[]; base: string; solid: boolean; layers: Layer[] }; + const perFace = new Map(); + + const runPass = (lightPos: Vec3 | undefined, thisPointIndex: number | undefined): void => { + const specs = computeReceiverShadowFaces({ + receiverPlanes: cachedPlanes, + receiverPolygons: receiverEntry.polygons, + receiverHasTexture: hasTexture, + casters: casterInputs, + lightDir, + lightPos, + allPointLights: passes.allPointLights, + thisPointIndex, + cameraRot, + ambientLight: options.ambientLight, + directionalLight: options.directionalLight, + shadow: { color: options.shadow?.color, opacity, maxExtend: options.shadow?.maxExtend }, + }); + for (const spec of specs) { + if (spec.facePolysUv.length === 0) continue; + const solid = spec.fullLitFill !== ""; + let agg = perFace.get(spec.faceIndex); + if (!agg) { + agg = { memberPolyIndices: spec.memberPolyIndices, base: spec.fullLitFill, solid, layers: [] }; + perFace.set(spec.faceIndex, agg); + } + agg.layers.push({ polys: spec.facePolysUv, fill: spec.fill, opacity: spec.opacity }); + } + }; + if (passes.runDirectional) runPass(undefined, undefined); + for (const p of passes.points) runPass(p.lightPos, p.index); + + const mounted = mountedFacesFor(receiverEntry, "m"); const seen = new Set(); - for (const spec of specs) { - seen.add(spec.faceIndex); - let face = mounted.get(spec.faceIndex); + const wantDebug = !!options.debugShadowAttrs; + const shadowRoot = ensureShadowRoot(ctx.shadowSvgState, ctx.doc, ctx.sceneEl); + + for (const [faceIndex, agg] of perFace) { + const plane = planeByFace.get(faceIndex); + if (!plane || agg.layers.length === 0) continue; + // Union bbox over every pass's polys (absolute face-(u,v)). + let minU = Infinity, minV = Infinity, maxU = -Infinity, maxV = -Infinity; + for (const layer of agg.layers) for (const poly of layer.polys) for (const pt of poly) { + if (pt[0] < minU) minU = pt[0]; + if (pt[0] > maxU) maxU = pt[0]; + if (pt[1] < minV) minV = pt[1]; + if (pt[1] > maxV) maxV = pt[1]; + } + if (!Number.isFinite(minU)) continue; + minU = Math.floor(minU - 1); minV = Math.floor(minV - 1); + maxU = Math.ceil(maxU + 1); maxV = Math.ceil(maxV + 1); + const w = maxU - minU, h = maxV - minV; + if (!(w > 0) || !(h > 0)) continue; + + const { O, u, v, n, lift } = plane; + const tx = O[0] + minU * u[0] + minV * v[0] + lift * n[0]; + const ty = O[1] + minU * u[1] + minV * v[1] + lift * n[1]; + const tz = O[2] + minU * u[2] + minV * v[2] + lift * n[2]; + const m = [u[0], u[1], u[2], 0, v[0], v[1], v[2], 0, n[0], n[1], n[2], 0, tx, ty, tz, 1]; + const matrixCss = `matrix3d(${m.map((x) => x.toFixed(4)).join(",")})`; + + seen.add(faceIndex); + let face = mounted.get(faceIndex); if (!face) { face = { svg: null, visible: false, width: -1, height: -1, matrixCss: "" }; - mounted.set(spec.faceIndex, face); + mounted.set(faceIndex, face); } let svg = face.svg; if (!svg) { svg = ctx.doc.createElementNS(SVG_NS, "svg"); svg.setAttribute("class", "polycss-shadow polycss-shadow-svg polycss-shadow-receiver"); - if (options.debugShadowAttrs) { - svg.setAttribute("data-poly-shadow-type", "receiver"); - if (lightKey) svg.setAttribute("data-poly-shadow-light", lightKey); - svg.setAttribute("data-poly-shadow-receiver", meshShadowId(receiverEntry)); - svg.setAttribute("data-poly-shadow-receiver-face", String(spec.faceIndex)); - svg.setAttribute("data-poly-shadow-receiver-polys", JSON.stringify(spec.memberPolyIndices)); - } - svg.setAttribute("width", String(spec.width)); - svg.setAttribute("height", String(spec.height)); - svg.setAttribute("viewBox", `0 0 ${spec.width} ${spec.height}`); - svg.setAttribute( - "style", - `position:absolute;top:0;left:0;display:block;overflow:hidden;` + - `transform-origin:0 0;pointer-events:none;will-change:transform;` + - `transform:${spec.matrixCss}`, - ); - // Mount inside the shared `.polycss-shadows` wrapper at scene root. - // The SVG's matrix3d still encodes the face plane in world frame — - // the wrapper is a 0×0 preserve-3d container that takes no layout - // space, so children composite at their absolute matrix3d positions - // just like they did when mounted directly on sceneEl. Grouping - // exists for DOM organization (clean DevTools tree) and to make - // future "clip all shadows to a region" / "hide all shadows" toggles - // trivial (one ancestor to flip). - const shadowRoot = ensureShadowRoot(ctx.shadowSvgState, ctx.doc, ctx.sceneEl); shadowRoot.appendChild(svg); face.svg = svg; - face.width = spec.width; - face.height = spec.height; - face.matrixCss = spec.matrixCss; - } else { - if (!face.visible) svg.style.display = "block"; - // Tight shadow-bbox SVGs resize/translate every frame as the shadow - // sweeps across the receiver — re-apply width/height/viewBox/transform - // only when they actually change. - if (face.width !== spec.width || face.height !== spec.height) { - svg.setAttribute("width", String(spec.width)); - svg.setAttribute("height", String(spec.height)); - svg.setAttribute("viewBox", `0 0 ${spec.width} ${spec.height}`); - face.width = spec.width; - face.height = spec.height; - } - if (face.matrixCss !== spec.matrixCss) { - svg.style.transform = spec.matrixCss; - face.matrixCss = spec.matrixCss; - } } + if (!face.visible) svg.style.display = "block"; face.visible = true; - - const paths = syncShadowPaths(svg, ctx.doc, spec.paths.length, /*withStroke*/ true); - const opStr = spec.opacity.toFixed(4); - const casterIds: string[] = []; - const wantDebug = !!options.debugShadowAttrs; - for (let i = 0; i < spec.paths.length; i++) { - const p = spec.paths[i]!; - const casterId = wantDebug ? meshShadowId(p.casterId) : ""; - if (wantDebug) casterIds.push(casterId); - const path = paths[i]!; - path.setAttribute("d", p.d); - if (path.getAttribute("fill") !== spec.fill) path.setAttribute("fill", spec.fill); - if (path.getAttribute("stroke") !== spec.fill) path.setAttribute("stroke", spec.fill); - if (path.getAttribute("opacity") !== opStr) path.setAttribute("opacity", opStr); - if (wantDebug) { - if (path.getAttribute("data-poly-shadow-caster") !== casterId) { - path.setAttribute("data-poly-shadow-caster", casterId); - } - const polysAttr = JSON.stringify(p.casterPolygonIndices); - if (path.getAttribute("data-poly-shadow-caster-polys") !== polysAttr) { - path.setAttribute("data-poly-shadow-caster-polys", polysAttr); - } - } + if (face.width !== w || face.height !== h) { + svg.setAttribute("width", String(w)); + svg.setAttribute("height", String(h)); + svg.setAttribute("viewBox", `0 0 ${w} ${h}`); + face.width = w; + face.height = h; } + // A single-light face paints its remaining color directly (one path); only + // multi-light SOLID faces need the base + per-pass `multiply` layers for + // correct overlap. Solid multi-light carries shadow strength at the SVG + // level (layers stay opaque so multiply is exact); everything else keeps + // per-path alpha. + const merged = agg.solid && agg.layers.length > 1; + const svgOpacity = merged ? opacity : 1; + const style = + `position:absolute;top:0;left:0;display:block;overflow:hidden;` + + `transform-origin:0 0;pointer-events:none;will-change:transform;` + + `opacity:${svgOpacity.toFixed(4)};transform:${matrixCss}`; + if (face.matrixCss !== style) { svg.setAttribute("style", style); face.matrixCss = style; } if (wantDebug) { - const castersAttr = casterIds.join(" "); - if (svg.getAttribute("data-poly-shadow-casters") !== castersAttr) { - svg.setAttribute("data-poly-shadow-casters", castersAttr); + svg.setAttribute("data-poly-shadow-type", "receiver"); + svg.setAttribute("data-poly-shadow-receiver", meshShadowId(receiverEntry)); + svg.setAttribute("data-poly-shadow-receiver-face", String(faceIndex)); + svg.setAttribute("data-poly-shadow-receiver-polys", JSON.stringify(agg.memberPolyIndices)); + svg.setAttribute("data-poly-shadow-layers", String(agg.layers.length)); + } + + // Rebuild children. Each layer is ONE path (all its polys, nonzero union) + // so within-light overlaps don't double-multiply; cross-light overlaps are + // separate paths that compose. + while (svg.firstChild) svg.removeChild(svg.firstChild); + if (merged) { + // base = full-lit color over the shadow union; each pass multiplies it. + // No stroke: a stroked multiply layer would darken its own outer edge, + // drawing a visible outline. Each layer is already a nonzero union so + // there are no internal seams to feather. + let baseD = ""; + for (const layer of agg.layers) baseD += polysToD(layer.polys, minU, minV); + svg.appendChild(makePath(ctx.doc, baseD, agg.base, false, 1, false)); + for (const layer of agg.layers) { + const factor = multiplyFactor(layer.fill, agg.base); + svg.appendChild(makePath(ctx.doc, polysToD(layer.polys, minU, minV), factor, true, 1, false)); + } + } else { + // One path per pass at its own alpha (single light, or textured). + for (const layer of agg.layers) { + svg.appendChild(makePath(ctx.doc, polysToD(layer.polys, minU, minV), layer.fill, false, layer.opacity, false)); } } } - // Hide faces with no current spec. + // Hide faces with no current content. for (const [idx, face] of mounted) { if (seen.has(idx)) continue; if (face.svg && face.visible) { @@ -352,3 +387,63 @@ export function emitReceiverShadows( } } } + +/** Build an `M…L…Z` path string from face-(u,v) polygons, offset to the SVG's + * tight bbox origin. */ +function polysToD( + polys: ReadonlyArray>, + minU: number, + minV: number, +): string { + let d = ""; + for (const poly of polys) { + if (poly.length < 3) continue; + d += `M${(poly[0]![0] - minU).toFixed(1)},${(poly[0]![1] - minV).toFixed(1)}`; + for (let j = 1; j < poly.length; j++) { + d += `L${(poly[j]![0] - minU).toFixed(1)},${(poly[j]![1] - minV).toFixed(1)}`; + } + d += "Z"; + } + return d; +} + +function makePath( + doc: Document, + d: string, + fill: string, + blendMultiply: boolean, + opacity: number, + stroke: boolean, +): SVGPathElement { + const path = doc.createElementNS(SVG_NS, "path"); + path.setAttribute("d", d); + path.setAttribute("fill", fill); + // Overlapping same-light subpaths union (don't alpha/multiply-stack). + path.setAttribute("fill-rule", "nonzero"); + if (stroke) { + // A 1px stroke of the same color closes sub-pixel seams between adjacent + // projected polygons (a single path's nonzero fill already merges them, + // so this only feathers the outer antialiased edge). + path.setAttribute("stroke", fill); + path.setAttribute("stroke-width", "1"); + path.setAttribute("stroke-linejoin", "round"); + } + if (opacity !== 1) path.setAttribute("opacity", opacity.toFixed(4)); + if (blendMultiply) path.style.mixBlendMode = "multiply"; + return path; +} + +/** Per-channel multiply factor `remaining / full` (both sRGB hex) as `rgb(...)`. + * Painting the base = `full` and this factor with `mix-blend-mode: multiply` + * reproduces `remaining`; overlapping factors multiply to the both-removed + * color. */ +function multiplyFactor(remaining: string, full: string): string { + const a = parseHexColor(remaining)?.rgb ?? [0, 0, 0]; + const b = parseHexColor(full)?.rgb ?? [255, 255, 255]; + const f = (i: number): number => { + const c = b[i] ?? 0; + if (c <= 0) return 255; + return Math.max(0, Math.min(255, Math.round((a[i]! / c) * 255))); + }; + return `rgb(${f(0)},${f(1)},${f(2)})`; +} From 297301ef727076e4ba42ddced05806b623543707 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 20 Jun 2026 02:09:33 +0200 Subject: [PATCH 10/21] fix(shadows): no directional shadow at zero/absent intensity (vanilla); drop implicit-sun fallback --- packages/polycss/src/api/createPolyScene.test.ts | 13 ++++++++++--- packages/polycss/src/api/createPolyScene.ts | 9 +++++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/polycss/src/api/createPolyScene.test.ts b/packages/polycss/src/api/createPolyScene.test.ts index b0f837a6..995c2449 100644 --- a/packages/polycss/src/api/createPolyScene.test.ts +++ b/packages/polycss/src/api/createPolyScene.test.ts @@ -2209,6 +2209,13 @@ describe("createPolyScene", () => { textureLighting: "dynamic" as const, directionalLight: { direction: [0.4, -0.7, 0.59] as [number, number, number], color: "#ffffff", intensity: 1 }, }; + // Baked shadow tests need an explicit directional light: a shadow is only + // emitted for a light that actually removes irradiance (Three.js parity — + // no light, no shadow), so configure one rather than relying on a default. + const bakedOpts = { + textureLighting: "baked" as const, + directionalLight: { direction: [0.4, -0.7, 0.59] as [number, number, number], color: "#ffffff", intensity: 1 }, + }; it("default (no castShadow) emits no .polycss-shadow elements", () => { scene = makeScene(host, dynOpts); @@ -2239,7 +2246,7 @@ describe("createPolyScene", () => { // fill-rule=nonzero, so overlapping CCW outlines composite as one // filled silhouette without alpha stacking while gaps remain holes. // One per mesh regardless of polygon count. - scene = makeScene(host, { textureLighting: "baked" }); + scene = makeScene(host, bakedOpts); scene.add(makeParseResult([floor()]), { receiveShadow: true }); scene.add(makeParseResult([backTriangle()]), { castShadow: true }); const shadows = host.querySelectorAll(".polycss-shadow"); @@ -2314,7 +2321,7 @@ describe("createPolyScene", () => { // where the back-facing piece would have contributed. With SVG // fill-rule=nonzero merging overlap into one solid silhouette, // including the back-facing polys is geometrically correct. - scene = makeScene(host, { textureLighting: "baked" }); + scene = makeScene(host, bakedOpts); scene.add(makeParseResult([floor()]), { receiveShadow: true }); scene.add(makeParseResult([backTriangle()]), { castShadow: true }); expect(host.querySelectorAll(".polycss-shadow").length).toBe(1); @@ -2392,7 +2399,7 @@ describe("createPolyScene", () => { }); it("switching from baked back to dynamic keeps the shadow as a positioned ", () => { - scene = makeScene(host, { textureLighting: "baked" }); + scene = makeScene(host, bakedOpts); scene.add(makeParseResult([floor()]), { receiveShadow: true }); scene.add(makeParseResult([backTriangle()]), { castShadow: true }); const before = host.querySelector(".polycss-shadow") as SVGSVGElement; diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index ba31c924..336d21ab 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -995,8 +995,13 @@ export function createPolyScene( .map((pl, i) => (pl.castShadow ? i : -1)) .filter((i) => i >= 0); const cssPointPositions = shadowPointIndices.map((i) => allPointLightsCss[i]!.position); - const runDirectionalShadow = - !!currentOptions.directionalLight?.direction || shadowPointIndices.length === 0; + // The directional pass runs only for an actual directional light with + // nonzero intensity. Three.js parity: a zero-intensity (or absent) + // directional light removes no light, so a blocked region is indistinct + // from a lit one — no shadow. (The old implicit-sun fallback that drew a + // default-direction shadow when no light was configured is gone.) + const dirLight = currentOptions.directionalLight; + const runDirectionalShadow = !!dirLight?.direction && (dirLight.intensity ?? 1) > 0; const dirKey = quantizeLightDirKey(lightDir); // Fold point-light positions into the short-circuit key so moving (or // toggling) a shadow point light re-emits even when the directional From a56fc5eb6ab908d21421fbf4d71add62a2664a10 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 20 Jun 2026 02:39:10 +0200 Subject: [PATCH 11/21] bench(color): shadow-color delta oracle (oracleColorDelta) + fix default-sun contamination in di=0 comparisons --- bench/point-light-oracle.html | 91 +++++++++++++++++++++++++++++++++-- bench/scripts/color-delta.mjs | 40 +++++++++++++++ 2 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 bench/scripts/color-delta.mjs diff --git a/bench/point-light-oracle.html b/bench/point-light-oracle.html index b3943e3a..9a26ac62 100644 --- a/bench/point-light-oracle.html +++ b/bench/point-light-oracle.html @@ -195,11 +195,12 @@ autoCenter: qs("ax", "0") === "1", }; - // Directional light for PolyCSS — omitted entirely when intensity ≤ 0 so - // its (intensity-independent) cast-shadow SVG doesn't appear while three.js - // shows nothing. Lets `di=0` isolate the point-light shadows for parity. + // Directional light for PolyCSS — always passed (even at intensity 0) so + // PolyCSS does true ambient-only shading instead of falling back to its + // default sun when directionalLight is omitted. The engine already gates + // the cast-shadow SVG on intensity > 0, so intensity 0 = no shadow, + // matching three.js. Lets `di=0` isolate ambient + point lights. function polyDirectional() { - if (S.dir.intensity <= 0) return undefined; return { direction: [S.dir.x, S.dir.y, S.dir.z], intensity: S.dir.intensity, @@ -2931,6 +2932,88 @@ return json; }; + // ── Shadow COLOR calibration ──────────────────────────────────────── + // NORMAL render (real materials, helpers hidden). Aligns the PolyCSS + // screenshot with the three.js canvas at host resolution, excludes the + // (reddish) cube, finds a lit-floor reference, and reports the per-channel + // delta (PolyCSS − three.js) separately for SHADOW pixels vs LIT floor. + // Paints a signed-delta heatmap into the diff pane (gray = match, the + // delta's own hue shows where/how PolyCSS diverges). + window.oracleColorDelta = async (polyUrl) => { + const hostRect = polyHost.getBoundingClientRect(); + const W = Math.round(hostRect.width), H = Math.round(hostRect.height); + if (W < 2 || H < 2) return null; + renderer.render(threeScene, camera); + const polyPx = await dataUrlToPolyPx(polyUrl, W, H); + const tb = document.createElement("canvas"); tb.width = W; tb.height = H; + const tctx = tb.getContext("2d"); + tctx.drawImage(threeCanvas, 0, 0, threeCanvas.width, threeCanvas.height, 0, 0, W, H); + const threePx = tctx.getImageData(0, 0, W, H).data; + + const luma = (d, i) => 0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2]; + // Cube is strongly red-dominant; floor is gray/purple. Skip cube (and a + // dilated edge) in EITHER engine to avoid silhouette-AA noise. + const isCube = (d, i) => d[i] - Math.max(d[i + 1], d[i + 2]) > 30; + const isFloor = (p) => !isCube(polyPx, p * 4) && !isCube(threePx, p * 4); + + // Lit-floor reference = 75th-percentile luma of three's floor pixels. + const litSamples = []; + for (let p = 0; p < W * H; p++) if (isFloor(p)) litSamples.push(luma(threePx, p * 4)); + litSamples.sort((a, b) => a - b); + const litRef = litSamples.length ? litSamples[Math.floor(litSamples.length * 0.75)] : 255; + const shadowCut = litRef * 0.92; // 8% darker than lit floor → shadowed + + const heat = diffCtx.createImageData(W, H); + const AMP = 4; + let shN = 0, litN = 0; + const shSum = [0, 0, 0], litSum = [0, 0, 0]; + let shAbs = 0, shMax = 0; + for (let p = 0; p < W * H; p++) { + const i = p * 4; + let r = 128, g = 128, b = 128, a = 255; + if (isFloor(p)) { + const dR = polyPx[i] - threePx[i]; + const dG = polyPx[i + 1] - threePx[i + 1]; + const dB = polyPx[i + 2] - threePx[i + 2]; + r = Math.max(0, Math.min(255, 128 + dR * AMP)); + g = Math.max(0, Math.min(255, 128 + dG * AMP)); + b = Math.max(0, Math.min(255, 128 + dB * AMP)); + const shadowed = luma(threePx, i) < shadowCut || luma(polyPx, i) < shadowCut; + if (shadowed) { + shN++; shSum[0] += dR; shSum[1] += dG; shSum[2] += dB; + const m = (Math.abs(dR) + Math.abs(dG) + Math.abs(dB)) / 3; + shAbs += m; if (m > shMax) shMax = m; + } else { + litN++; litSum[0] += dR; litSum[1] += dG; litSum[2] += dB; + } + } else { + a = 40; // dim the cube region in the heatmap + } + heat.data[i] = r; heat.data[i + 1] = g; heat.data[i + 2] = b; heat.data[i + 3] = a; + } + const tmp = document.createElement("canvas"); tmp.width = W; tmp.height = H; + tmp.getContext("2d").putImageData(heat, 0, 0); + diffCtx.setTransform(1, 0, 0, 1, 0, 0); + diffCtx.fillStyle = "#0f172a"; diffCtx.fillRect(0, 0, diffCanvas.width, diffCanvas.height); + diffCtx.drawImage(tmp, 0, 0, diffCanvas.width, diffCanvas.height); + + const mean = (s, n) => n ? s.map((v) => +(v / n).toFixed(1)) : [0, 0, 0]; + const json = { + litRef: +litRef.toFixed(1), + shadowPx: shN, + litPx: litN, + // PolyCSS − three.js, per channel (positive = PolyCSS brighter). + shadowMeanDelta: mean(shSum, shN), + litMeanDelta: mean(litSum, litN), + shadowMeanAbs: shN ? +(shAbs / shN).toFixed(1) : 0, + shadowMaxAbs: +shMax.toFixed(1), + }; + oracleStatus.textContent = + `[color] shadow Δ ${json.shadowMeanDelta.join("/")} (|${json.shadowMeanAbs}|) · lit Δ ${json.litMeanDelta.join("/")}`; + window.__oracleColor = json; + return json; + }; + // Live mode — opt-in. Re-runs compareFrame after every update() call // (debounced 350 ms to let the async atlas rebake settle). Off by // default because the snapshot + per-pixel diff costs ~500-1500 ms diff --git a/bench/scripts/color-delta.mjs b/bench/scripts/color-delta.mjs new file mode 100644 index 00000000..45bdac9f --- /dev/null +++ b/bench/scripts/color-delta.mjs @@ -0,0 +1,40 @@ +#!/usr/bin/env node +/** + * Shadow COLOR calibration — PolyCSS vs three.js. + * + * Renders both engines in NORMAL mode (real materials), aligns them at host + * resolution, and reports the per-channel color delta (PolyCSS − three.js) + * split into SHADOW pixels vs LIT floor, plus a signed-delta heatmap. + * + * Usage: node bench/scripts/color-delta.mjs "" [outPrefix] + * Server must be running (default :4322). + */ +import { chromium } from "playwright"; +import { writeFileSync } from "node:fs"; + +const url = process.argv[2]; +if (!url) { console.error("usage: color-delta.mjs [outPrefix]"); process.exit(2); } +const prefix = process.argv[3] ?? "/tmp/color-delta"; + +const browser = await chromium.launch({ args: ["--use-angle=metal", "--enable-gpu-rasterization"] }); +const ctx = await browser.newContext({ viewport: { width: 1500, height: 950 }, deviceScaleFactor: 2 }); +const page = await ctx.newPage(); +page.on("pageerror", (e) => console.error("[pageerror]", e.message)); +await page.goto(url, { waitUntil: "networkidle", timeout: 30000 }); +await page.waitForTimeout(6500); +await page.evaluate(() => window.oracleSetHelpersHidden(true)); +await page.waitForTimeout(400); + +const shot = await (await page.$("#poly-host")).screenshot(); +const json = await page.evaluate( + (d) => window.oracleColorDelta(d), + "data:image/png;base64," + shot.toString("base64"), +); + +await (await page.$(".stage.polycss")).screenshot({ path: `${prefix}-poly.png` }); +await (await page.$(".stage.three")).screenshot({ path: `${prefix}-three.png` }); +await (await page.$(".stage.diff")).screenshot({ path: `${prefix}-heat.png` }); +writeFileSync(`${prefix}.json`, JSON.stringify(json, null, 2)); +console.log(JSON.stringify(json, null, 2)); +console.error(`wrote ${prefix}-poly.png ${prefix}-three.png ${prefix}-heat.png ${prefix}.json`); +await browser.close(); From d1707b0411e33c2fb5bd346491a816e29c9e838b Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 20 Jun 2026 02:47:54 +0200 Subject: [PATCH 12/21] =?UTF-8?q?fix(shadows):=20dynamic=20mode=20ignores?= =?UTF-8?q?=20point=20lights=20for=20shadows=20too=20(no=20colored=20shado?= =?UTF-8?q?ws=20on=20unlit=20floor)=20=E2=80=94=20vanilla?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/polycss/src/api/createPolyScene.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index 336d21ab..d663b178 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -980,18 +980,23 @@ export function createPolyScene( // frame. invalidateShadowLightCache() is called by every code path that // mutates caster/receiver geometry or shadow appearance, so a cache hit // here means "same light, same scene → previous SVG content is still valid". - // Shadow-casting point lights, converted to CSS-frame positions. Only - // lights with castShadow:true project (Three.js parity); shading-only - // point lights never reach the shadow path. + // Point lights are baked-mode only (they don't drive dynamic-mode surface + // shading), so in dynamic mode they must not drive shadows either — + // otherwise colored point shadows appear over a floor the same lights + // never lit, which reads as broken. Dynamic mode → directional shadows + // only (ambient fill). Baked mode → full point participation. + const pointLightsForShadow = currentOptions.textureLighting === "dynamic" + ? [] + : (currentOptions.pointLights ?? []); // ALL point lights in CSS frame — the shaded shadow color needs every // light that illuminates the receiver (even non-casters), minus the one // being shadowed. `shadowPointIndices` are the entries that cast. - const allPointLightsCss = (currentOptions.pointLights ?? []).map((pl) => ({ + const allPointLightsCss = pointLightsForShadow.map((pl) => ({ position: worldPositionToCss(pl.position), color: pl.color, intensity: pl.intensity, })); - const shadowPointIndices = (currentOptions.pointLights ?? []) + const shadowPointIndices = pointLightsForShadow .map((pl, i) => (pl.castShadow ? i : -1)) .filter((i) => i >= 0); const cssPointPositions = shadowPointIndices.map((i) => allPointLightsCss[i]!.position); From a210eb2b586c061f5ff286b3f7d52addbe82e445 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 20 Jun 2026 02:51:50 +0200 Subject: [PATCH 13/21] docs: dynamic mode ignores point lights for shadows too (directional-only) --- AGENTS.md | 4 ++-- website/src/content/docs/api/types.mdx | 3 ++- website/src/content/docs/components/poly-scene.mdx | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 58795d29..eb2e7f4b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,12 +49,12 @@ The `.vox` fast path emits plain `` elements inside `.polycss-voxel-face` wra ### Lights -The scene takes one `directionalLight`, one `ambientLight`, and zero or more `pointLights` (`PolyPointLight[]`). Point lights are **direction-only** — no distance falloff. Per polygon the contribution is `color · intensity · max(0, n · L̂)`, where `L̂` is the unit direction from the surface to the light position; multiple colored lights accumulate per-channel alongside the directional + ambient terms. This deliberately omits CSS gradients: point lights shade flat-per-face (an accepted approximation vs three.js's per-fragment `PointLight(distance:0, decay:0)`; exact for small faces / distant lights). Point lights are **baked-mode only** — the dynamic mode's zero-JS light move can't express a per-face direction that varies with position, so dynamic scenes ignore `pointLights` for surface shading. +The scene takes one `directionalLight`, one `ambientLight`, and zero or more `pointLights` (`PolyPointLight[]`). Point lights are **direction-only** — no distance falloff. Per polygon the contribution is `color · intensity · max(0, n · L̂)`, where `L̂` is the unit direction from the surface to the light position; multiple colored lights accumulate per-channel alongside the directional + ambient terms. This deliberately omits CSS gradients: point lights shade flat-per-face (an accepted approximation vs three.js's per-fragment `PointLight(distance:0, decay:0)`; exact for small faces / distant lights). Point lights are **baked-mode only** — the dynamic mode's zero-JS light move can't express a per-face direction that varies with position, so dynamic scenes ignore `pointLights` entirely: not for surface shading, and not for shadows. (A point light casting a shadow onto a floor those same lights never lit would read as broken, so dynamic shadows are directional-only — see the lighting modes below.) ### Lighting modes (`PolyTextureLightingMode = "baked" | "dynamic"`) - **Baked.** Lambert (directional + each point light + ambient) is computed once on the CPU per polygon, multiplied into the inline `color` (for ``/``/``) or into the rasterised atlas pixels (for atlas-backed ``). Direct image `` leaves preserve source pixels and use `texturePresentation.lighting="source"`; scene-lit direct images fall back to the atlas path. Moving a light requires explicit re-rasterising of affected lit atlas polys via `mesh.rebakeAtlas()`. Cast shadows are independent SVG projections and can re-emit without atlas redraw, so shadows can follow the light interactively even when lit-side shading stays frozen. -- **Dynamic.** Scene root carries the directional + ambient setup as custom properties (`--plx/y/z`, `--plr/g/b`, `--pli`, `--par/g/b`, `--pai`). Each leaf embeds its surface normal (`--pnx/y/z`) and base color (`--psr/g/b`) inline. CSS `calc()` resolves the Lambert dot product and per-channel tint at paint time. Moving a light mutates scene-root vars for surface lighting — zero JS, no atlas redraw. Point lights are not represented in dynamic surface shading (see above). Cast shadows still use CPU-projected SVG paths and re-emit when a directional or point light changes. +- **Dynamic.** Scene root carries the directional + ambient setup as custom properties (`--plx/y/z`, `--plr/g/b`, `--pli`, `--par/g/b`, `--pai`). Each leaf embeds its surface normal (`--pnx/y/z`) and base color (`--psr/g/b`) inline. CSS `calc()` resolves the Lambert dot product and per-channel tint at paint time. Moving a light mutates scene-root vars for surface lighting — zero JS, no atlas redraw. Point lights are not represented in dynamic mode at all — neither surface shading nor shadows (see above). Cast shadows are **directional-only** in dynamic mode (CPU-projected SVG paths, ambient fill) and re-emit when the directional light changes. All solid and atlas-backed tags work in both modes. Direct image `` leaves are source-lit only; callers that need scene lighting use the atlas backend. The `.vox` direct-matrix fast path is baked-only for now; dynamic mode uses the polygon path so lighting semantics stay correct. The full coverage matrix is in `packages/polycss/src/styles/styles.ts`. diff --git a/website/src/content/docs/api/types.mdx b/website/src/content/docs/api/types.mdx index 8bb221f2..3fa0d63f 100644 --- a/website/src/content/docs/api/types.mdx +++ b/website/src/content/docs/api/types.mdx @@ -99,7 +99,8 @@ A positional light source. **Direction-only** — there is no distance falloff; per polygon the contribution is `color · intensity · max(0, n · L̂)` where `L̂` is the unit direction from the surface to `position`. Shading is flat per face (an approximation of three.js's `PointLight(distance: 0, decay: 0)`), and is -**baked-mode only** — dynamic lighting ignores `pointLights` for surface shading. +**baked-mode only** — dynamic lighting ignores `pointLights` entirely (neither +surface shading nor shadows; dynamic-mode cast shadows are directional-only). ```ts interface PolyPointLight { diff --git a/website/src/content/docs/components/poly-scene.mdx b/website/src/content/docs/components/poly-scene.mdx index b255c9ee..02ae7f2e 100644 --- a/website/src/content/docs/components/poly-scene.mdx +++ b/website/src/content/docs/components/poly-scene.mdx @@ -162,7 +162,7 @@ import { PolyPerspectiveCamera, PolyScene, PolyTorus, PolyBox } from "@layoutit/ ### Cast Shadows -Shadows are SVG-projected surfaces. They work in both lighting modes; `textureLighting` controls surface shading, while shadow geometry reprojects when the light or scene geometry changes. +Shadows are SVG-projected surfaces that reproject when the light or scene geometry changes. Directional-light shadows work in both lighting modes. Point-light shadows are **baked mode only** — like point-light shading, they're omitted in dynamic mode (a colored point shadow over a floor those lights never lit would look broken), so dynamic-mode shadows are directional-only. Where multiple lights overlap on a face, the shadows composite to the correct both-blocked color (each light shows the others' color where it alone is blocked). ```tsx import { PolyCamera, PolyScene, PolyGround, PolyMesh } from "@layoutit/polycss-react"; From 1cb3708f4172a2ec6dc1b48d7c624c722be3d04c Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sat, 20 Jun 2026 03:23:26 +0200 Subject: [PATCH 14/21] feat(shadows): share merged per-face shadow path across all renderers (core computeMergedReceiverShadows); colored overlap + dynamic + intensity gate in react/vue --- AGENTS.md | 2 +- packages/core/src/index.ts | 4 + .../core/src/shadow/computeReceiverShadows.ts | 204 ++++++++++++++++++ .../src/shadow/mergedReceiverShadows.test.ts | 107 +++++++++ .../polycss/src/api/scene/receiverShadow.ts | 191 ++++------------ packages/react/src/scene/PolyMesh.tsx | 153 +++++++------ packages/vue/src/scene/PolyMesh.ts | 142 ++++++------ 7 files changed, 493 insertions(+), 310 deletions(-) create mode 100644 packages/core/src/shadow/mergedReceiverShadows.test.ts diff --git a/AGENTS.md b/AGENTS.md index eb2e7f4b..b0c5b51b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ Strategies are ordered cheapest → most expensive. The mesher's job is to maxim 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 ``, because it belongs to the non-triangle clipped-solid family. `` 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. -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. The directional light projects in parallel; each `pointLights` entry with `castShadow: true` casts an additional **radial** shadow (each vertex projected along its own ray from the light position), and point-light passes always project the caster *silhouette* — projecting individual back-faces leaves the contact footprint unshadowed under radial divergence. Shadows are **shaded, not flat black**: each light's shadow is filled with the receiver lit by every OTHER light (the blocked light removed), so a region shadowed from one colored light still shows the remaining lights' color (Three.js colored shadows). A lone directional light reduces this to the ambient-only fill (unchanged). All of a receiver FACE's lights are merged into **one SVG per face** so overlapping shadows composite correctly: a single-light face paints its remaining color directly (one path); a multi-light solid face paints a base = full-lit color `C` then each light as a `mix-blend-mode: multiply` layer with factor `remaining/C`, so the both-blocked overlap becomes `C·∏factor` (ambient only). `mix-blend-mode` works *within* one SVG but NOT across SVGs (`preserve-3d` isolates each SVG against a transparent backdrop — verified), which is why the merge is per-face rather than per-light. Textured receivers (per-pixel base, no uniform multiply) fall back to per-pass alpha layers that cumulatively darken. The per-face color uses the face CENTROID direction (matching the baked per-polygon shading) so the base leaves no visible color box. **Status:** the per-face merge currently lives in the vanilla renderer only; React/Vue still emit one SVG per light (approximate overlap) pending migration. 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. +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. The directional light projects in parallel; each `pointLights` entry with `castShadow: true` casts an additional **radial** shadow (each vertex projected along its own ray from the light position), and point-light passes always project the caster *silhouette* — projecting individual back-faces leaves the contact footprint unshadowed under radial divergence. Shadows are **shaded, not flat black**: each light's shadow is filled with the receiver lit by every OTHER light (the blocked light removed), so a region shadowed from one colored light still shows the remaining lights' color (Three.js colored shadows). A lone directional light reduces this to the ambient-only fill (unchanged). All of a receiver FACE's lights are merged into **one SVG per face** so overlapping shadows composite correctly: a single-light face paints its remaining color directly (one path); a multi-light solid face paints a base = full-lit color `C` then each light as a `mix-blend-mode: multiply` layer with factor `remaining/C`, so the both-blocked overlap becomes `C·∏factor` (ambient only). `mix-blend-mode` works *within* one SVG but NOT across SVGs (`preserve-3d` isolates each SVG against a transparent backdrop — verified), which is why the merge is per-face rather than per-light. Textured receivers (per-pixel base, no uniform multiply) fall back to per-pass alpha layers that cumulatively darken. The per-face color uses the face CENTROID direction (matching the baked per-polygon shading) so the base leaves no visible color box. The per-face merge is the shared core helper `computeMergedReceiverShadows` (runs every light pass + aggregates each face into one SVG descriptor); all three renderers call it and only emit the ``/`` nodes, so multi-light overlap is identical everywhere. 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. 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. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2d0c9211..62d8a228 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -213,6 +213,7 @@ export { buildPolySceneTransform } from "./transform/sceneTransform"; export type { PolySceneTransformInput } from "./transform/sceneTransform"; export { buildSharedEdgeMap, + computeMergedReceiverShadows, computeReceiverShadowFaces, prepareCasterEdgeOwners, prepareCasterPolyItems, @@ -221,6 +222,9 @@ export { export type { CasterPolyItem, ComputeReceiverShadowFacesInput, + MergedReceiverShadowInput, + MergedShadowFace, + MergedShadowLayer, ReceiverCasterInput, ReceiverFacePlane, ReceiverShadowFaceSpec, diff --git a/packages/core/src/shadow/computeReceiverShadows.ts b/packages/core/src/shadow/computeReceiverShadows.ts index 4835f13b..6e7fb411 100644 --- a/packages/core/src/shadow/computeReceiverShadows.ts +++ b/packages/core/src/shadow/computeReceiverShadows.ts @@ -15,6 +15,7 @@ import { BASE_TILE } from "../camera/camera"; import { normalFacesCamera, type CameraCullRotation } from "../cull/cameraBackfaceCulling"; import { shadePolygon } from "../atlas/paintDefaults"; +import { parseHexColor } from "../color/color"; import { rotateVec3InWrapperCssFrame } from "../math/rotation"; import { clipPolygonToConvex2D } from "./clipping"; import { ensureCcw2D } from "./projection"; @@ -1148,5 +1149,208 @@ export function computeReceiverShadowFaces( return out; } +// ── Multi-light per-face merge ────────────────────────────────────────────── +// All renderers share this so a receiver FACE's lights merge into ONE SVG: +// overlapping shadows then composite correctly (single light → one path with +// the remaining-light color; multi-light solid → a full-lit base + one +// `mix-blend-mode: multiply` layer per light, so the both-blocked overlap +// becomes base·∏factor). Pure data in → DOM-ready descriptors out; each +// renderer only emits the / nodes. + +/** One path inside a merged face SVG. */ +export interface MergedShadowLayer { + /** Path data (`M…L…Z`, already offset to the SVG's tight bbox). */ + d: string; + /** Fill color (remaining-light color for a single layer, multiply factor for + * a merged solid layer, dark shadow color for textured). */ + fill: string; + /** Apply `mix-blend-mode: multiply` (merged solid layers only). */ + multiply: boolean; + /** Per-path opacity (1 for merged solid layers, which carry strength on the + * SVG; the pass's own opacity otherwise). */ + opacity: number; +} + +/** One receiver FACE's merged shadow, ready to mount as a single SVG. */ +export interface MergedShadowFace { + faceIndex: number; + memberPolyIndices: number[]; + matrixCss: string; + width: number; + height: number; + /** SVG-level opacity (shadow strength for merged solid faces, else 1). */ + svgOpacity: number; + /** Full-lit base path (merged solid faces only); null otherwise. */ + baseFill: string | null; + baseD: string | null; + layers: MergedShadowLayer[]; +} + +/** Inputs for `computeMergedReceiverShadows` — the full light set for one + * receiver, plus its prepared face planes and casters. */ +export interface MergedReceiverShadowInput { + receiverPlanes: ReceiverFacePlane[]; + receiverPolygons: readonly Polygon[]; + receiverHasTexture: boolean; + casters: ReceiverCasterInput[]; + /** Directional light vector in CSS frame (to-source). */ + lightDir: Vec3; + /** Run the directional pass (caller gates on a real, nonzero-intensity + * directional light). */ + runDirectional: boolean; + /** One pass per shadow-casting point light: CSS position + index into + * `allPointLights`. Empty in dynamic mode (point lights are baked-only). */ + pointPasses: ReadonlyArray<{ lightPos: Vec3; index: number }>; + /** All point lights (CSS positions) for the shaded shadow color; empty in + * dynamic mode. */ + allPointLights?: ReadonlyArray<{ position: Vec3; color?: string; intensity?: number }>; + cameraRot: CameraCullRotation; + ambientLight?: PolyAmbientLight; + directionalLight?: PolyDirectionalLight; + shadow?: { color?: string; opacity?: number; maxExtend?: number }; +} + +/** Per-channel multiply factor `remaining / full` (both sRGB hex) as `rgb(...)`. + * Painting the base = `full` then this with `mix-blend-mode: multiply` + * reproduces `remaining`; overlapping factors multiply to the both-removed + * color. */ +function shadowMultiplyFactor(remaining: string, full: string): string { + const a = parseHexColor(remaining)?.rgb ?? [0, 0, 0]; + const b = parseHexColor(full)?.rgb ?? [255, 255, 255]; + const f = (i: number): number => { + const c = b[i] ?? 0; + if (c <= 0) return 255; + return Math.max(0, Math.min(255, Math.round((a[i]! / c) * 255))); + }; + return `rgb(${f(0)},${f(1)},${f(2)})`; +} + +/** Build an `M…L…Z` path from face-(u,v) polygons offset to (minU, minV). */ +function polysToPathD( + polys: ReadonlyArray>, + minU: number, + minV: number, +): string { + let d = ""; + for (const poly of polys) { + if (poly.length < 3) continue; + d += `M${(poly[0]![0] - minU).toFixed(1)},${(poly[0]![1] - minV).toFixed(1)}`; + for (let j = 1; j < poly.length; j++) { + d += `L${(poly[j]![0] - minU).toFixed(1)},${(poly[j]![1] - minV).toFixed(1)}`; + } + d += "Z"; + } + return d; +} + +/** + * Run every light pass for one receiver and merge each face's passes into a + * single SVG descriptor. Shared by all three renderers so multi-light shadow + * overlap is identical everywhere. + */ +export function computeMergedReceiverShadows( + input: MergedReceiverShadowInput, +): MergedShadowFace[] { + const { + receiverPlanes, receiverPolygons, receiverHasTexture, casters, + lightDir, runDirectional, pointPasses, allPointLights, + cameraRot, ambientLight, directionalLight, shadow, + } = input; + const shadowOpacity = shadow?.opacity ?? 0.25; + + const planeByFace = new Map(); + for (const pl of receiverPlanes) planeByFace.set(pl.faceIndex, pl); + + type Layer = { polys: Array>; fill: string; opacity: number }; + type FaceAgg = { memberPolyIndices: number[]; base: string; solid: boolean; layers: Layer[] }; + const perFace = new Map(); + + const runPass = (lightPos: Vec3 | undefined, thisPointIndex: number | undefined): void => { + const specs = computeReceiverShadowFaces({ + receiverPlanes, receiverPolygons, receiverHasTexture, casters, + lightDir, lightPos, allPointLights, thisPointIndex, + cameraRot, ambientLight, directionalLight, shadow, + }); + for (const spec of specs) { + if (spec.facePolysUv.length === 0) continue; + const solid = spec.fullLitFill !== ""; + let agg = perFace.get(spec.faceIndex); + if (!agg) { + agg = { memberPolyIndices: spec.memberPolyIndices, base: spec.fullLitFill, solid, layers: [] }; + perFace.set(spec.faceIndex, agg); + } + agg.layers.push({ polys: spec.facePolysUv, fill: spec.fill, opacity: spec.opacity }); + } + }; + if (runDirectional) runPass(undefined, undefined); + for (const p of pointPasses) runPass(p.lightPos, p.index); + + const out: MergedShadowFace[] = []; + for (const [faceIndex, agg] of perFace) { + const plane = planeByFace.get(faceIndex); + if (!plane || agg.layers.length === 0) continue; + let minU = Infinity, minV = Infinity, maxU = -Infinity, maxV = -Infinity; + for (const layer of agg.layers) for (const poly of layer.polys) for (const pt of poly) { + if (pt[0] < minU) minU = pt[0]; + if (pt[0] > maxU) maxU = pt[0]; + if (pt[1] < minV) minV = pt[1]; + if (pt[1] > maxV) maxV = pt[1]; + } + if (!Number.isFinite(minU)) continue; + minU = Math.floor(minU - 1); minV = Math.floor(minV - 1); + maxU = Math.ceil(maxU + 1); maxV = Math.ceil(maxV + 1); + const width = maxU - minU, height = maxV - minV; + if (!(width > 0) || !(height > 0)) continue; + + const { O, u, v, n, lift } = plane; + const tx = O[0] + minU * u[0] + minV * v[0] + lift * n[0]; + const ty = O[1] + minU * u[1] + minV * v[1] + lift * n[1]; + const tz = O[2] + minU * u[2] + minV * v[2] + lift * n[2]; + const m = [u[0], u[1], u[2], 0, v[0], v[1], v[2], 0, n[0], n[1], n[2], 0, tx, ty, tz, 1]; + const matrixCss = `matrix3d(${m.map((x) => x.toFixed(4)).join(",")})`; + + // Merge (base + multiply) only when a SOLID face has ≥2 lights; otherwise + // one path per pass at its own alpha (single light, or textured). + const merged = agg.solid && agg.layers.length > 1; + let baseFill: string | null = null; + let baseD: string | null = null; + const layers: MergedShadowLayer[] = []; + if (merged) { + baseFill = agg.base; + baseD = ""; + for (const layer of agg.layers) baseD += polysToPathD(layer.polys, minU, minV); + for (const layer of agg.layers) { + layers.push({ + d: polysToPathD(layer.polys, minU, minV), + fill: shadowMultiplyFactor(layer.fill, agg.base), + multiply: true, + opacity: 1, + }); + } + } else { + for (const layer of agg.layers) { + layers.push({ + d: polysToPathD(layer.polys, minU, minV), + fill: layer.fill, + multiply: false, + opacity: layer.opacity, + }); + } + } + out.push({ + faceIndex, + memberPolyIndices: agg.memberPolyIndices, + matrixCss, + width, + height, + svgOpacity: merged ? shadowOpacity : 1, + baseFill, + baseD, + layers, + }); + } + return out; +} + /** Re-export caster scale helper for caller convenience. */ export { meshScaleVec3 }; diff --git a/packages/core/src/shadow/mergedReceiverShadows.test.ts b/packages/core/src/shadow/mergedReceiverShadows.test.ts new file mode 100644 index 00000000..8c455f5d --- /dev/null +++ b/packages/core/src/shadow/mergedReceiverShadows.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from "vitest"; +import { + computeMergedReceiverShadows, + prepareCasterPolyItems, + prepareReceiverFacePlanes, +} from "./computeReceiverShadows"; +import { worldPositionToCss } from "./receiverFaceGroups"; +import type { Polygon, Vec3 } from "../types"; + +// A floor receiver (one big quad) + a small caster just above it. The floor is +// a single coplanar group → one face, so every light's shadow lands on it and +// the merge has to combine them. +const floor: Polygon = { + vertices: [[-10, -10, -0.1], [10, -10, -0.1], [10, 10, -0.1], [-10, 10, -0.1]], + color: "#888888", +}; +const caster: Polygon = { + vertices: [[0, 0, 0], [0, 1, 0], [1, 0, 0]], + color: "#00ff00", +}; +const cssLight: Vec3 = [0.5, 0.5, 1]; // CSS-frame to-source, lights the floor +const cameraRot = { rotX: 30, rotY: 0 }; + +function setup() { + const receiverPlanes = prepareReceiverFacePlanes( + [floor], [0, 0, 0], 1, new Set(), 0.001, null, + ); + const items = prepareCasterPolyItems([caster], [0, 0, 0], 1, () => true, null); + const casters = [{ id: "c", items, casterPolygonCount: 1 }]; + return { receiverPlanes, casters }; +} + +describe("computeMergedReceiverShadows", () => { + it("single directional light → one path per face, no base, no multiply", () => { + const { receiverPlanes, casters } = setup(); + const faces = computeMergedReceiverShadows({ + receiverPlanes, + receiverPolygons: [floor], + receiverHasTexture: false, + casters, + lightDir: cssLight, + runDirectional: true, + pointPasses: [], + allPointLights: [], + cameraRot, + directionalLight: { direction: [0.5, 0.5, 1], intensity: 1 }, + ambientLight: { intensity: 0.4 }, + shadow: { opacity: 0.25 }, + }); + expect(faces.length).toBeGreaterThan(0); + const f = faces[0]!; + expect(f.baseFill).toBeNull(); + expect(f.baseD).toBeNull(); + expect(f.layers.length).toBe(1); + expect(f.layers[0]!.multiply).toBe(false); + // Single solid layer carries the shadow strength on the path, SVG at 1. + expect(f.svgOpacity).toBe(1); + expect(f.layers[0]!.opacity).toBeCloseTo(0.25, 4); + }); + + it("directional + shadow-casting point light → merged face: base + multiply layers", () => { + const { receiverPlanes, casters } = setup(); + const pointCss = worldPositionToCss([0, 0, 6]); // above the caster + const faces = computeMergedReceiverShadows({ + receiverPlanes, + receiverPolygons: [floor], + receiverHasTexture: false, + casters, + lightDir: cssLight, + runDirectional: true, + pointPasses: [{ lightPos: pointCss, index: 0 }], + allPointLights: [{ position: pointCss, color: "#5599ff", intensity: 1 }], + cameraRot, + directionalLight: { direction: [0.5, 0.5, 1], intensity: 1 }, + ambientLight: { intensity: 0.4 }, + shadow: { opacity: 1 }, + }); + expect(faces.length).toBeGreaterThan(0); + const merged = faces.find((f) => f.layers.length > 1); + expect(merged).toBeDefined(); + // Merged solid face: a full-lit base path + one multiply layer per light. + expect(merged!.baseFill).not.toBeNull(); + expect(merged!.baseD).not.toBeNull(); + expect(merged!.layers.every((l) => l.multiply)).toBe(true); + // Strength rides the SVG; layers stay opaque so the multiply is exact. + expect(merged!.svgOpacity).toBeCloseTo(1, 4); + expect(merged!.layers.every((l) => l.opacity === 1)).toBe(true); + }); + + it("no lights → no faces", () => { + const { receiverPlanes, casters } = setup(); + const faces = computeMergedReceiverShadows({ + receiverPlanes, + receiverPolygons: [floor], + receiverHasTexture: false, + casters, + lightDir: cssLight, + runDirectional: false, + pointPasses: [], + allPointLights: [], + cameraRot, + ambientLight: { intensity: 0.4 }, + shadow: { opacity: 0.25 }, + }); + expect(faces.length).toBe(0); + }); +}); diff --git a/packages/polycss/src/api/scene/receiverShadow.ts b/packages/polycss/src/api/scene/receiverShadow.ts index d9575912..51315a45 100644 --- a/packages/polycss/src/api/scene/receiverShadow.ts +++ b/packages/polycss/src/api/scene/receiverShadow.ts @@ -10,9 +10,8 @@ */ import { buildSharedEdgeMap, - computeReceiverShadowFaces, + computeMergedReceiverShadows, meshScaleVec3, - parseHexColor, prepareCasterEdgeOwners, prepareCasterPolyItems, prepareReceiverFacePlanes, @@ -238,86 +237,35 @@ export function emitReceiverShadows( meshRotation: receiverEntry.handle.transform.rotation, }; - // Plane basis lookup by faceIndex (cachedPlanes is occlusion-filtered). - const planeByFace = new Map(); - for (const pl of cachedPlanes) planeByFace.set(pl.faceIndex, pl); - - // Aggregate every light pass per receiver FACE, so a face's coplanar shadows - // share one SVG and overlaps composite correctly. Solid receivers paint a - // base = full-lit color C, then each pass as a `multiply` layer with factor - // (remaining/C) — overlaps become C·fA·fB (both lights removed). Textured - // receivers (per-pixel base, no uniform multiply) fall back to per-pass dark - // alpha layers, which cumulatively darken. - // `fill` is the per-pass remaining-light color (receiver lit by all OTHER - // lights). Single-layer faces paint it directly (one path, like before); - // multi-layer faces multiply it against the base as `fill/base`. - type Layer = { polys: Array>; fill: string; opacity: number }; - type FaceAgg = { memberPolyIndices: number[]; base: string; solid: boolean; layers: Layer[] }; - const perFace = new Map(); - - const runPass = (lightPos: Vec3 | undefined, thisPointIndex: number | undefined): void => { - const specs = computeReceiverShadowFaces({ - receiverPlanes: cachedPlanes, - receiverPolygons: receiverEntry.polygons, - receiverHasTexture: hasTexture, - casters: casterInputs, - lightDir, - lightPos, - allPointLights: passes.allPointLights, - thisPointIndex, - cameraRot, - ambientLight: options.ambientLight, - directionalLight: options.directionalLight, - shadow: { color: options.shadow?.color, opacity, maxExtend: options.shadow?.maxExtend }, - }); - for (const spec of specs) { - if (spec.facePolysUv.length === 0) continue; - const solid = spec.fullLitFill !== ""; - let agg = perFace.get(spec.faceIndex); - if (!agg) { - agg = { memberPolyIndices: spec.memberPolyIndices, base: spec.fullLitFill, solid, layers: [] }; - perFace.set(spec.faceIndex, agg); - } - agg.layers.push({ polys: spec.facePolysUv, fill: spec.fill, opacity: spec.opacity }); - } - }; - if (passes.runDirectional) runPass(undefined, undefined); - for (const p of passes.points) runPass(p.lightPos, p.index); + // Shared core merge: run every light pass for this receiver and aggregate + // each face's passes into one SVG descriptor (single light → one path; + // multi-light solid → base + per-light multiply layers for correct overlap). + const faces = computeMergedReceiverShadows({ + receiverPlanes: cachedPlanes, + receiverPolygons: receiverEntry.polygons, + receiverHasTexture: hasTexture, + casters: casterInputs, + lightDir, + runDirectional: passes.runDirectional, + pointPasses: passes.points, + allPointLights: passes.allPointLights, + cameraRot, + ambientLight: options.ambientLight, + directionalLight: options.directionalLight, + shadow: { color: options.shadow?.color, opacity, maxExtend: options.shadow?.maxExtend }, + }); const mounted = mountedFacesFor(receiverEntry, "m"); const seen = new Set(); const wantDebug = !!options.debugShadowAttrs; const shadowRoot = ensureShadowRoot(ctx.shadowSvgState, ctx.doc, ctx.sceneEl); - for (const [faceIndex, agg] of perFace) { - const plane = planeByFace.get(faceIndex); - if (!plane || agg.layers.length === 0) continue; - // Union bbox over every pass's polys (absolute face-(u,v)). - let minU = Infinity, minV = Infinity, maxU = -Infinity, maxV = -Infinity; - for (const layer of agg.layers) for (const poly of layer.polys) for (const pt of poly) { - if (pt[0] < minU) minU = pt[0]; - if (pt[0] > maxU) maxU = pt[0]; - if (pt[1] < minV) minV = pt[1]; - if (pt[1] > maxV) maxV = pt[1]; - } - if (!Number.isFinite(minU)) continue; - minU = Math.floor(minU - 1); minV = Math.floor(minV - 1); - maxU = Math.ceil(maxU + 1); maxV = Math.ceil(maxV + 1); - const w = maxU - minU, h = maxV - minV; - if (!(w > 0) || !(h > 0)) continue; - - const { O, u, v, n, lift } = plane; - const tx = O[0] + minU * u[0] + minV * v[0] + lift * n[0]; - const ty = O[1] + minU * u[1] + minV * v[1] + lift * n[1]; - const tz = O[2] + minU * u[2] + minV * v[2] + lift * n[2]; - const m = [u[0], u[1], u[2], 0, v[0], v[1], v[2], 0, n[0], n[1], n[2], 0, tx, ty, tz, 1]; - const matrixCss = `matrix3d(${m.map((x) => x.toFixed(4)).join(",")})`; - - seen.add(faceIndex); - let face = mounted.get(faceIndex); + for (const fc of faces) { + seen.add(fc.faceIndex); + let face = mounted.get(fc.faceIndex); if (!face) { face = { svg: null, visible: false, width: -1, height: -1, matrixCss: "" }; - mounted.set(faceIndex, face); + mounted.set(fc.faceIndex, face); } let svg = face.svg; if (!svg) { @@ -328,54 +276,30 @@ export function emitReceiverShadows( } if (!face.visible) svg.style.display = "block"; face.visible = true; - if (face.width !== w || face.height !== h) { - svg.setAttribute("width", String(w)); - svg.setAttribute("height", String(h)); - svg.setAttribute("viewBox", `0 0 ${w} ${h}`); - face.width = w; - face.height = h; + if (face.width !== fc.width || face.height !== fc.height) { + svg.setAttribute("width", String(fc.width)); + svg.setAttribute("height", String(fc.height)); + svg.setAttribute("viewBox", `0 0 ${fc.width} ${fc.height}`); + face.width = fc.width; + face.height = fc.height; } - // A single-light face paints its remaining color directly (one path); only - // multi-light SOLID faces need the base + per-pass `multiply` layers for - // correct overlap. Solid multi-light carries shadow strength at the SVG - // level (layers stay opaque so multiply is exact); everything else keeps - // per-path alpha. - const merged = agg.solid && agg.layers.length > 1; - const svgOpacity = merged ? opacity : 1; const style = `position:absolute;top:0;left:0;display:block;overflow:hidden;` + `transform-origin:0 0;pointer-events:none;will-change:transform;` + - `opacity:${svgOpacity.toFixed(4)};transform:${matrixCss}`; + `opacity:${fc.svgOpacity.toFixed(4)};transform:${fc.matrixCss}`; if (face.matrixCss !== style) { svg.setAttribute("style", style); face.matrixCss = style; } if (wantDebug) { svg.setAttribute("data-poly-shadow-type", "receiver"); svg.setAttribute("data-poly-shadow-receiver", meshShadowId(receiverEntry)); - svg.setAttribute("data-poly-shadow-receiver-face", String(faceIndex)); - svg.setAttribute("data-poly-shadow-receiver-polys", JSON.stringify(agg.memberPolyIndices)); - svg.setAttribute("data-poly-shadow-layers", String(agg.layers.length)); + svg.setAttribute("data-poly-shadow-receiver-face", String(fc.faceIndex)); + svg.setAttribute("data-poly-shadow-receiver-polys", JSON.stringify(fc.memberPolyIndices)); + svg.setAttribute("data-poly-shadow-layers", String(fc.layers.length)); } - // Rebuild children. Each layer is ONE path (all its polys, nonzero union) - // so within-light overlaps don't double-multiply; cross-light overlaps are - // separate paths that compose. while (svg.firstChild) svg.removeChild(svg.firstChild); - if (merged) { - // base = full-lit color over the shadow union; each pass multiplies it. - // No stroke: a stroked multiply layer would darken its own outer edge, - // drawing a visible outline. Each layer is already a nonzero union so - // there are no internal seams to feather. - let baseD = ""; - for (const layer of agg.layers) baseD += polysToD(layer.polys, minU, minV); - svg.appendChild(makePath(ctx.doc, baseD, agg.base, false, 1, false)); - for (const layer of agg.layers) { - const factor = multiplyFactor(layer.fill, agg.base); - svg.appendChild(makePath(ctx.doc, polysToD(layer.polys, minU, minV), factor, true, 1, false)); - } - } else { - // One path per pass at its own alpha (single light, or textured). - for (const layer of agg.layers) { - svg.appendChild(makePath(ctx.doc, polysToD(layer.polys, minU, minV), layer.fill, false, layer.opacity, false)); - } + if (fc.baseFill && fc.baseD) svg.appendChild(makeShadowPath(ctx.doc, fc.baseD, fc.baseFill, false, 1)); + for (const layer of fc.layers) { + svg.appendChild(makeShadowPath(ctx.doc, layer.d, layer.fill, layer.multiply, layer.opacity)); } } // Hide faces with no current content. @@ -388,62 +312,19 @@ export function emitReceiverShadows( } } -/** Build an `M…L…Z` path string from face-(u,v) polygons, offset to the SVG's - * tight bbox origin. */ -function polysToD( - polys: ReadonlyArray>, - minU: number, - minV: number, -): string { - let d = ""; - for (const poly of polys) { - if (poly.length < 3) continue; - d += `M${(poly[0]![0] - minU).toFixed(1)},${(poly[0]![1] - minV).toFixed(1)}`; - for (let j = 1; j < poly.length; j++) { - d += `L${(poly[j]![0] - minU).toFixed(1)},${(poly[j]![1] - minV).toFixed(1)}`; - } - d += "Z"; - } - return d; -} - -function makePath( +function makeShadowPath( doc: Document, d: string, fill: string, blendMultiply: boolean, opacity: number, - stroke: boolean, ): SVGPathElement { const path = doc.createElementNS(SVG_NS, "path"); path.setAttribute("d", d); path.setAttribute("fill", fill); // Overlapping same-light subpaths union (don't alpha/multiply-stack). path.setAttribute("fill-rule", "nonzero"); - if (stroke) { - // A 1px stroke of the same color closes sub-pixel seams between adjacent - // projected polygons (a single path's nonzero fill already merges them, - // so this only feathers the outer antialiased edge). - path.setAttribute("stroke", fill); - path.setAttribute("stroke-width", "1"); - path.setAttribute("stroke-linejoin", "round"); - } if (opacity !== 1) path.setAttribute("opacity", opacity.toFixed(4)); if (blendMultiply) path.style.mixBlendMode = "multiply"; return path; } - -/** Per-channel multiply factor `remaining / full` (both sRGB hex) as `rgb(...)`. - * Painting the base = `full` and this factor with `mix-blend-mode: multiply` - * reproduces `remaining`; overlapping factors multiply to the both-removed - * color. */ -function multiplyFactor(remaining: string, full: string): string { - const a = parseHexColor(remaining)?.rgb ?? [0, 0, 0]; - const b = parseHexColor(full)?.rgb ?? [255, 255, 255]; - const f = (i: number): number => { - const c = b[i] ?? 0; - if (c <= 0) return 255; - return Math.max(0, Math.min(255, Math.round((a[i]! / c) * 255))); - }; - return `rgb(${f(0)},${f(1)},${f(2)})`; -} diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx index 65479ee8..5d7e7f60 100644 --- a/packages/react/src/scene/PolyMesh.tsx +++ b/packages/react/src/scene/PolyMesh.tsx @@ -40,7 +40,7 @@ import { BASE_TILE, buildPolyMeshTransform, buildSharedEdgeMap, - computeReceiverShadowFaces, + computeMergedReceiverShadows, computeSceneBbox, DEFAULT_SEAM_BLEED, ensureCcw2D, @@ -979,24 +979,27 @@ export const PolyMesh = forwardRef(function PolyM // vanilla emitGround/emitReceiverShadows pipelines.) const userLightDir = sceneDirectionalLight?.direction ?? ([0.4, -0.7, 0.59] as Vec3); const lightDir = worldDirectionToCss(userLightDir); - // Shadow-casting point lights, converted to CSS-frame absolute positions - // (same world-CSS frame as the caster/receiver vertices). Only lights with - // castShadow:true project (Three.js parity). Mirrors vanilla emitSceneShadows. + // Point lights are baked-mode only — in dynamic mode they drive neither + // surface shading nor shadows (a colored point shadow on a floor those + // lights never lit reads as broken). Mirrors vanilla createPolyScene. + const dynamicShading = effectiveTextureLighting === "dynamic"; + const scenePoints = dynamicShading ? [] : (sceneCtx?.pointLights ?? []); // ALL point lights in CSS frame — the shaded shadow color needs every // light that illuminates the receiver (even non-casters), minus the one // being shadowed. `shadowPointIndices` are the entries that cast. - const allPointLightsCss = (sceneCtx?.pointLights ?? []).map((pl) => ({ + const allPointLightsCss = scenePoints.map((pl) => ({ position: worldPositionToCss(pl.position), color: pl.color, intensity: pl.intensity, })); - const shadowPointIndices = (sceneCtx?.pointLights ?? []) + const shadowPointIndices = scenePoints .map((pl, i) => (pl.castShadow ? i : -1)) .filter((i) => i >= 0); - const cssPointPositions = shadowPointIndices.map((i) => allPointLightsCss[i]!.position); - // Directional pass runs when a directional light is configured, or — to - // preserve the implicit-sun shadow — when there are no point shadow lights. - const runDirectionalShadow = !!sceneDirectionalLight?.direction || shadowPointIndices.length === 0; + // Directional pass runs only for a real, nonzero-intensity directional + // light (Three.js parity: zero-intensity removes no light → no shadow; the + // old implicit-sun fallback is gone). + const runDirectionalShadow = + !!sceneDirectionalLight?.direction && (sceneDirectionalLight.intensity ?? 1) > 0; const hasShadowPoints = shadowPointIndices.length > 0; const shadowLift = sceneShadow?.lift ?? 0.001; const planes = prepareReceiverFacePlanes( @@ -1066,75 +1069,67 @@ export const PolyMesh = forwardRef(function PolyM rotY: cameraState?.rotY ?? 45, meshRotation: rotation, }; - // One pass per light: directional (namespace "d") + one per shadow point - // light (namespace "p0".."pN"). Each pass projects into its own SVG keys so - // overlapping shadows from different lights stack independently — a point - // shadowed by two lights reads darker, matching multi-shadow-map occlusion. - const runPass = ( - lightKey: string, - passLightDir: Vec3, - lightPos: Vec3 | undefined, - thisPointIndex: number | undefined, - ): ReactNode[] => { - const specs = computeReceiverShadowFaces({ - receiverPlanes: planes, - receiverPolygons: polygons, - receiverHasTexture: polygons.some((p) => p.texture !== undefined), - casters: casterInputs, - lightDir: passLightDir, - lightPos, - allPointLights: allPointLightsCss, - thisPointIndex, - cameraRot, - ambientLight: sceneCtx?.ambientLight, - directionalLight: sceneDirectionalLight, - shadow: { color: sceneShadow?.color, opacity: sceneShadow?.opacity ?? 0.25, maxExtend: sceneShadow?.maxExtend }, - }); - return specs.map((spec) => ( - - {spec.paths.map((p, i) => ( - - ))} - - )); - }; - const nodes: ReactNode[] = []; - if (runDirectionalShadow) nodes.push(...runPass("d", lightDir, undefined, undefined)); - for (let li = 0; li < cssPointPositions.length; li++) { - nodes.push(...runPass(`p${li}`, lightDir, cssPointPositions[li], shadowPointIndices[li])); - } - return <>{nodes}; - }, [receiveShadow, shadowCasters, shadowCastersVersion, polygons, position, scale, rotation, sceneDirectionalLight, sceneCtx?.pointLights, sceneShadow, sceneCtx?.ambientLight, cameraCtx?.store, cameraTick, selfShadowEdgeMap]); + // Shared core merge: one SVG per receiver face, all its lights merged so + // overlaps composite correctly (single light → one path; multi-light solid + // → base + per-light multiply layers). Identical to vanilla + Vue. + const faces = computeMergedReceiverShadows({ + receiverPlanes: planes, + receiverPolygons: polygons, + receiverHasTexture: polygons.some((p) => p.texture !== undefined), + casters: casterInputs, + lightDir, + runDirectional: runDirectionalShadow, + pointPasses: shadowPointIndices.map((i) => ({ lightPos: allPointLightsCss[i]!.position, index: i })), + allPointLights: allPointLightsCss, + cameraRot, + ambientLight: sceneCtx?.ambientLight, + directionalLight: sceneDirectionalLight, + shadow: { color: sceneShadow?.color, opacity: sceneShadow?.opacity ?? 0.25, maxExtend: sceneShadow?.maxExtend }, + }); + if (faces.length === 0) return null; + return ( + <> + {faces.map((fc) => ( + + {fc.baseFill && fc.baseD ? ( + + ) : null} + {fc.layers.map((layer, i) => ( + + ))} + + ))} + + ); + }, [receiveShadow, shadowCasters, shadowCastersVersion, polygons, position, scale, rotation, sceneDirectionalLight, sceneCtx?.pointLights, effectiveTextureLighting, sceneShadow, sceneCtx?.ambientLight, cameraCtx?.store, cameraTick, selfShadowEdgeMap]); // Portal receiver shadow SVGs OUT of `.polycss-mesh` into `.polycss-scene`. // The SVG `matrix3d(...)` already includes this mesh's `position` (baked diff --git a/packages/vue/src/scene/PolyMesh.ts b/packages/vue/src/scene/PolyMesh.ts index 49d88709..2add10b8 100644 --- a/packages/vue/src/scene/PolyMesh.ts +++ b/packages/vue/src/scene/PolyMesh.ts @@ -38,7 +38,7 @@ import { import { BASE_TILE, buildPolyMeshTransform, - computeReceiverShadowFaces, + computeMergedReceiverShadows, computeSceneBbox, DEFAULT_SEAM_BLEED, ensureCcw2D, @@ -657,24 +657,26 @@ export const PolyMesh = defineComponent({ // same frame for the shadow projection math to land correctly. const userLightDir = ctx?.directionalLight?.direction ?? ([0.4, -0.7, 0.59] as Vec3); const lightDir = worldDirectionToCss(userLightDir); - // Shadow-casting point lights → CSS-frame absolute positions (same - // world-CSS frame as caster/receiver vertices). Only castShadow:true - // lights project (Three.js parity). Mirrors vanilla emitSceneShadows. + // Point lights are baked-mode only — in dynamic mode they drive neither + // surface shading nor shadows (a colored point shadow on a floor those + // lights never lit reads as broken). Mirrors vanilla createPolyScene. + const dynamicShading = atlasTextureLighting.value === "dynamic"; + const scenePoints = dynamicShading ? [] : (ctx?.pointLights ?? []); // ALL point lights in CSS frame — the shaded shadow color needs every // light that illuminates the receiver (even non-casters), minus the one // being shadowed. `shadowPointIndices` are the entries that cast. - const allPointLightsCss = (ctx?.pointLights ?? []).map((pl) => ({ + const allPointLightsCss = scenePoints.map((pl) => ({ position: worldPositionToCss(pl.position), color: pl.color, intensity: pl.intensity, })); - const shadowPointIndices = (ctx?.pointLights ?? []) + const shadowPointIndices = scenePoints .map((pl, i) => (pl.castShadow ? i : -1)) .filter((i) => i >= 0); - const cssPointPositions = shadowPointIndices.map((i) => allPointLightsCss[i]!.position); - // Directional pass runs when a directional light is configured, or — to - // preserve the implicit-sun shadow — when there are no point shadow lights. - const runDirectionalShadow = !!ctx?.directionalLight?.direction || shadowPointIndices.length === 0; + // Directional pass runs only for a real, nonzero-intensity directional + // light (Three.js parity; the old implicit-sun fallback is gone). + const runDirectionalShadow = + !!ctx?.directionalLight?.direction && (ctx.directionalLight.intensity ?? 1) > 0; const hasShadowPoints = shadowPointIndices.length > 0; const shadowLift = ctx?.shadow?.lift ?? 0.001; const planes = prepareReceiverFacePlanes( @@ -742,75 +744,65 @@ export const PolyMesh = defineComponent({ rotY: cameraState?.rotY ?? 45, meshRotation: props.rotation, }; - // One pass per light: directional (namespace "d") + one per shadow point - // light ("p0".."pN"), each into its own SVG keys so overlapping shadows - // stack independently — matching multi-shadow-map occlusion. - const runPass = ( - lightKey: string, - passLightDir: Vec3, - lightPos: Vec3 | undefined, - thisPointIndex: number | undefined, - ): VNode[] => { - const specs = computeReceiverShadowFaces({ - receiverPlanes: planes, - receiverPolygons: polygons.value, - receiverHasTexture: polygons.value.some((p) => p.texture !== undefined), - casters: casterInputs, - lightDir: passLightDir, - lightPos, - allPointLights: allPointLightsCss, - thisPointIndex, - cameraRot, - ambientLight: ctx?.ambientLight, - directionalLight: ctx?.directionalLight, - shadow: { color: ctx?.shadow?.color, opacity: ctx?.shadow?.opacity ?? 0.25, maxExtend: ctx?.shadow?.maxExtend }, - }); - return specs.map((spec) => - h( - "svg", - { - key: `receiver-${lightKey}-${spec.faceIndex}`, - class: "polycss-shadow polycss-shadow-svg polycss-shadow-receiver", - "data-poly-shadow-type": "receiver", - "data-poly-shadow-light": lightKey, - "data-poly-shadow-receiver-face": String(spec.faceIndex), - "data-poly-shadow-receiver-polys": JSON.stringify(spec.memberPolyIndices), - width: String(spec.width), - height: String(spec.height), - viewBox: `0 0 ${spec.width} ${spec.height}`, - style: { - position: "absolute", - top: "0", - left: "0", - display: "block", - overflow: "hidden", - transformOrigin: "0 0", - pointerEvents: "none", - willChange: "transform", - transform: spec.matrixCss, - } as CSSProperties, - }, - spec.paths.map((p, idx) => + // Shared core merge: one SVG per receiver face, all its lights merged so + // overlaps composite correctly (single light → one path; multi-light + // solid → base + per-light multiply layers). Identical to vanilla + React. + const faces = computeMergedReceiverShadows({ + receiverPlanes: planes, + receiverPolygons: polygons.value, + receiverHasTexture: polygons.value.some((p) => p.texture !== undefined), + casters: casterInputs, + lightDir, + runDirectional: runDirectionalShadow, + pointPasses: shadowPointIndices.map((i) => ({ lightPos: allPointLightsCss[i]!.position, index: i })), + allPointLights: allPointLightsCss, + cameraRot, + ambientLight: ctx?.ambientLight, + directionalLight: ctx?.directionalLight, + shadow: { color: ctx?.shadow?.color, opacity: ctx?.shadow?.opacity ?? 0.25, maxExtend: ctx?.shadow?.maxExtend }, + }); + return faces.map((fc) => + h( + "svg", + { + key: `receiver-${fc.faceIndex}`, + class: "polycss-shadow polycss-shadow-svg polycss-shadow-receiver", + "data-poly-shadow-type": "receiver", + "data-poly-shadow-receiver-face": String(fc.faceIndex), + "data-poly-shadow-receiver-polys": JSON.stringify(fc.memberPolyIndices), + width: String(fc.width), + height: String(fc.height), + viewBox: `0 0 ${fc.width} ${fc.height}`, + style: { + position: "absolute", + top: "0", + left: "0", + display: "block", + overflow: "hidden", + transformOrigin: "0 0", + pointerEvents: "none", + willChange: "transform", + opacity: String(fc.svgOpacity), + transform: fc.matrixCss, + } as CSSProperties, + }, + [ + ...(fc.baseFill && fc.baseD + ? [h("path", { d: fc.baseD, fill: fc.baseFill, "fill-rule": "nonzero" })] + : []), + ...fc.layers.map((layer, idx) => h("path", { key: idx, - d: p.d, - fill: spec.fill, - stroke: spec.fill, - "stroke-width": "3", - "stroke-linejoin": "round", - opacity: spec.opacity.toFixed(4), - "data-poly-shadow-caster-polys": JSON.stringify(p.casterPolygonIndices), + d: layer.d, + fill: layer.fill, + "fill-rule": "nonzero", + ...(layer.opacity !== 1 ? { opacity: layer.opacity.toFixed(4) } : {}), + ...(layer.multiply ? { style: { mixBlendMode: "multiply" } as CSSProperties } : {}), }), ), - ), - ); - }; - const nodes: VNode[] = []; - if (runDirectionalShadow) nodes.push(...runPass("d", lightDir, undefined, undefined)); - for (let li = 0; li < cssPointPositions.length; li++) { - nodes.push(...runPass(`p${li}`, lightDir, cssPointPositions[li], shadowPointIndices[li])); - } - return nodes; + ], + ), + ); }); // Register this mesh with the shadow registry when castShadow=true in From 44f976d13738c8c79e6cd8face8a9165f3f718d4 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 21 Jun 2026 00:54:44 +0200 Subject: [PATCH 15/21] bench: 4-pane shadow-parity page (vanilla/react/vue/three) to confirm cross-renderer shadow parity --- bench/build.mjs | 15 ++ bench/entries/parityMeshes.ts | 28 ++++ bench/entries/shadowParityReact.tsx | 50 +++++++ bench/entries/shadowParityVue.ts | 55 +++++++ bench/shadow-parity.html | 213 ++++++++++++++++++++++++++++ 5 files changed, 361 insertions(+) create mode 100644 bench/entries/parityMeshes.ts create mode 100644 bench/entries/shadowParityReact.tsx create mode 100644 bench/entries/shadowParityVue.ts create mode 100644 bench/shadow-parity.html diff --git a/bench/build.mjs b/bench/build.mjs index 29a8efca..9aff81f6 100644 --- a/bench/build.mjs +++ b/bench/build.mjs @@ -100,6 +100,21 @@ const targets = [ entry: resolve(__dirname, "entries/vue.ts"), out: resolve(bundleDir, "polycss-vue.js"), }, + { + label: "shadow-parity shared meshes", + entry: resolve(__dirname, "entries/parityMeshes.ts"), + out: resolve(bundleDir, "parity-meshes.js"), + }, + { + label: "shadow-parity react mount", + entry: resolve(__dirname, "entries/shadowParityReact.tsx"), + out: resolve(bundleDir, "shadow-parity-react.js"), + }, + { + label: "shadow-parity vue mount", + entry: resolve(__dirname, "entries/shadowParityVue.ts"), + out: resolve(bundleDir, "shadow-parity-vue.js"), + }, { label: "HTML chunk mount bench entry", entry: resolve(__dirname, "entries/htmlMount.ts"), diff --git a/bench/entries/parityMeshes.ts b/bench/entries/parityMeshes.ts new file mode 100644 index 00000000..d62caf3a --- /dev/null +++ b/bench/entries/parityMeshes.ts @@ -0,0 +1,28 @@ +/** + * Bench entry — shared scene geometry for shadow-parity.html, so every pane + * (vanilla / React / Vue) renders byte-identical polygons. Bundled into + * bench/.generated/parity-meshes.js. + */ +import { boxPolygons } from "@layoutit/polycss-core"; +import type { Polygon } from "@layoutit/polycss-core"; + +/** 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 })); +} + +/** Flat square floor on z=0. */ +export function floorPolygons(size = 20, color = "#cbd5e1"): Polygon[] { + const h = size / 2; + return [ + { + vertices: [ + [-h, -h, 0], + [h, -h, 0], + [h, h, 0], + [-h, h, 0], + ], + color, + }, + ]; +} diff --git a/bench/entries/shadowParityReact.tsx b/bench/entries/shadowParityReact.tsx new file mode 100644 index 00000000..649c201a --- /dev/null +++ b/bench/entries/shadowParityReact.tsx @@ -0,0 +1,50 @@ +/** + * Bench entry — React shadow-parity mount. Bundled by bench/build.mjs into + * bench/.generated/shadow-parity-react.js and used by bench/shadow-parity.html. + * + * Exposes a tiny imperative `mount(host, params)` that renders the SAME scene + * the vanilla / Vue / three panes render, so the parity page can compare all + * renderers pixel-for-pixel. Driven via the public component API (no iframe / + * postMessage). + */ +import { createElement as h } from "react"; +import { createRoot } from "react-dom/client"; +import { PolyCamera, PolyScene, PolyMesh } from "@layoutit/polycss-react"; + +export interface ParityParams { + cubePolys: unknown[]; + floorPolys: unknown[]; + cubeCenter: [number, number, number]; + directionalLight?: unknown; + pointLights?: unknown[]; + ambientLight?: unknown; + textureLighting: "baked" | "dynamic"; + shadow: { color?: string; opacity?: number; lift?: number }; + cam: { rotX: number; rotY: number; zoom: number }; +} + +export function mount(host: HTMLElement, initial: ParityParams) { + const root = createRoot(host); + const render = (p: ParityParams): void => { + root.render( + h( + PolyCamera as never, + { rotX: p.cam.rotX, rotY: p.cam.rotY, zoom: p.cam.zoom } as never, + h( + PolyScene as never, + { + directionalLight: p.directionalLight, + pointLights: p.pointLights, + ambientLight: p.ambientLight, + textureLighting: p.textureLighting, + shadow: p.shadow, + } as never, + h(PolyMesh as never, { key: "floor", polygons: p.floorPolys, receiveShadow: true } as never), + h(PolyMesh as never, { key: "cube", polygons: p.cubePolys, position: p.cubeCenter, castShadow: true } as never), + ), + ), + ); + }; + render(initial); + return { update: render, dispose: () => root.unmount() }; +} diff --git a/bench/entries/shadowParityVue.ts b/bench/entries/shadowParityVue.ts new file mode 100644 index 00000000..2e5885cf --- /dev/null +++ b/bench/entries/shadowParityVue.ts @@ -0,0 +1,55 @@ +/** + * Bench entry — Vue shadow-parity mount. Bundled by bench/build.mjs into + * bench/.generated/shadow-parity-vue.js and used by bench/shadow-parity.html. + * + * Mirror of shadowParityReact.tsx: a tiny imperative `mount(host, params)` + * that renders the same scene as the other panes via the public component API. + */ +import { createApp, h, reactive } from "vue"; +import { PolyCamera, PolyScene, PolyMesh } from "@layoutit/polycss-vue"; + +export interface ParityParams { + cubePolys: unknown[]; + floorPolys: unknown[]; + cubeCenter: [number, number, number]; + directionalLight?: unknown; + pointLights?: unknown[]; + ambientLight?: unknown; + textureLighting: "baked" | "dynamic"; + shadow: { color?: string; opacity?: number; lift?: number }; + cam: { rotX: number; rotY: number; zoom: number }; +} + +export function mount(host: HTMLElement, initial: ParityParams) { + const st = reactive<{ p: ParityParams }>({ p: initial }); + const app = createApp({ + render() { + const p = st.p; + return h( + PolyCamera as never, + { rotX: p.cam.rotX, rotY: p.cam.rotY, zoom: p.cam.zoom }, + { + default: () => + h( + PolyScene as never, + { + directionalLight: p.directionalLight, + pointLights: p.pointLights, + ambientLight: p.ambientLight, + textureLighting: p.textureLighting, + shadow: p.shadow, + }, + { + default: () => [ + h(PolyMesh as never, { polygons: p.floorPolys, receiveShadow: true }), + h(PolyMesh as never, { polygons: p.cubePolys, position: p.cubeCenter, castShadow: true }), + ], + }, + ), + }, + ); + }, + }); + app.mount(host); + return { update: (np: ParityParams) => { st.p = np; }, dispose: () => app.unmount() }; +} diff --git a/bench/shadow-parity.html b/bench/shadow-parity.html new file mode 100644 index 00000000..087788d4 --- /dev/null +++ b/bench/shadow-parity.html @@ -0,0 +1,213 @@ + + + + + + Shadow parity — vanilla / React / Vue / three.js + + + +

+ Shadow parity + + + + + + point lights are baked-mode only — dynamic shows directional shadows only +
+
+

PolyCSS (vanilla)

+

PolyCSS (React)

+

PolyCSS (Vue)

+

three.js

+
+ + + + + + From 481ca98ff2ea6976667c2067bef8fcf679ae85a9 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 21 Jun 2026 01:04:59 +0200 Subject: [PATCH 16/21] fix(polycss): setOptions re-emits shadows on directional intensity/color change (not just direction); parity bench rebakes vanilla surface on light change --- bench/shadow-parity.html | 13 ++++++++-- packages/polycss/src/api/createPolyScene.ts | 28 +++++++++++++++------ 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/bench/shadow-parity.html b/bench/shadow-parity.html index 087788d4..59f2b802 100644 --- a/bench/shadow-parity.html +++ b/bench/shadow-parity.html @@ -95,8 +95,8 @@ const vanillaScene = createPolyScene(document.getElementById("host-vanilla"), { camera: vanillaCam, ...sceneOpts(params()), }); - vanillaScene.add({ polygons: FLOOR }, { receiveShadow: true }); - vanillaScene.add({ polygons: CUBE }, { position: CUBE_CENTER, castShadow: true }); + const vanillaFloor = vanillaScene.add({ polygons: FLOOR }, { receiveShadow: true }); + const vanillaCube = vanillaScene.add({ polygons: CUBE }, { position: CUBE_CENTER, castShadow: true }); function sceneOpts(p) { return { directionalLight: p.directionalLight, pointLights: p.pointLights, @@ -107,6 +107,15 @@ vanillaCam.update({ rotX: p.cam.rotX, rotY: p.cam.rotY, zoom: p.cam.zoom }); vanillaScene.applyCamera(); vanillaScene.setOptions(sceneOpts(p)); + // Imperative baked mode freezes the lit surface until an explicit + // rebake (perf: no re-bake per drag frame). The declarative React/Vue + // renderers re-bake automatically on prop change, so rebake here to keep + // all panes in parity when the light changes. Shadows re-emit via + // setOptions already; dynamic mode shades in CSS (no rebake needed). + if (p.textureLighting === "baked") { + vanillaFloor.rebakeAtlas(); + vanillaCube.rebakeAtlas(); + } } // ── React + Vue panes ─────────────────────────────────────────────── diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index d663b178..1a492f54 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -2179,6 +2179,8 @@ export function createPolyScene( const prevTextureProjection = currentOptions.textureProjection; const prevTextureLighting = currentOptions.textureLighting; const prevLightDir = currentOptions.directionalLight?.direction; + const prevDirIntensity = currentOptions.directionalLight?.intensity; + const prevDirColor = currentOptions.directionalLight?.color; const prevShadow = currentOptions.shadow; const prevPointLights = currentOptions.pointLights; const normalizedPartial = normalizeSceneOptions(partial); @@ -2243,16 +2245,25 @@ export function createPolyScene( // ; lift shifts the ground plane and rebuilds geometry) const textureLightingChanged = partial.textureLighting !== undefined && prevTextureLighting !== currentOptions.textureLighting; - const nextLightDir = currentOptions.directionalLight?.direction; - const lightDirChanged = partial.directionalLight !== undefined - && !vec3Equal(prevLightDir, nextLightDir); + const nextDirLight = currentOptions.directionalLight; + // ANY directional change re-emits shadows — not just direction, but also + // intensity and color: the shadow only emits for intensity > 0 (and its + // shaded fill depends on intensity/color), so toggling a light off or + // sliding its intensity must re-project. The emit short-circuit key is + // keyed on direction only, so intensity/color changes also bust the cache + // below (otherwise emitSceneShadows would no-op). + const directionalChanged = partial.directionalLight !== undefined && ( + !vec3Equal(prevLightDir, nextDirLight?.direction) || + (prevDirIntensity ?? 1) !== (nextDirLight?.intensity ?? 1) || + (prevDirColor ?? "#ffffff") !== (nextDirLight?.color ?? "#ffffff") + ); const nextShadow = currentOptions.shadow; const shadowAppearanceChanged = partial.shadow !== undefined && !shadowOptsEqual(prevShadow, nextShadow); // Point-light changes also re-emit: the emit short-circuit key folds in // each shadow point light's CSS position, so a moved/toggled point light // produces a different key and re-projects its radial shadow. - const shadowReemitNeeded = lightDirChanged || shadowAppearanceChanged || pointLightsChanged; + const shadowReemitNeeded = directionalChanged || shadowAppearanceChanged || pointLightsChanged; if (textureLightingChanged) { // Every mesh needs a full re-render to swap baked/dynamic leaf // emission. Baked leaves carry inline `color: rgb(...)`; dynamic @@ -2279,10 +2290,11 @@ export function createPolyScene( invalidateShadowLightCache(); emitSceneShadows(); } else if (shadowReemitNeeded) { - // Shadow-appearance change (color/opacity/lift) must bust the cache; - // a light-direction-only change is safe because the quantization key - // already discriminates by direction. - if (shadowAppearanceChanged) invalidateShadowLightCache(); + // The emit short-circuit key only discriminates by light DIRECTION, so a + // direction change self-busts, but shadow-appearance and directional + // intensity/color changes must bust the cache explicitly or + // emitSceneShadows would no-op. + if (shadowAppearanceChanged || directionalChanged) invalidateShadowLightCache(); emitSceneShadows(); } if (shadowAppearanceChanged && partial.shadow?.lift !== prevShadow?.lift) { From cb89ce7c80071f3b4295b52bd7ae5017b9edd019 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 21 Jun 2026 01:10:58 +0200 Subject: [PATCH 17/21] =?UTF-8?q?docs:=20document=20baked=20rebake=20contr?= =?UTF-8?q?act=20=E2=80=94=20vanilla=20explicit=20rebake=20vs=20React/Vue?= =?UTF-8?q?=20auto-rebake=20(intentional,=20perf)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index b0c5b51b..306100e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,7 +53,7 @@ The scene takes one `directionalLight`, one `ambientLight`, and zero or more `po ### Lighting modes (`PolyTextureLightingMode = "baked" | "dynamic"`) -- **Baked.** Lambert (directional + each point light + ambient) is computed once on the CPU per polygon, multiplied into the inline `color` (for ``/``/``) or into the rasterised atlas pixels (for atlas-backed ``). Direct image `` leaves preserve source pixels and use `texturePresentation.lighting="source"`; scene-lit direct images fall back to the atlas path. Moving a light requires explicit re-rasterising of affected lit atlas polys via `mesh.rebakeAtlas()`. Cast shadows are independent SVG projections and can re-emit without atlas redraw, so shadows can follow the light interactively even when lit-side shading stays frozen. +- **Baked.** Lambert (directional + each point light + ambient) is computed once on the CPU per polygon, multiplied into the inline `color` (for ``/``/``) or into the rasterised atlas pixels (for atlas-backed ``). Direct image `` leaves preserve source pixels and use `texturePresentation.lighting="source"`; scene-lit direct images fall back to the atlas path. Moving a light requires explicit re-rasterising of affected lit atlas polys via `mesh.rebakeAtlas()` — the atlas bake (canvas raster + async `toBlob`) is the one expensive step, so the vanilla imperative API does NOT auto-rebake the lit surface on a `setOptions({directionalLight})` / point-light change; that keeps high-frequency light drags fast (the caller rebakes, typically debounced to drag-end). Cast shadows ARE cheap (CPU-projected SVG paths) so they re-emit automatically on any light change — direction, intensity, or color (intensity 0 removes the shadow) — and follow the light interactively even while the baked lit side stays frozen. **Renderer asymmetry:** the declarative React/Vue components re-render → auto-rebake the lit surface on any light prop change; vanilla freezes it until an explicit `rebakeAtlas()`. This is intentional (vanilla keeps the fast-drag escape hatch); for live/animated lights prefer dynamic mode. Left as-is by design — do not "fix" the asymmetry by making vanilla auto-rebake without explicit approval. - **Dynamic.** Scene root carries the directional + ambient setup as custom properties (`--plx/y/z`, `--plr/g/b`, `--pli`, `--par/g/b`, `--pai`). Each leaf embeds its surface normal (`--pnx/y/z`) and base color (`--psr/g/b`) inline. CSS `calc()` resolves the Lambert dot product and per-channel tint at paint time. Moving a light mutates scene-root vars for surface lighting — zero JS, no atlas redraw. Point lights are not represented in dynamic mode at all — neither surface shading nor shadows (see above). Cast shadows are **directional-only** in dynamic mode (CPU-projected SVG paths, ambient fill) and re-emit when the directional light changes. All solid and atlas-backed tags work in both modes. Direct image `` leaves are source-lit only; callers that need scene lighting use the atlas backend. The `.vox` direct-matrix fast path is baked-only for now; dynamic mode uses the polygon path so lighting semantics stay correct. The full coverage matrix is in `packages/polycss/src/styles/styles.ts`. From bbf0c6caac9c2c6dd84998857d6831f89788bbe1 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 21 Jun 2026 01:21:18 +0200 Subject: [PATCH 18/21] docs: add Lighting & Shadows guide (directional/ambient/point lights, baked vs dynamic, colored shadows) --- website/astro.config.mjs | 1 + website/src/content/docs/guides/lighting.mdx | 178 +++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 website/src/content/docs/guides/lighting.mdx diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 1d012b41..61f11269 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -92,6 +92,7 @@ export default defineConfig({ label: 'Guides', items: [ { label: 'Loading Meshes', slug: 'guides/textures' }, + { label: 'Lighting & Shadows', slug: 'guides/lighting' }, { label: 'Per-polygon Interaction', slug: 'guides/shapes' }, { label: 'Performance', slug: 'guides/performance' }, { label: 'Projections', slug: 'guides/projections' }, diff --git a/website/src/content/docs/guides/lighting.mdx b/website/src/content/docs/guides/lighting.mdx new file mode 100644 index 00000000..d7cf088e --- /dev/null +++ b/website/src/content/docs/guides/lighting.mdx @@ -0,0 +1,178 @@ +--- +title: Lighting & Shadows +description: Directional, ambient, and point lights; baked vs dynamic shading; and colored cast shadows. +--- + +import { Tabs, TabItem } from '@astrojs/starlight/components'; +import PolyDemo from '../../../components/PolyDemo.astro'; + +PolyCSS shades each polygon with a Lambert model that matches three.js's `MeshLambertMaterial`. A scene takes **one directional light, one ambient light, and any number of point lights**, set on `` / `PolyScene` / `createPolyScene()`. Shading happens either once on the CPU (**baked**) or live in CSS `calc()` (**dynamic**) — see [Lighting modes](#lighting-modes). + +The light types are documented in the [Core Types reference](/api/types/#polydirectionallight); this guide is the conceptual tour. + +## The light sources + +### Directional light + +One infinitely-distant light with a single `direction`, `color`, and `intensity` — like the sun. Every surface gets `color · intensity · max(0, n · L̂)`. + + + + + +```js +import { createPolyScene, createPolyCamera } from "@layoutit/polycss"; + +const scene = createPolyScene(host, { + camera: createPolyCamera({ rotX: 60, rotY: 30 }), + directionalLight: { direction: [0.5, -0.6, 0.6], color: "#ffffff", intensity: 1 }, + ambientLight: { intensity: 0.3 }, +}); +``` + + +```tsx +import { PolyCamera, PolyScene } from "@layoutit/polycss-react"; + + + + {/* meshes */} + + +``` + + +```vue + + + +``` + + + +### Ambient light + +A uniform fill with `color` and `intensity` (default `0.4`) added to every surface regardless of orientation. Use it to lift shadowed faces out of pure black — exactly what a shadowed region fades toward. + +### Point lights + +Positional lights given as an array. Each has a world-space `position`, `color`, `intensity`, and optional `castShadow`. Point lights are **direction-only** — there is **no distance falloff** (they emulate three.js's `PointLight(distance: 0, decay: 0)`); a surface is lit by `color · intensity · max(0, n · L̂)` where `L̂` points from the surface to the light. Shading is flat per face — an accepted approximation of three.js's per-fragment gradient, exact for small faces or distant lights. + +> **Point lights are baked-mode only.** Dynamic mode's zero-JS light updates can't express a per-face direction that varies with position, so dynamic scenes ignore `pointLights` entirely (shading *and* shadows). See [Lighting modes](#lighting-modes). + + + +```js +const scene = createPolyScene(host, { + camera: createPolyCamera({ rotX: 35, rotY: 20 }), + // textureLighting defaults to "baked" — required for point lights. + pointLights: [ + { position: [-4, 4, 5], color: "#ff7755", intensity: 1, castShadow: true }, + { position: [5, -3, 4], color: "#5599ff", intensity: 1, castShadow: true }, + ], + ambientLight: { intensity: 0.3 }, +}); +``` + + +```tsx + + {/* meshes */} + +``` + + +```vue + + + +``` + + + +## Lighting modes + +`textureLighting` (scene-level, with a per-mesh override) chooses **how** the Lambert result is applied: + +| | **`"baked"`** (default) | **`"dynamic"`** | +|---|---|---| +| How | Computed once on the CPU, written into each leaf's color / atlas pixels | Resolved live in CSS `calc()` from scene-root variables + per-leaf normals | +| Point lights | ✅ Supported | ❌ Ignored (direction-only CSS can't vary per position) | +| Moving the directional light | Re-bake needed for the lit surface (shadows update for free) | **Zero JS** — just updates a CSS variable | +| Best for | Static scenes, point lights, exact color | Live / animated directional light, interactive light dragging | + +Rule of thumb: **dynamic** when the directional light moves every frame; **baked** when you want point lights or the lights are mostly static. + +## Cast shadows + +Shadows are CPU-projected SVG surfaces (not a render-strategy leaf). Mark casters with `castShadow` and receivers with `receiveShadow`: + + + +```js +scene.add(parsedFloor, { receiveShadow: true }); +scene.add(parsedModel, { castShadow: true }); +scene.setOptions({ shadow: { color: "#000000", opacity: 0.3, lift: 0.02 } }); +``` + + +```tsx + + + + +``` + + +```vue + + + + +``` + + + +What to expect: + +- **Directional shadows** work in both lighting modes and only appear for a directional light with `intensity > 0` (intensity `0`, or no directional light, casts nothing — matching three.js). +- **Point-light shadows** are baked-mode only and **radial** (each vertex projects along its own ray from the light). Set `castShadow: true` on the point light *and* `castShadow`/`receiveShadow` on the meshes. +- **Colored multi-light shadows.** Each light's shadow is filled with the receiver lit by every *other* light, so a spot blocked from one colored light still shows the others' color. Where two shadows overlap, the region composites to the both-blocked color (ambient only) — not a doubled-up black smear. +- **`shadow.lift`** floats the shadow a hair above the receiver to avoid z-fighting; the default is fine, just don't set it to exactly `0` on a coplanar floor. + +## Live & animated lights + +- **Animating the directional light:** use **dynamic** mode — moving the light is a single CSS-variable write per frame, no JS in the paint loop, and shadows re-project automatically. +- **Baked mode + a light change:** cast shadows re-emit automatically (cheap SVG), but the baked *lit surface* stays frozen until you re-bake — call `mesh.rebakeAtlas()` (debounce it to the end of a drag; the atlas raster is the one costly step). The React/Vue components re-bake automatically on prop change, so this only applies to the imperative `createPolyScene()` API. +- **Animating point lights:** baked-mode only, so re-bake per change (or per debounced step). For continuously moving lights, prefer a single directional light in dynamic mode. From edc09366c5a7f54fc2c2eab27f7c99be849068ea Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 21 Jun 2026 01:34:06 +0200 Subject: [PATCH 19/21] =?UTF-8?q?docs:=20interactive=20lighting=20demo=20?= =?UTF-8?q?=E2=80=94=20PolyDemo=20gains=20light=20controls=20(intensity/am?= =?UTF-8?q?bient/direction)=20+=20opt-in=20ground=20shadow;=20fix=20zoom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- website/src/components/PolyDemo.astro | 68 ++++++++++++++++++-- website/src/content/docs/guides/lighting.mdx | 10 ++- 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/website/src/components/PolyDemo.astro b/website/src/components/PolyDemo.astro index dd21c568..ac702e1b 100644 --- a/website/src/components/PolyDemo.astro +++ b/website/src/components/PolyDemo.astro @@ -22,11 +22,13 @@ interface Props { defaults?: string; /** Show FPS + polygon count overlay */ showStats?: boolean; + /** Add a ground plane below the shape and cast a shadow onto it (generator demos). */ + shadow?: boolean; } import '../styles/poly-demo.css'; -const { id, model, mtl, generator, generatorParams, controls, defaults, showStats } = Astro.props; +const { id, model, mtl, generator, generatorParams, controls, defaults, showStats, shadow } = Astro.props; ---
+ data-show-stats={showStats ? "1" : undefined} + data-shadow={shadow ? "1" : undefined}>
@@ -286,6 +289,7 @@ const { id, model, mtl, generator, generatorParams, controls, defaults, showStat const defaultsData = demoEl.getAttribute("data-defaults"); const generatorParamsData = demoEl.getAttribute("data-generator-params"); const wantStats = demoEl.getAttribute("data-show-stats") === "1"; + const wantShadow = demoEl.getAttribute("data-shadow") === "1"; let controlList: string[] = []; try { controlList = JSON.parse(controlsData || "[]"); } catch {} @@ -366,6 +370,11 @@ const { id, model, mtl, generator, generatorParams, controls, defaults, showStat interactive: defaults.interactive ?? false, animate: defaults.animate ?? false, light: lightDefault, + // Light-direction sliders (azimuth/elevation), seeded from the default + // direction so the sliders start in sync. + lightYaw: Math.round((Math.atan2(lightDefault.direction[1], lightDefault.direction[0]) * 180) / Math.PI), + lightPitch: Math.round((Math.asin(Math.max(-1, Math.min(1, + lightDefault.direction[2] / (Math.hypot(...lightDefault.direction) || 1)))) * 180) / Math.PI), // Generator-specific state (sphere) subdivisions: generatorParams.subdivisions ?? defaults.subdivisions ?? 3, radius: generatorParams.radius ?? defaults.radius ?? 10, @@ -573,7 +582,10 @@ const { id, model, mtl, generator, generatorParams, controls, defaults, showStat perspective !== undefined ? { ...cameraOpts, perspective } : cameraOpts, ); - const sceneOptions: Record = { camera, autoCenter: true }; + const sceneOptions: Record = { camera, autoCenter: !wantShadow }; + if (wantShadow) { + sceneOptions.shadow = { color: "#000000", opacity: 0.32, lift: 0.04 }; + } if (state.light) { // Split the demo's `light` config back into the two scene fields // the imperative API takes. `state.light.ambient` is shorthand @@ -597,11 +609,22 @@ const { id, model, mtl, generator, generatorParams, controls, defaults, showStat wheel: state.interactive, animate: state.animate ? { speed: 0.3, axis: "y", pauseOnInteraction: true } : false, }); + // Ground plane below the shape that receives the cast shadow. + if (wantShadow) { + const g = state.radius * 3; + const z = -state.radius * 1.05; + const groundPolys = [{ + vertices: [[-g, -g, z], [g, -g, z], [g, g, z], [-g, g, z]], + color: "#c9c4ba", + }]; + groundHandle = sceneHandle.add({ polygons: groundPolys, dispose: () => {} }, { receiveShadow: true }); + } await rebuildGeneratorMesh(); loadingEl.style.display = "none"; } let currentMeshHandle: any = null; + let groundHandle: any = null; async function rebuildGeneratorMesh() { if (!sceneHandle) return; @@ -615,13 +638,13 @@ const { id, model, mtl, generator, generatorParams, controls, defaults, showStat state.polyCount = polygons.length; updateStats(); const parseResult = { polygons, dispose: () => {} }; - currentMeshHandle = sceneHandle.add(parseResult); + currentMeshHandle = sceneHandle.add(parseResult, { castShadow: wantShadow }); } else if (generatorName && PLATONIC_SHAPES.includes(generatorName)) { const polygons = buildPlatonic(generatorName, state.radius); state.polyCount = polygons.length; updateStats(); const parseResult = { polygons, dispose: () => {} }; - currentMeshHandle = sceneHandle.add(parseResult); + currentMeshHandle = sceneHandle.add(parseResult, { castShadow: wantShadow }); } updateCode(); } @@ -656,6 +679,35 @@ const { id, model, mtl, generator, generatorParams, controls, defaults, showStat updateCode(); } + // Azimuth/elevation (deg) → a directional to-source vector. + function dirFromYawPitch(yawDeg: number, pitchDeg: number): number[] { + const yaw = (yawDeg * Math.PI) / 180; + const pitch = (pitchDeg * Math.PI) / 180; + const ch = Math.cos(pitch); + return [ch * Math.cos(yaw), ch * Math.sin(yaw), Math.sin(pitch)]; + } + + // Live light update. Baked mode freezes the lit surface, so after pushing + // the new light via setOptions we re-bake the shape + ground (shadows + // re-emit automatically). Custom-element demos fall back to scene attrs. + function updateLight() { + if (sceneHandle) { + const dir: Record = {}; + if (state.light.direction) dir.direction = state.light.direction; + if (state.light.color) dir.color = state.light.color; + if (state.light.intensity !== undefined) dir.intensity = state.light.intensity; + const amb: Record = {}; + if (state.light.ambient !== undefined) amb.intensity = state.light.ambient; + if (state.light.ambientColor) amb.color = state.light.ambientColor; + sceneHandle.setOptions({ directionalLight: dir, ambientLight: amb }); + currentMeshHandle?.rebakeAtlas?.(); + groundHandle?.rebakeAtlas?.(); + } else { + applySceneAttrs(); + } + updateCode(); + } + // ── Code snippet generation ──────────────────────────────────────────── function updateCode() { // Camera props — zoom/rotX/rotY always on camera; perspective on @@ -966,6 +1018,12 @@ const { id, model, mtl, generator, generatorParams, controls, defaults, showStat perspective: () => makeRange("perspective", 200, 64000, 100, state.perspective === false || state.perspective === undefined ? rendererDefaultPerspective : state.perspective, (v) => { state.perspective = v === rendererDefaultPerspective ? undefined : v; updateCamera(); }, "px"), interactive: () => makeSwitch("interactive", state.interactive, (v) => { state.interactive = v; updateCamera(); }), animate: () => makeSwitch("animate", state.animate, (v) => { state.animate = v; updateCamera(); }), + // Lighting controls (generator demos): intensity, ambient fill, and the + // directional light's azimuth/elevation. + lightIntensity: () => makeRange("intensity", 0, 8, 0.1, state.light.intensity, (v) => { state.light.intensity = v; updateLight(); }), + ambient: () => makeRange("ambient", 0, 1.5, 0.05, state.light.ambient, (v) => { state.light.ambient = v; updateLight(); }), + lightYaw: () => makeRange("light °", 0, 360, 1, ((state.lightYaw % 360) + 360) % 360, (v) => { state.lightYaw = v; state.light.direction = dirFromYawPitch(state.lightYaw, state.lightPitch); updateLight(); }, "°"), + lightPitch: () => makeRange("light ↑", 5, 89, 1, state.lightPitch, (v) => { state.lightPitch = v; state.light.direction = dirFromYawPitch(state.lightYaw, state.lightPitch); updateLight(); }, "°"), // Sphere-specific controls subdivisions: () => makeRange("subdivisions", 0, 5, 1, state.subdivisions, (v) => { state.subdivisions = Math.round(v); rebuildGeneratorMesh(); updateCode(); }), radius: () => makeRange("radius", 2, 20, 1, state.radius, (v) => { state.radius = Math.round(v); rebuildGeneratorMesh(); updateCode(); }), diff --git a/website/src/content/docs/guides/lighting.mdx b/website/src/content/docs/guides/lighting.mdx index d7cf088e..9c686d87 100644 --- a/website/src/content/docs/guides/lighting.mdx +++ b/website/src/content/docs/guides/lighting.mdx @@ -18,11 +18,15 @@ One infinitely-distant light with a single `direction`, `color`, and `intensity` +Drag **light °** / **light ↑** to move the sun and watch the lit side — and the cast shadow on the ground — follow; **intensity** and **ambient** control brightness and fill. + ```js From 57730e931d45d623f68b5d257a457256b8e16044 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 21 Jun 2026 01:41:05 +0200 Subject: [PATCH 20/21] docs: use a cube in the lighting demo (clearer per-face Lambert + clean cast shadow) --- website/src/content/docs/guides/lighting.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/src/content/docs/guides/lighting.mdx b/website/src/content/docs/guides/lighting.mdx index 9c686d87..e1717784 100644 --- a/website/src/content/docs/guides/lighting.mdx +++ b/website/src/content/docs/guides/lighting.mdx @@ -18,11 +18,11 @@ One infinitely-distant light with a single `direction`, `color`, and `intensity` Drag **light °** / **light ↑** to move the sun and watch the lit side — and the cast shadow on the ground — follow; **intensity** and **ambient** control brightness and fill. From 49a8f624dcea5ee8d9000b7b40a614c4e7402b54 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 21 Jun 2026 01:45:55 +0200 Subject: [PATCH 21/21] =?UTF-8?q?docs:=20lighting=20demo=20=E2=80=94=20dro?= =?UTF-8?q?p=20no-op=20rotY=20control,=20zoom=20out=20a=20touch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- website/src/content/docs/guides/lighting.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/content/docs/guides/lighting.mdx b/website/src/content/docs/guides/lighting.mdx index e1717784..29f386ab 100644 --- a/website/src/content/docs/guides/lighting.mdx +++ b/website/src/content/docs/guides/lighting.mdx @@ -21,8 +21,8 @@ One infinitely-distant light with a single `direction`, `color`, and `intensity` generator="cube" generatorParams='{"radius":14}' shadow - controls='["lightYaw","lightPitch","lightIntensity","ambient","rotY"]' - defaults='{"rotX":58,"rotY":35,"zoom":0.13,"light":{"intensity":4.5,"ambient":0.4}}' + controls='["lightYaw","lightPitch","lightIntensity","ambient"]' + defaults='{"rotX":58,"rotY":35,"zoom":0.1,"light":{"intensity":4.5,"ambient":0.4}}' /> Drag **light °** / **light ↑** to move the sun and watch the lit side — and the cast shadow on the ground — follow; **intensity** and **ambient** control brightness and fill.