Skip to content

Commit 67257fd

Browse files
committed
docs: scope renderer-specific shadow, throttle, and backend claims
1 parent 35cf577 commit 67257fd

7 files changed

Lines changed: 47 additions & 35 deletions

File tree

‎AGENTS.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ Voxel-shaped meshes are the exception to "all polygons stay mounted": meshes wit
5050

5151
Strategies are ordered cheapest → most expensive. The mesher's job is to maximise `<b>` / `<u>` / `<i>` and minimise `<s>` (see "Meshing implications" below).
5252

53-
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 gives detected shared solid edges 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 exposed: `seamBleed` is a scene option in all three renderers plus a React/Vue per-mesh prop, though not a custom-element attribute. **Known divergence:** vanilla clamps the number to `0..1` and multiplies the `1.5` px default (`resolveBleedRatio`), while React/Vue pass the raw value straight into core plan construction, where `normalizedSeamBleed` accepts finite positive numbers only. `"auto"` therefore diverges too: vanilla resolves it to the full `1.5` px default, but React/Vue's `"auto"` yields no shared-edge overscan at all (the plan's `bleedRatio` still resolves to `1`, so per-strategy primitive bleeds are unchanged). Only the numeric default `1.5` matches across renderers. Unifying this is a pending cross-renderer fix, not a docs problem.
53+
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 gives detected shared solid edges 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 exposed: `seamBleed` is a scene option in all three renderers plus a React/Vue per-mesh prop, though not a custom-element attribute. **Known divergence:** vanilla clamps the number to `0..1` and multiplies the `1.5` px default (`resolveBleedRatio`), while React/Vue pass the raw value straight into core plan construction, where `normalizedSeamBleed` accepts finite positive numbers only. `"auto"` therefore diverges too: vanilla resolves it to the full `1.5` px default, but React/Vue's `"auto"` yields no shared-edge overscan at all (the plan's `bleedRatio` still resolves to `1`, so per-strategy primitive bleeds are unchanged). Only the numeric default `1.5` matches across renderers. Sub-1 numbers additionally scale the per-strategy primitive bleeds in both, by different factors (vanilla via its recomputed ratio, React/Vue by the raw value). Unifying this is a pending cross-renderer fix, not a docs problem.
5454

5555
Cast shadows are not a render-strategy leaf tag. Meshes with `castShadow: true` project casting polygons on the CPU into SVG shadow surfaces onto scene-level receiver surfaces where `receiveShadow` is enabled. **Renderer divergence on the no-receiver case:** vanilla dropped its legacy virtual ground-shadow fallback for Three.js parity — a caster with no receiver in the scene draws nothing, `emitGroundShadow` is now dead code, and `hideGroundShadow()` suppresses any legacy leftovers every tick. React/Vue still emit a per-mesh ground-plane shadow when a caster has no receiver, and drop it as soon as any receiver exists. Reconciling the two is an open decision. 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.
5656

@@ -72,7 +72,7 @@ The scene takes one `directionalLight`, one `ambientLight`, and zero or more `po
7272

7373
### Lighting modes (`PolyTextureLightingMode = "baked" | "dynamic"`)
7474

75-
- **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.
75+
- **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})` 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. **Exception:** a `setOptions({pointLights})` change DOES re-render every mesh in vanilla (`createPolyScene.ts` `pointLightsChanged` → `renderEntry`), so the freeze applies to the directional light only.
7676
- **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.
7777

7878
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`.
@@ -165,7 +165,7 @@ React or Vue wrappers.
165165
- **Types:** `PolyDirectionalLight`, `PolyPointLight`, `PolyAmbientLight`, `PolyTextureLightingMode`, `PolyTextureLeafSizing`, `PolyTextureBackend`, `PolyTextureImageRendering`, `PolyTextureImageLighting`, `PolyTextureProjection`, `PolyTexturePresentation`, `PolyTextureImageSource`, `PolyCameraProjection`, `PolyCameraSnapshot`, `PolyCameraSnapshotStats`, `PolyMeshTransformInput`, `PolySceneTransformInput`, `PolyAnimationMixer`, `PolyRenderStats`.
166166
- **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`.
167167
- **Vanilla factories:** `create*` names stay as-is (`createPolyScene`, `createTransformControls`, `createSelect`).
168-
- **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>`).
168+
- **HTML custom elements:** `poly-` prefix + kebab-case. Registered tags (see `packages/polycss/src/elements/index.ts`): `<poly-scene>`, `<poly-mesh>`, `<poly-iframe>`, `<poly-polygon>`, `<poly-camera>`, `<poly-perspective-camera>`, `<poly-orthographic-camera>`, `<poly-orbit-controls>`, `<poly-map-controls>`, `<poly-first-person-controls>`, `<poly-transform-controls>`, `<poly-select>`, `<poly-axes-helper>`, and the shape elements (`<poly-box>`, `<poly-plane>`, `<poly-ring>`, `<poly-sphere>`, `<poly-cylinder>`, `<poly-cone>`, `<poly-torus>`, and the Platonic solids). Any new element follows the same shape.
169169
- **`<poly-iframe>`:** flat textured "quad" whose "texture" is a live document (an `<iframe>`) instead of an atlas slice. NOT a render-strategy leaf — same transform conventions as `<poly-mesh>` (`position`/`rotation`/`scale` post-parity; iframe content centered at the wrapper's local origin so rotation/scale pivot at the visible center). Mounted as a child of `.polycss-scene` and inherits the camera transform.
170170
- **Leaf DOM tags (`<b>`, `<i>`, `<s>`, `<u>`):** internal render-strategy tags. Not part of the public API and not user-facing — do not document them as such.
171171
- `PolyCamera` is a kept alias for `PolyOrthographicCamera` — the ergonomic default, optimised for iso/voxel/diagrammatic scenes which is PolyCSS's structural strength. **Not deprecated.**

‎packages/react/README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export default function App() {
4545
### `<PolyCamera>`
4646

4747
- `rotX`, `rotY` control the orbit angle in degrees.
48-
- `zoom` is on-screen CSS pixels per world unit (Three.js `OrthographicCamera.zoom` style).
48+
- `zoom` is on-screen CSS pixels per world unit (default `0.65`; orbit controls clamp to `0.1`–`10`).
4949
- `target` pans the camera target in world coordinates.
5050
- `distance` adds dolly pull-back.
5151
- `PolyCamera` is the orthographic default. Use `<PolyPerspectiveCamera>` for
@@ -67,7 +67,8 @@ export default function App() {
6767
`<PolyScene autoCenter><PolyMesh src=… /></PolyScene>` the bbox is empty and
6868
nothing shifts. Pass the mesh's polygons as `centerPolygons`, or use
6969
`<PolyMesh autoCenter>` to recenter the mesh itself. (Vanilla
70-
`createPolyScene` differs — it unions every added mesh.)
70+
`createPolyScene` differs — it unions every added mesh, minus any marked
71+
`excludeFromAutoCenter`.)
7172

7273
Unlike the vanilla renderer, React re-renders on prop change, so a light change
7374
**auto-rebakes** the lit surface in baked mode. For live or animated lights,

‎packages/vue/README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ Props are listed in their template (kebab-case) form.
5858
### `<PolyCamera>`
5959

6060
- `rot-x`, `rot-y` control the orbit angle in degrees.
61-
- `zoom` is on-screen CSS pixels per world unit (Three.js `OrthographicCamera.zoom` style).
61+
- `zoom` is on-screen CSS pixels per world unit (default `0.65`; orbit controls clamp to `0.1`–`10`).
6262
- `target` pans the camera target in world coordinates.
6363
- `distance` adds dolly pull-back.
6464
- `PolyCamera` is the orthographic default. Use `<PolyPerspectiveCamera>` for
@@ -80,7 +80,8 @@ Props are listed in their template (kebab-case) form.
8080
`<PolyScene auto-center><PolyMesh src="…" /></PolyScene>` the bbox is empty
8181
and nothing shifts. Pass the mesh's polygons as `center-polygons`, or use
8282
`<PolyMesh auto-center>` to recenter the mesh itself. (Vanilla
83-
`createPolyScene` differs — it unions every added mesh.)
83+
`createPolyScene` differs — it unions every added mesh, minus any marked
84+
`excludeFromAutoCenter`.)
8485

8586
Unlike the vanilla renderer, Vue re-renders on prop change, so a light change
8687
**auto-rebakes** the lit surface in baked mode. For live or animated lights,

‎website/public/skill.md‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ import {
5555
- Coordinates are PolyCSS world space: `[x, y, z]`, **+X right, +Y forward,
5656
+Z up**.
5757
- Camera rotations are degrees: `rotX`, `rotY`.
58-
- `zoom` is on-screen CSS pixels per world unit (Three.js
59-
`OrthographicCamera.zoom` style). `BASE_TILE` (50) is the internal world-unit
58+
- `zoom` is on-screen CSS pixels per world unit (default `0.65`; orbit controls
59+
clamp to `0.1`–`10`). `BASE_TILE` (50) is the internal world-unit
6060
→ CSS px factor, and matters only for APIs that take world units directly,
6161
like `<poly-iframe width>`.
6262
- `PolyCamera` / `createPolyCamera` are orthographic by default.
@@ -103,9 +103,9 @@ console warning:
103103
right-hand rule (`(v1-v0) × (v2-v0)`), and PolyCSS backface-culls every leaf. A
104104
reversed face is invisible from the side you meant to show, and shades from the
105105
flipped normal — typically ambient-only, since the directional term clamps at
106-
zero (it darkens, it does not invert). It still casts a ground shadow: shadow
107-
projection ignores winding. Wind counter-clockwise as seen from the side you
108-
want to look at.
106+
zero (it darkens, it does not invert). It still casts — onto `receiveShadow`
107+
meshes, or React/Vue's ground fallback — because shadow projection ignores
108+
winding. Wind counter-clockwise as seen from the side you want to look at.
109109

110110
```ts
111111
// Faces +Z (up) — visible from above.
@@ -251,10 +251,13 @@ scene.add(floor, { receiveShadow: true });
251251
scene.setOptions({ shadow: { color: "#000000", opacity: 0.3, parametric: true, definition: 32 } });
252252
```
253253

254-
- Two receiver mechanisms: a caster projects onto the scene ground plane
255-
automatically (what `<PolyGround>` relies on — it has **no** `receiveShadow`
256-
prop), *or* onto explicit `receiveShadow` meshes. As soon as any receiver
257-
exists, casters drop the ground fallback.
254+
- **Receivers differ by renderer.** Vanilla has no ground fallback: a
255+
`castShadow` mesh draws nothing until some mesh has `receiveShadow: true`, so
256+
`scene.add(floor, { receiveShadow: true })` is required (the snippet above
257+
does this). React/Vue additionally project onto the scene ground plane
258+
automatically when no receiver exists — that is what `<PolyGround>` relies on
259+
(it has **no** `receiveShadow` prop) — and drop that fallback as soon as any
260+
receiver exists.
258261
- `shadow.parametric: true` casts a low-resolution coverage silhouette per
259262
caster instead of full geometry — far cheaper. `definition` (default `16`)
260263
is the detail knob; `<PolyMesh shadowDefinition>` overrides it per mesh.

‎website/src/content/docs/api/types.mdx‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,8 @@ are preserved as-is.
108108
/** Leaf primitive sizing for texture leaves. Default "canonical". */
109109
type PolyTextureLeafSizing = "canonical" | "local" | "raster";
110110

111-
/** Requested backend. "auto" lets the renderer choose. Default "auto". */
111+
/** Requested backend. Default "auto", which currently always resolves to the
112+
* atlas — direct image leaves must be requested explicitly with "image". */
112113
type PolyTextureBackend = "auto" | "atlas" | "image";
113114

114115
/** CSS `image-rendering` for the leaf. Default "auto". */
@@ -341,7 +342,9 @@ interface PolySceneOptions {
341342
color?: string;
342343
/** 0..1. Default: 0.25. */
343344
opacity?: number;
344-
/** World units above the receiver, avoids z-fighting. Default: 0.05. */
345+
/** World units the shadow is lifted off its receiver to avoid z-fighting.
346+
* Default 0.05 for the ground-plane fallback, 0.001 for `receiveShadow`
347+
* meshes. */
345348
lift?: number;
346349
/** Max CSS px the shadow may extend past the mesh footprint. Default: 2000. */
347350
maxExtend?: number;
@@ -359,11 +362,14 @@ interface PolySceneOptions {
359362
* greedy-meshes the coverage into blocky rectangles. */
360363
style?: "vector" | "pixel";
361364
/** Re-emit shadows while a mesh animates instead of freezing at the last
362-
* pose. Throttled internally (~12fps). Default: false. */
365+
* pose. Default false. Vanilla throttles the re-emit internally (~12fps);
366+
* React/Vue re-emit on every polygon change — pair with a low parametric
367+
* `definition`. */
363368
followAnimation?: boolean;
364369
};
365370
/** Emit `data-poly-shadow-*` attribution attributes on every shadow SVG and
366-
* path, for DevTools inspection. Default: false. */
371+
* path, for DevTools inspection. Vanilla `createPolyScene` only.
372+
* Default: false. */
367373
debugShadowAttrs?: boolean;
368374
}
369375

0 commit comments

Comments
 (0)