Skip to content

Commit 0d91822

Browse files
authored
feat: point lights (direction-only shading + radial colored shadows) (#71)
* feat(core): direction-only point-light shading in the baked atlas plan (PolyPointLight + per-face contribs) * feat(polycss): pointLights scene option — per-mesh local conversion + bake/re-bake wiring * feat(bench): point-light oracle — 2 point lights (PolyCSS) vs three.js PointLight(decay=0) emulation * feat(react,vue): mirror direction-only point-light shading in baked atlas builders * feat(core,polycss): radial point-light cast shadows (per-light receiver passes) * feat(react,vue): mirror radial point-light cast shadows (per-light receiver passes) * docs: document pointLights (direction-only shading + radial cast shadows) * feat(shadows): shaded per-light colored shadows + point-light bench controls * feat(shadows): merge a face's lights into one SVG for correct overlap (vanilla) * fix(shadows): no directional shadow at zero/absent intensity (vanilla); drop implicit-sun fallback * bench(color): shadow-color delta oracle (oracleColorDelta) + fix default-sun contamination in di=0 comparisons * fix(shadows): dynamic mode ignores point lights for shadows too (no colored shadows on unlit floor) — vanilla * docs: dynamic mode ignores point lights for shadows too (directional-only) * feat(shadows): share merged per-face shadow path across all renderers (core computeMergedReceiverShadows); colored overlap + dynamic + intensity gate in react/vue * bench: 4-pane shadow-parity page (vanilla/react/vue/three) to confirm cross-renderer shadow parity * fix(polycss): setOptions re-emits shadows on directional intensity/color change (not just direction); parity bench rebakes vanilla surface on light change * docs: document baked rebake contract — vanilla explicit rebake vs React/Vue auto-rebake (intentional, perf) * docs: add Lighting & Shadows guide (directional/ambient/point lights, baked vs dynamic, colored shadows) * docs: interactive lighting demo — PolyDemo gains light controls (intensity/ambient/direction) + opt-in ground shadow; fix zoom * docs: use a cube in the lighting demo (clearer per-face Lambert + clean cast shadow) * docs: lighting demo — drop no-op rotY control, zoom out a touch
1 parent 5353eff commit 0d91822

40 files changed

Lines changed: 4989 additions & 205 deletions

AGENTS.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,20 @@ Strategies are ordered cheapest → most expensive. The mesher's job is to maxim
4141

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

44-
Cast shadows are not a render-strategy leaf tag. Meshes with `castShadow: true` project casting polygons on the CPU into SVG shadow surfaces: one aggregate path for the ground plane in vanilla, per-mesh SVG paths in React/Vue, plus scene-level receiver surfaces where `receiveShadow` is enabled. 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.
44+
Cast shadows are not a render-strategy leaf tag. Meshes with `castShadow: true` project casting polygons on the CPU into SVG shadow surfaces: one aggregate path for the ground plane in vanilla, per-mesh SVG paths in React/Vue, plus scene-level receiver surfaces where `receiveShadow` is enabled. EVERY polygon casts — shadow casters are NOT filtered to the camera-rendered set (atlas plan) or de-duplicated, because a polygon casts a shadow regardless of whether it's painted for the camera; filtering left camera-dependent holes in imported-mesh shadows. Coincident/overlapping projections are merged into one compound path per caster under `fill-rule: nonzero`, so they don't alpha-stack rather than being pre-dropped. 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 `<svg>`/`<path>` 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.
4545

4646
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.
4747

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

50+
### Lights
51+
52+
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 `` 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.)
53+
5054
### Lighting modes (`PolyTextureLightingMode = "baked" | "dynamic"`)
5155

52-
- **Baked.** Lambert is computed once on the CPU per polygon, multiplied into the inline `color` (for `<b>`/`<i>`/`<u>`) or into the rasterised atlas pixels (for atlas-backed `<s>`). Direct image `<s>` 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.
53-
- **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.
56+
- **Baked.** Lambert (directional + each point light + ambient) is computed once on the CPU per polygon, multiplied into the inline `color` (for `<b>`/`<i>`/`<u>`) or into the rasterised atlas pixels (for atlas-backed `<s>`). Direct image `<s>` 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.
57+
- **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.
5458

5559
All solid and atlas-backed tags work in both modes. Direct image `<s>` 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`.
5660

@@ -89,7 +93,7 @@ If you find yourself wanting a `requestAnimationFrame` loop to update many DOM n
8993
- Every public export gets a `Poly` prefix. Exceptions are generic math types: `Vec2`, `Vec3`, `Polygon`, `PolyMaterial` (already prefixed).
9094
- **Hooks/composables:** `usePolyCamera`, `usePolyMesh`, `usePolySceneContext`, `usePolySelect`, `usePolySelectionApi`, `usePolyAnimation`.
9195
- **Components:** `PolyPerspectiveCamera`, `PolyOrthographicCamera`, `PolyOrbitControls`, `PolyMapControls`, `PolyTransformControls`, `PolySelect`, `PolyAxesHelper`, `PolyDirectionalLightHelper`, `PolyIframe`.
92-
- **Types:** `PolyDirectionalLight`, `PolyAmbientLight`, `PolyTextureLightingMode`, `PolyTextureLeafSizing`, `PolyTextureBackend`, `PolyTextureImageRendering`, `PolyTextureImageLighting`, `PolyTextureProjection`, `PolyTexturePresentation`, `PolyTextureImageSource`, `PolyCameraProjection`, `PolyCameraSnapshot`, `PolyCameraSnapshotStats`, `PolyMeshTransformInput`, `PolySceneTransformInput`, `PolyAnimationMixer`, `PolyRenderStats`.
96+
- **Types:** `PolyDirectionalLight`, `PolyPointLight`, `PolyAmbientLight`, `PolyTextureLightingMode`, `PolyTextureLeafSizing`, `PolyTextureBackend`, `PolyTextureImageRendering`, `PolyTextureImageLighting`, `PolyTextureProjection`, `PolyTexturePresentation`, `PolyTextureImageSource`, `PolyCameraProjection`, `PolyCameraSnapshot`, `PolyCameraSnapshotStats`, `PolyMeshTransformInput`, `PolySceneTransformInput`, `PolyAnimationMixer`, `PolyRenderStats`.
9397
- **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`.
9498
- **Vanilla factories:** `create*` names stay as-is (`createPolyScene`, `createTransformControls`, `createSelect`).
9599
- **HTML custom elements:** `poly-` prefix + kebab-case. Existing tags: `<poly-scene>`, `<poly-mesh>`, `<poly-iframe>`, `<poly-polygon>`, `<poly-perspective-camera>`, `<poly-orthographic-camera>`, `<poly-axes-helper>`, `<poly-directional-light-helper>`. Any new element follows the same shape (e.g. `<poly-transform-controls>`, `<poly-select>`).

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export default function App() {
6969
### PolyScene
7070

7171
- `polygons` renders a static `Polygon[]` directly.
72-
- `directionalLight` and `ambientLight` control scene lighting.
72+
- `directionalLight`, `pointLights` (direction-only, baked mode; optional per-light `castShadow`), and `ambientLight` control scene lighting.
7373
- `textureLighting` chooses `"baked"` or `"dynamic"`.
7474
- `textureQuality` controls atlas raster budget.
7575
- `strategies` can disable selected render strategies for diagnostics.

bench/build.mjs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,21 @@ const targets = [
100100
entry: resolve(__dirname, "entries/vue.ts"),
101101
out: resolve(bundleDir, "polycss-vue.js"),
102102
},
103+
{
104+
label: "shadow-parity shared meshes",
105+
entry: resolve(__dirname, "entries/parityMeshes.ts"),
106+
out: resolve(bundleDir, "parity-meshes.js"),
107+
},
108+
{
109+
label: "shadow-parity react mount",
110+
entry: resolve(__dirname, "entries/shadowParityReact.tsx"),
111+
out: resolve(bundleDir, "shadow-parity-react.js"),
112+
},
113+
{
114+
label: "shadow-parity vue mount",
115+
entry: resolve(__dirname, "entries/shadowParityVue.ts"),
116+
out: resolve(bundleDir, "shadow-parity-vue.js"),
117+
},
103118
{
104119
label: "HTML chunk mount bench entry",
105120
entry: resolve(__dirname, "entries/htmlMount.ts"),

bench/entries/parityMeshes.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Bench entry — shared scene geometry for shadow-parity.html, so every pane
3+
* (vanilla / React / Vue) renders byte-identical polygons. Bundled into
4+
* bench/.generated/parity-meshes.js.
5+
*/
6+
import { boxPolygons } from "@layoutit/polycss-core";
7+
import type { Polygon } from "@layoutit/polycss-core";
8+
9+
/** Unit-2 cube centered at the origin (sit it on the floor with position z=1). */
10+
export function cubePolygons(color = "#dc2626"): Polygon[] {
11+
return boxPolygons({ size: 2 }).map((p) => ({ ...p, color }));
12+
}
13+
14+
/** Flat square floor on z=0. */
15+
export function floorPolygons(size = 20, color = "#cbd5e1"): Polygon[] {
16+
const h = size / 2;
17+
return [
18+
{
19+
vertices: [
20+
[-h, -h, 0],
21+
[h, -h, 0],
22+
[h, h, 0],
23+
[-h, h, 0],
24+
],
25+
color,
26+
},
27+
];
28+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* Bench entry — React shadow-parity mount. Bundled by bench/build.mjs into
3+
* bench/.generated/shadow-parity-react.js and used by bench/shadow-parity.html.
4+
*
5+
* Exposes a tiny imperative `mount(host, params)` that renders the SAME scene
6+
* the vanilla / Vue / three panes render, so the parity page can compare all
7+
* renderers pixel-for-pixel. Driven via the public component API (no iframe /
8+
* postMessage).
9+
*/
10+
import { createElement as h } from "react";
11+
import { createRoot } from "react-dom/client";
12+
import { PolyCamera, PolyScene, PolyMesh } from "@layoutit/polycss-react";
13+
14+
export interface ParityParams {
15+
cubePolys: unknown[];
16+
floorPolys: unknown[];
17+
cubeCenter: [number, number, number];
18+
directionalLight?: unknown;
19+
pointLights?: unknown[];
20+
ambientLight?: unknown;
21+
textureLighting: "baked" | "dynamic";
22+
shadow: { color?: string; opacity?: number; lift?: number };
23+
cam: { rotX: number; rotY: number; zoom: number };
24+
}
25+
26+
export function mount(host: HTMLElement, initial: ParityParams) {
27+
const root = createRoot(host);
28+
const render = (p: ParityParams): void => {
29+
root.render(
30+
h(
31+
PolyCamera as never,
32+
{ rotX: p.cam.rotX, rotY: p.cam.rotY, zoom: p.cam.zoom } as never,
33+
h(
34+
PolyScene as never,
35+
{
36+
directionalLight: p.directionalLight,
37+
pointLights: p.pointLights,
38+
ambientLight: p.ambientLight,
39+
textureLighting: p.textureLighting,
40+
shadow: p.shadow,
41+
} as never,
42+
h(PolyMesh as never, { key: "floor", polygons: p.floorPolys, receiveShadow: true } as never),
43+
h(PolyMesh as never, { key: "cube", polygons: p.cubePolys, position: p.cubeCenter, castShadow: true } as never),
44+
),
45+
),
46+
);
47+
};
48+
render(initial);
49+
return { update: render, dispose: () => root.unmount() };
50+
}

bench/entries/shadowParityVue.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* Bench entry — Vue shadow-parity mount. Bundled by bench/build.mjs into
3+
* bench/.generated/shadow-parity-vue.js and used by bench/shadow-parity.html.
4+
*
5+
* Mirror of shadowParityReact.tsx: a tiny imperative `mount(host, params)`
6+
* that renders the same scene as the other panes via the public component API.
7+
*/
8+
import { createApp, h, reactive } from "vue";
9+
import { PolyCamera, PolyScene, PolyMesh } from "@layoutit/polycss-vue";
10+
11+
export interface ParityParams {
12+
cubePolys: unknown[];
13+
floorPolys: unknown[];
14+
cubeCenter: [number, number, number];
15+
directionalLight?: unknown;
16+
pointLights?: unknown[];
17+
ambientLight?: unknown;
18+
textureLighting: "baked" | "dynamic";
19+
shadow: { color?: string; opacity?: number; lift?: number };
20+
cam: { rotX: number; rotY: number; zoom: number };
21+
}
22+
23+
export function mount(host: HTMLElement, initial: ParityParams) {
24+
const st = reactive<{ p: ParityParams }>({ p: initial });
25+
const app = createApp({
26+
render() {
27+
const p = st.p;
28+
return h(
29+
PolyCamera as never,
30+
{ rotX: p.cam.rotX, rotY: p.cam.rotY, zoom: p.cam.zoom },
31+
{
32+
default: () =>
33+
h(
34+
PolyScene as never,
35+
{
36+
directionalLight: p.directionalLight,
37+
pointLights: p.pointLights,
38+
ambientLight: p.ambientLight,
39+
textureLighting: p.textureLighting,
40+
shadow: p.shadow,
41+
},
42+
{
43+
default: () => [
44+
h(PolyMesh as never, { polygons: p.floorPolys, receiveShadow: true }),
45+
h(PolyMesh as never, { polygons: p.cubePolys, position: p.cubeCenter, castShadow: true }),
46+
],
47+
},
48+
),
49+
},
50+
);
51+
},
52+
});
53+
app.mount(host);
54+
return { update: (np: ParityParams) => { st.p = np; }, dispose: () => app.unmount() };
55+
}

0 commit comments

Comments
 (0)