Skip to content

Commit 35cf577

Browse files
committed
docs: scope ground-shadow fallback to React/Vue
1 parent 73491da commit 35cf577

3 files changed

Lines changed: 19 additions & 16 deletions

File tree

‎AGENTS.md‎

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

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

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

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

‎website/src/content/docs/components/poly-scene.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ React/Vue `<PolyMesh>` supports the full table. The `<poly-mesh>` custom element
6262
| `parseOptions` | `UseMeshOptions` | Parser options forwarded to `loadMesh`; `meshResolution` defaults to `"lossy"`. |
6363
| `meshResolution` | `"lossless" \| "lossy"` | Top-level optimizer intent. Wins over `parseOptions.meshResolution`; defaults to `"lossy"`. |
6464
| `castShadow` | `boolean` | Emit SVG cast shadows in both lighting modes; projections update when light, ground, or mesh geometry changes. |
65-
| `receiveShadow` | `boolean` | Casters project per-coplanar-face SVG shadows onto this mesh's visible surfaces (Three.js `mesh.receiveShadow` semantics). Any receiver in the scene disables the casters' ground-shadow fallback. Defaults to `false`. |
65+
| `receiveShadow` | `boolean` | Casters project per-coplanar-face SVG shadows onto this mesh's visible surfaces (Three.js `mesh.receiveShadow` semantics). Defaults to `false`. In React/Vue, any receiver in the scene disables the casters' ground-shadow fallback; vanilla has no such fallback, so a receiver is required for any shadow to appear. |
6666
| `shadowDefinition` | `number` | Per-mesh parametric-shadow detail, overriding the scene's `shadow.definition` (only when `shadow.parametric`). |
6767
| `merge` | `boolean` | Run the polygon optimizer (dedupe, interior cull, coplanar/lossy merge). Defaults to `true`. Set `false` to render **the polygon array entering the renderer** exactly as given. It cannot restore source-file geometry: with `src`, `loadMesh` has already optimized the parse result before `merge` is consulted. |
6868
| `fallback` | `ReactNode` | Rendered while `src` is loading. (React / Vue only.) |

‎website/src/content/docs/guides/lighting.mdx‎

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -173,20 +173,23 @@ scene.setOptions({ shadow: { color: "#000000", opacity: 0.3, lift: 0.02 } });
173173
There are two distinct receiver mechanisms, and mixing them up is a common
174174
source of "my shadow disappeared":
175175

176-
- **Ground-shadow fallback.** A `castShadow` mesh projects onto the scene's
177-
ground plane automatically, with no receiver mesh required. This is what
178-
`<PolyGround>` relies on — it is a convenience quad that renders with
179-
`castShadow: false` and has **no `receiveShadow` prop of its own**. Passing
180-
`receiveShadow` to `<PolyGround>` does nothing.
181-
- **`receiveShadow` meshes.** Marking any mesh `receiveShadow` makes casters
182-
project per-coplanar-face SVG shadows onto each of its visible surfaces
183-
(Three.js `mesh.receiveShadow` semantics). As soon as *any* receiver exists in
184-
the scene, casters **drop the ground-shadow fallback** so the receiver paints
185-
the only shadow pass.
186-
187-
So `<PolyGround>` alone works, and an explicit `receiveShadow` mesh alone works
188-
— but adding a receiver elsewhere in the scene will silently turn off the
189-
ground fallback under `<PolyGround>`.
176+
- **Ground-shadow fallback — React/Vue only.** A `castShadow` mesh projects onto
177+
the scene's ground plane automatically, with no receiver mesh required. This is
178+
what `<PolyGround>` relies on: it is a convenience quad that renders with
179+
`castShadow: false` and has **no `receiveShadow` prop of its own** (passing one
180+
does nothing). **Vanilla `createPolyScene` has no such fallback** — a caster
181+
with no receiver in the scene emits no shadow, so vanilla scenes must mark a
182+
floor mesh `receiveShadow: true` explicitly.
183+
- **`receiveShadow` meshes — all renderers.** Marking any mesh `receiveShadow`
184+
makes casters project per-coplanar-face SVG shadows onto each of its visible
185+
surfaces (Three.js `mesh.receiveShadow` semantics). In React/Vue, as soon as
186+
*any* receiver exists in the scene, casters **drop the ground-shadow fallback**
187+
so the receiver paints the only shadow pass.
188+
189+
So in React/Vue, `<PolyGround>` alone works and an explicit `receiveShadow` mesh
190+
alone works — but adding a receiver elsewhere silently turns off the ground
191+
fallback under `<PolyGround>`. In vanilla, an explicit receiver is the only
192+
option.
190193

191194
What to expect:
192195

0 commit comments

Comments
 (0)