Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
e7852cc
feat(core): direction-only point-light shading in the baked atlas pla…
apresmoi Jun 19, 2026
033e611
feat(polycss): pointLights scene option — per-mesh local conversion +…
apresmoi Jun 19, 2026
ed53a5b
feat(bench): point-light oracle — 2 point lights (PolyCSS) vs three.j…
apresmoi Jun 19, 2026
59d5c4a
feat(react,vue): mirror direction-only point-light shading in baked a…
apresmoi Jun 19, 2026
a183d98
feat(core,polycss): radial point-light cast shadows (per-light receiv…
apresmoi Jun 19, 2026
990bfc8
feat(react,vue): mirror radial point-light cast shadows (per-light re…
apresmoi Jun 19, 2026
e1c9b32
docs: document pointLights (direction-only shading + radial cast shad…
apresmoi Jun 19, 2026
e8a72c1
feat(shadows): shaded per-light colored shadows + point-light bench c…
apresmoi Jun 19, 2026
99ca66e
feat(shadows): merge a face's lights into one SVG for correct overlap…
apresmoi Jun 19, 2026
297301e
fix(shadows): no directional shadow at zero/absent intensity (vanilla…
apresmoi Jun 20, 2026
a56fc5e
bench(color): shadow-color delta oracle (oracleColorDelta) + fix defa…
apresmoi Jun 20, 2026
d1707b0
fix(shadows): dynamic mode ignores point lights for shadows too (no c…
apresmoi Jun 20, 2026
a210eb2
docs: dynamic mode ignores point lights for shadows too (directional-…
apresmoi Jun 20, 2026
1cb3708
feat(shadows): share merged per-face shadow path across all renderers…
apresmoi Jun 20, 2026
44f976d
bench: 4-pane shadow-parity page (vanilla/react/vue/three) to confirm…
apresmoi Jun 20, 2026
481ca98
fix(polycss): setOptions re-emits shadows on directional intensity/co…
apresmoi Jun 20, 2026
cb89ce7
docs: document baked rebake contract — vanilla explicit rebake vs Rea…
apresmoi Jun 20, 2026
bbf0c6c
docs: add Lighting & Shadows guide (directional/ambient/point lights,…
apresmoi Jun 20, 2026
edc0936
docs: interactive lighting demo — PolyDemo gains light controls (inte…
apresmoi Jun 20, 2026
57730e9
docs: use a cube in the lighting demo (clearer per-face Lambert + cle…
apresmoi Jun 20, 2026
49a8f62
docs: lighting demo — drop no-op rotY control, zoom out a touch
apresmoi Jun 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<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.

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

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 `<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.

### 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` 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 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.
- **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 `<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.
- **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 `<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`.

Expand Down Expand Up @@ -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: `<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>`).
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions bench/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
28 changes: 28 additions & 0 deletions bench/entries/parityMeshes.ts
Original file line number Diff line number Diff line change
@@ -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,
},
];
}
50 changes: 50 additions & 0 deletions bench/entries/shadowParityReact.tsx
Original file line number Diff line number Diff line change
@@ -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() };
}
55 changes: 55 additions & 0 deletions bench/entries/shadowParityVue.ts
Original file line number Diff line number Diff line change
@@ -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() };
}
Loading
Loading