Skip to content

feat: three.js parity sweep — API, shadows, lighting, transforms across all renderers#60

Merged
apresmoi merged 197 commits into
mainfrom
feat/three-parity
Jun 3, 2026
Merged

feat: three.js parity sweep — API, shadows, lighting, transforms across all renderers#60
apresmoi merged 197 commits into
mainfrom
feat/three-parity

Conversation

@apresmoi
Copy link
Copy Markdown
Collaborator

@apresmoi apresmoi commented Jun 3, 2026

Summary

End-to-end three.js parity for PolyCSS. Aligns the public API (position, rotation, scale, zoom, lights) with Three.js conventions, rewrites the shadow system to a receiver-only model with per-receiver-face SVG projection, replaces the lighting pipeline with physical Lambert + linear-light BRDF, and mirrors every change into the React + Vue bindings. Adds a comprehensive bench infrastructure for cross-renderer parity and a multi-iteration perf research loop that landed several real wins.

196 commits, 159 files, +14691/-4008. Squash-merge.

What changed, by theme

1. Public API: three.js shapes at the boundary

  • Position is world units (+X right, +Y forward, +Z up); the renderer applies the world→CSS axis swap (world.y → CSS.x, world.x → CSS.y) and × BASE_TILE scale internally. Mesh position, camera.target, light direction, light helper position — all consume the Three.js frame at the public surface.
  • Zoom semantics match THREE.OrthographicCamera.zoomzoom=1 ≡ 1 world unit = 1 CSS pixel; the scene-root scale(zoom / DEFAULT_TILE) absorbs the internal tile scale.
  • Rotation pivots at the wrapper's local origin (was bbox-center), matching mesh.position / mesh.rotation / mesh.scale. Parsers gained { center: true } opt-in for callers that want bbox-centered geometry.
  • World↔CSS reflection conjugation on rotation: a world R(n, θ) becomes R(M·n, −θ) in CSS frame — axis swap and angle sign flip. world X → CSS Y, world Y → CSS X, world Z → CSS Z. Mirrored into React + Vue buildTransform in this PR.
  • Light direction in user-frame at the API. Renderer applies worldDirectionToCss before any Lambert dot.
  • gridShift dropped — parsed bbox MIN now lands at world origin (like Three.js), instead of the legacy (1,1,1) offset.

2. Shadow system: receiver-only, per-receiver-face SVG

  • Receiver-only shadows — virtual-ground fallback dropped. Without a mesh marked receiveShadow: true, no shadow paints. Matches Three.js's mesh.receiveShadow contract.
  • Per-coplanar-face SVG projection: one <svg> per receiver face placed via matrix3d(u, v, n, O) so SVG-local (u, v) projects back onto the receiver plane. Replaces the legacy per-caster compound SVG.
  • Member-polygon clipping — shadows clip against the actual member polygons of a face group, not just the face outline; kills phantom shadows on door frames and small surface features.
  • Cast + receive coexist — meshes that cast can also receive (self-shadow).
  • Self-shadow seam cull via shared-edge adjacency map + min-area threshold — kills the spiderweb seam shadows on smooth GLBs (apple, sphere, teapot).
  • Light-visibility cull + interior-occlusion cull + coplanar-caster skip + sliver cull (per member-clip area).
  • Cached per-light raytrace + skip shadow paint on fully-occluded faces.
  • All shadow SVGs grouped inside a .polycss-shadows wrapper mounted under the scene root, instead of scattered across mesh wrappers.
  • castShadow toggle correctly tears down receiver SVGs when no casters remain or when receiveShadow flips off.
  • Mesh rotation applied to caster + receiver world vertices via wrapper CSS rotation matrix conjugation.
  • shadow.maxExtend propagated through the receiver-face path.
  • shadow.lift wired through scene options + zoom-scaled bench.

3. Lighting: physical Lambert + linear-light BRDF

  • Three.js BRDF_Lambert / π in shadePolygon — direct + ambient both wrap by 1/π. Matches MeshLambertMaterial exactly.
  • Physical Lambert for textured atlas tints — per-pixel srgbByteToLinear → multiply → linearToSrgbByte instead of canvas globalCompositeOperation = "multiply" (which is sRGB × sRGB and noticeably darkens mid-tones). React + Vue copies mirrored in this PR; rock1 went from ~3× too dark to byte-identical with vanilla.
  • Dynamic Lambert formula matches MeshLambertMaterial in styles.ts cascade.
  • Per-mesh dynamic light override — on non-zero mesh rotation, emit --plx/ly/lz on the wrapper inverse-rotated into the mesh local frame.
  • Per-light raytrace occlusion DROPPED from baked atlas — Three.js doesn't bake shadow into the diffuse atlas (the real shadow map handles occluded geometry at render time). Vanilla disabled this; React + Vue mirrored in this PR.
  • Baked solid leaves always emit entry.shadedColor — the === solidPaintDefaults.paintColor shortcut produced silently-wrong inherited colors when shadedColor was undefined ("first render looks wrong, fixes after any light nudge"). React + Vue mirrored.
  • castShadow flipping a voxel mesh swaps it back to the polygon renderer so shadows generate.
  • Voxel brush Lambert matches the polygon path (sRGB+/π).
  • CSS-cascade baked-solid preview for live light helper drag.

4. Wrapper-rotation refactor

  • Mesh position / rotation / scale all pivot at the wrapper's local origin (transform-origin: 0 0 0 on .polycss-mesh).
  • Three-js-parity Euler convention: world [rx, ry, rz] → CSS rotateY(−rx) rotateX(−ry) rotateZ(−rz).
  • parseGltf + parseObj accept { center } for callers that want centroid-pivot (bbox-min-at-origin by default).
  • Receiver back-face cull skips mesh rotation since the face plane normal is already in world frame.
  • Scale shadow geometry from mesh origin to match wrapper pivot.

5. Cross-renderer parity (React + Vue mirror)

Per AGENTS.md "Renderer-owned browser glue" — the canvas atlas pipeline, browser-feature detection, direct voxel renderer, and .polycss-scene/.polycss-camera base styles exist as independent copies across the three renderers. This PR makes them consistent:

  • React + Vue receiver-face shadows ported from vanilla.
  • Receiver SVGs portaled out of the mesh wrapper.
  • lightOccludedPolyIndices self-shadow path.
  • buildBasisHints + data-poly-index parity.
  • worldDirectionalLightToCss applied to bakedDirectional before atlas plan.
  • Scene lights forwarded to atlas plan in dynamic mode + cornerShape transform parity.
  • borderShape strategy active in dynamic lighting.
  • voxelSource prop exposed on <PolyMesh> for the direct voxel fast path.
  • cast-shadow + receive-shadow attributes on <poly-mesh> custom element.
  • objOptions attributes exposed on <poly-mesh>.
  • Camera CSS + orthographic perspective parity (zoom, rotX, rotY).
  • optimizeMeshPolygons in PolyMesh + merge prop.
  • React + Vue dynamic-mode CSS-var quantize (H10 mirror).
  • worldDirectionToCss applied to shadow light.
  • TextureCornerShapeSolidPoly + filterAtlasPlans cornerShape support.
  • Wrapper rotation axes + local-origin pivot mirror (this PR).
  • Mesh rotation passed to prepareReceiverFacePlanes / prepareCasterPolyItems.
  • Per-pixel linear-light texture tint (this PR).
  • Drop === solidPaintDefaults.paintColor shortcut on baked solid leaves (this PR).

6. Performance research loop

Established bench/notes/SHADOW_PERF_LOG.md + bench/scripts/shadow-regression.mjs (teapot-self, teapot-floor, castle-floor, crate-floor × 3 azimuths) and a three-parity-shots.mjs script for visual regression vs three.js.

Iterations explored (kept on branches for traceability):

  • H2 path simplification on d= strings — flat, discarded.
  • H3 light-direction quantization → skip per-frame re-emit — landed.
  • H8 d= memoization (re-tested post-H9b) — flat, discarded.
  • H9 per-caster-mesh silhouette extraction — landed.
  • H9b silhouette for self-shadow casters — reverted (broke coliseum self-shadow contrast).
  • H10 quantize --plx/ly/lz + --clx/cly/clz CSS vars to 0.01 → +38% mean fps on worst-case teapot-self drag. Mirrored to React + Vue.
  • H11 receiver-face coalesce — negative, discarded.
  • H11b silhouette-onto-OBB-proxy for self-shadow — reverted (broke gallery self-shadow visibility).

End result: 7-10× fps on worst-case teapot-self drag vs the branch baseline.

7. Code-organisation refactors

  • createPolyScene.ts split into focused modules:
    • types.ts + equality.ts
    • transforms.ts (build mesh + scene transforms, axis swap, zoom)
    • lightingVars.ts
    • shadowSvg.ts (ShadowSvgState + ground SVG primitives)
    • shadowGeometry.ts (groupReceiverFaceGroups, expandConvexHullOutward, worldCssForMesh)
    • shadowCache.ts + emitGroundShadow.ts
    • sceneContext.ts + MeshEntry/ReceiverFacePlane/CasterPolyItem types
    • receiverShadow.ts (~625 LOC pulled out of createPolyScene)

8. Bench infrastructure

  • parity-quad.html — 4-up live view (vanilla / react / vue / html-CE iframes) with postMessage slider sync across panes.
  • shadow-oracle.html — per-pixel shadow diff debugger with polygon attribution.
  • three-parity bench — side-by-side vs three.js reference with cast-shadow, floor, autoCenter, light-color knobs.
  • Per-renderer perf-*.html pages drive full state from URL params + postMessage; parity-quad iframes them.
  • Gallery-style draggable rhombus light helper wired to TransformControls; cursor-perfect via screenToWorldOnSphere.
  • Self-shadow toggle in gallery + bench.

Test plan

  • pnpm test2607 / 2607 pass (core 1024, fonts 45, polycss 706, react 416, vue 416)
  • pnpm build:packages — DTS + ESM + CJS green for all 4 published packages
  • Pixel-perfect parity sampled on parity-quad.html?mesh=rock1&mode=baked — vanilla, react, vue, html-CE all produce identical RGB at every atlas sample point
  • Shadow regression fixture passes (teapot-self, teapot-floor, castle-floor, crate-floor × 3 azimuths)
  • Smoke test the website gallery for any regression in light helper drag / baked rebake / static atlas swap

apresmoi added 30 commits May 29, 2026 20:23
mesh.position is now world units (matching camera.target's existing frame
instead of the old raw-CSS-pixel inconsistency). camera.zoom is now "px
per world unit" (Three.js OrthographicCamera.zoom semantic) instead of
a raw CSS scale factor. Both conversions live at the public-API
boundary; the renderer's internal tile-pixel frame and load-bearing
hacks (seam bleed, edge repair, canonical primitives, matrix decimals)
are unchanged.

three-parity.html confirms the same numeric inputs now produce the same
on-screen geometry in PolyCSS and Three.js. PolyDirectionalLightHelper
sheds its own ad-hoc CSS conversion now that the boundary handles it.
The polycss vanilla camera now interprets `zoom` as "px per world unit"
(Three.js convention) rather than a raw CSS scale, but the website
presets, demos, and PolyDemo controls still carry CSS-scale-tuned
values that ALSO drive the React renderer (kept on the old semantic
until the parity refactor is mirrored there). Multiply by 50 at the
boundary in VanillaScene + PolyDemo + the landing apple so the site
remains visually equivalent to polycss.com while the rest of the
parity work lands. Remove once @layoutit/polycss-react mirrors the
change and presets are rescaled.
directionalLight.direction is now in the standard world frame (+X right,
+Y forward, +Z up) — Three.js convention. The renderer's row/col axis
swap stays internal; createPolyScene applies the swap once at each pass
into the lighting pipeline (renderEntry, applyBakedSolidColor,
emitSceneShadows, applyLightingVars for dynamic mode, applyMeshLight-
VarOverride for per-mesh inverse-rotation), so the same numeric
direction now lights the same face in PolyCSS and Three.js.

The per-mesh dynamic override test now expects the swapped components
on the wrapper (-1 on --ply instead of --plx after rotateY(-90) of an
upward light), reflecting that --plx/--ply/--plz drive a CSS-frame
Lambert dot.
shadePolygon now performs the diffuse multiply in linear-light space
(sRGB → linear → multiply → linear → sRGB-encode) and divides the
direct contribution by π. This matches Three.js's MeshLambertMaterial
output for the same `intensity` value — sampled lit pixels on the
parity bench agree at rgb(128, 16, 16) versus the previous rgb(220,
38, 38).

`intensity = 1` no longer saturates a white surface; physical Lambert
caps direct contribution at `1 / π ≈ 0.318` in linear. The existing
paintDefaults test that expected intensity=1 → full white is updated
to the new value; a second case at `intensity = π` documents how
callers restore the legacy saturated behavior.
The previous physical-Lambert change only divided the direct contribution
by π and added ambient straight. Three.js's MeshLambertMaterial applies
the diffuse BRDF (`albedo / π`) to BOTH direct and indirect (ambient)
irradiance:

  lit = (albedo / π) × (directIrradiance + indirectIrradiance)

Re-grouped the shadePolygon math so the `× (1/π)` factor multiplies the
full `(lightColor × lambert × I + ambientColor × I_amb)` sum. Bench
sampling now lands on identical rgb(129, 18, 18) for the same input
parameters on the lit faces of both PolyCSS and Three.js.

The legacy "ambient × 1 → full white" test moves to `intensity = π` for
the saturated-output case, mirroring the same documented compensation
used in the direct-light case.
Three.js renders shadows only on receiving surfaces; PolyCSS used to
paint both the virtual ground SVG AND each receiver, so a floor at
z=0 got double-darkened (~75% effective opacity instead of the
configured 50%). Gated emitSceneGroundShadow on `!hasReceiver` so it
only fires as a fallback when no `receiveShadow` mesh exists.

Bench three-parity.html now includes a 20×20 floor + receive/cast
config. Pixel sampling under the same input numbers:
  cube face   rgb(129, 18, 18) on both engines (exact)
  floor lit   rgb(119, 125, 132) on both engines (exact)
  shadow     rgb(59,62,66) PolyCSS vs rgb(68,71,76) Three.js
The remaining shadow gap is the per-pixel algorithm: PolyCSS uses
shadow.opacity as a uniform black overlay; Three.js removes only the
direct-light contribution. Documented as the next step.
Replaced the "uniform black-overlay at shadow.opacity" model with the
per-receiver ambient-only lit color (what each receiver would look like
with no direct contribution). Computed once per receiver inside
emitSceneReceiverShadows via shadePolygon(directScale=0). At opacity=1.0
the shadow pixel exactly matches Three.js's "lit by ambient only".

Bench parity:
  cube    rgb(129, 18, 18) both engines
  floor   rgb(119, 125, 132) both engines
  shadow  rgb(68, 71, 76) both engines  (byte-exact)

shadow.color (the legacy "shadow tint" option) is now ignored — the
surface-aware ambient color replaces it. Bench shadow.opacity default
moves to 1.0 to match Three.js out of the box; values < 1 fade the
shadow back toward the lit pixel.

All 2350 tests (core + polycss + react + vue) still pass.
Two parity bench fixes:
- parseObj defaults `gridShift` to 1, adding +1 to every vertex after
  scaling so the bbox MIN lands at (1, 1, 1) instead of the origin. The
  PolyCSS cottage was floating 1 unit above the floor while the Three.js
  cottage sat on it. Override to gridShift: 0 to match.
- Camera drag/wheel was calling update() which unconditionally re-baked
  every PolyCSS atlas. For textured meshes (cottage) this re-rasterizes
  every slice on every pointer tick, producing visible texture flicker.
  Gate the re-bake on `lightsChanged`; camera-only and position-only
  changes skip the rebake since viewing angle doesn't affect Lambert.
gridShift was a leftover from PolyCSS's tile-grid origin: every parsed
vertex got `+ gridShift` added after the `(v - vMin) * scale` step so
the bbox MIN landed at (1,1,1) instead of the world origin (parseObj /
parseGltf defaulted to 1; parseVox already defaulted to 0). The option
served no rendering requirement — anything internal to the engine works
the same with or without the offset — and it diverged from Three.js's
convention where loaded geometry sits at its natural origin. Anyone
who wants the old behaviour can pass `position: [n, n, n]` on the mesh
handle.

Dropped:
- The `gridShift?: number` field from ObjParseOptions / GltfParseOptions /
  VoxParseOptions.
- The `gridShift` field on `PolyVoxelSource` (no internal consumers).
- All `+ gridShift` adds in the three parser projection paths.
- Every workbench/bench/preset/test that passed it.
- The two parser tests that asserted the gridShift=1 behaviour and the
  parseGltf describe("gridShift") block.

All 2347 tests pass (core 941 / polycss 581 / vue 412 / react 413);
website + packages build clean.
…ow clip

DirectionalLight.position in Three.js doubles as the shadow camera's
world position. Setting it to the raw direction vector (~1 unit from
origin) put the shadow camera's near plane inside the cottage, clipping
the projected shadow. PolyCSS only uses direction so it didn't notice.

Scale the user direction by LIGHT_DIST=100 before assigning to the
Three.js light position so the shadow camera sits far enough from the
scene to capture the full silhouette — same direction, parallel light
behaviour, no more cottage shadow truncation in the top-down view.
Hardcoded LIGHT_DIST / SHADOW_R worked for the default scene but failed
on larger meshes (cottage with mtl, anything bigger than the cube) and
on grazing light angles. PolyCSS has no equivalent limit because it
CPU-projects shadows onto receivers analytically.

Replaced with `fitDirLightToScene()` that:
- Walks every mesh in the scene to compute the world bounding sphere,
- Places dirLight.position at (sphere.center + direction × radius × 4)
  so the shadow camera sits far from the geometry (near plane clear),
- Sets shadow.camera.{left,right,top,bottom} = ±radius × 6 to cover
  the worst-case grazing-light shadow extent,
- Sets shadow.camera.far = lightDist × 3 to cover the depth of the
  scene from the light's POV.

Called on every update so cottage/cube/floor swaps and slider tweaks
keep the shadow camera correctly framed. Bench now mirrors PolyCSS's
"shadows just work" feel regardless of scene size.
textureTintFactors now returns linear-light BRDF factors matching
Three.js MeshLambertMaterial: `tint = (lightLinear × directScale +
ambLinear × ambI) / π`. Light + ambient colors are decoded from sRGB
to linear before forming the factors.

applyTextureTint switched from canvas `globalCompositeOperation =
multiply` (a sRGB×sRGB blend) to a per-pixel pass that decodes each
texture sample from sRGB to linear, multiplies by the tint, and
re-encodes — the gamma-correct path. Runs at atlas build time, not
per-frame, so cost is bounded.

Bench cottage textures now match Three.js brightness instead of
appearing oversaturated. Tests:
- textureTintFactors expectations updated to the new linear/π values
- the low-alpha edge-repair test now grabs the LAST putImageData call
  (the tint pass introduces an earlier one).
All 2347 tests pass (core 941 / polycss 581 / vue 412 / react 413).
…bert

The bench was passing `textureLighting: "static"`, which is not a valid
value in PolyTextureLightingMode (`"baked" | "dynamic"`). The string
flowed through createPolyScene's `?? "baked"` nullish coalesces
unmodified (?? only catches null/undefined), so the rasteriser's
`=== "baked"` gate at rasterise.ts:587 skipped applyTextureTint
entirely. Textured `<s>` leaves painted with raw atlas pixels — no
per-face Lambert variation — so the cottage roof's two slopes appeared
the same brightness despite having distinct normals.

Solid `<b>`/`<i>`/`<u>` leaves bake into inline `color` and aren't gated
by textureLighting, so the cube's parity held — the bug only surfaced
on the textured cottage.

One-line bench fix; the engine works correctly. Could harden createPoly-
Scene to whitelist textureLighting values (default unknowns to "baked")
as a follow-up so future typos don't silently swallow texture lighting.
Default 'auto' caps the atlas at ~2048px / 16MB which shrinks the
cottage's wall and roof slices uniformly, leaving them visibly
pixelated next to Three.js's full-res textures. The bench prioritises
parity over memory; real apps stick with 'auto'.
Baked stores lit colors in atlas pixels — byte-exact parity with
Three.js, but every light change re-rasterises every atlas, producing
visible texture flicker on the cottage.

Dynamic evaluates Lambert in CSS calc() per-frame from --plx/--ply/--plz
vars — zero flicker, slider drags are smooth. Brightness is currently
approximate (CSS calc skips the /π BRDF normalization and can't do
per-pixel sRGB↔linear on a sampled texture), so this trades exact
parity for interactivity.

The textureLighting setOptions call already forwards the chosen mode,
and the rebakeAtlas branch is now gated on `S.lighting === "baked"`.
emitSceneReceiverShadows used to `continue` when the caster was the
same mesh as the receiver, so a mesh tagged both castShadow and
receiveShadow never shadowed itself. Three.js does self-shadow when
both flags are on (cottage roof over its own wall is the canonical
case), so removed the skip — the per-triangle 3D-clip against the
receiver-face plane already drops caster tris that are coplanar with
or behind the receiver face, so a face never shadows itself, only
OTHER parts of the same mesh.

Bench cube + cottage now opt into receiveShadow: true so the parity
demo shows correct self-shadow.

All 581 polycss tests still pass.
The receiver-plane half-space test used `dp >= 0` to mark caster
vertices as "above". For a convex mesh whose adjacent faces share an
edge (cube), the shared-edge vertices have `planeDist == 0` on the
neighbour's plane. With `>= 0` they got pushed into the above-set,
producing degenerate triangles (3 vertices at the shared edge) that
projected onto the receiver as visible artefact bands.

Same root cause for the tiny gap between the cube's base and its
shadow on the floor: the cube bottom sits at z=0, the lifted shadow
ground at z=0.05, and the bottom vertices' near-zero planeDist
generated edge-aligned degenerate projections.

Switched to a strict `dp > SELF_SHADOW_EPS` (1e-3 in world units) for
both the bbox prefilter and the per-tri half-space clip. Caster
vertices ON the receiver plane or below it no longer contribute — only
vertices genuinely above (by more than the epsilon) do, so:
- convex meshes (cube) self-shadow correctly = no-op
- non-convex meshes (cottage) still self-shadow where one part is
  genuinely above another along the light direction
- shadows that hug the receiver edge don't smear into the gap

All 581 polycss tests still pass.
…solid

Receiver shadow fill now branches on whether the receiver has any
textured polygons:

- Solid receivers (floor, cube): keep the ambient-only lit color at
  full opacity → byte-exact Three.js "no direct light" parity.
- Textured receivers (cottage walls, etc.): paint shadow.color (default
  black) at `shadow.opacity × directIntensity / (directIntensity +
  ambIntensity)`. The opacity factor approximates Three.js's
  per-pixel darkening ratio so the texture stays visible underneath
  while being multiplicatively darkened, instead of getting repainted
  with a solid gray patch.

Restores task #88's "darken, not repaint" behaviour that was lost when
the receiver fill switched to ambient-only color for solid-receiver
parity. Cottage self-shadow on its walls no longer reads as solid gray
patches over the brick texture.

581 polycss tests still pass.
The textured-receiver shadow opacity was using a single global value
based on the LIGHT'S intensity vs ambient — same value for every face
on the mesh, regardless of how oblique each wall is to the light. That
over-darkened oblique walls (cottage gable on a side wall): Three.js
darkens those by less since lambert is smaller, so ambient contributes
relatively more to the final pixel.

Moved the opacity calculation into the per-group loop so each
receiver face uses its own `Ldotn` (the dot product already computed
for back-face culling):

  direct = intensity × max(n·L, 0)        // per group
  effOp  = shadow.opacity × direct / (direct + ambient)

A face at full lambert (= 1) still gets the strongest darkening;
oblique faces (low lambert) get proportionally lighter shadow, matching
Three.js's per-pixel lit × ambient/(direct+ambient) shadow formula.
The per-group opacity for textured self-shadow was using
`direct / (direct + ambient)` directly as the SVG opacity, but CSS
opacity blending happens in sRGB-encoded space, not linear. Three.js
does the equivalent math in linear:
  shadow_linear = lit_linear × ambient / (direct + ambient)
Applying that ratio directly as a sRGB opacity over-darkens because
sRGB encoding is non-linear in the dark-tone region.

Approximated the sRGB equivalent of the linear ratio with a γ=2.4
gamma correction:
  ratio_sRGB ≈ ratio_linear^(1/2.4)
  opacity = 1 - ratio_sRGB

For the typical bench scene (direct=0.667, ambient=0.3):
- old formula: opacity 0.69 → shadow pixel = lit × 0.31 = ~37
- new formula: opacity 0.40 → shadow pixel = lit × 0.60 = ~71
  (matches Three.js's expected ~71)

Mean cottage luminance moves PolyCSS 42.99 → 43.35; large
darker-than-Three.js pixels drop 204 → 176. Visually the gable
self-shadow on the cottage wall is no longer noticeably darker than
the equivalent Three.js shadow.
…wMap

- SELF_SHADOW_EPS dropped from 1e-3 to 1e-6 (now just above float
  precision). The larger epsilon was excluding caster vertices very
  close to the receiver plane (cottage roof barely above wall, etc.)
  and producing a visible gap between the casting feature and the
  self-shadow it should drop.
- bench passes `shadow.lift: 0` so the floor-cast shadow sits exactly
  on the floor plane instead of the default 0.05-unit lifted plane
  that created a thin visible offset between the cube/cottage base
  and the shadow start.
- bench switches Three.js shadow map from BasicShadowMap to
  PCFShadowMap (1-tap, no soft kernel) and bumps mapSize to 8192 so
  shadow edges stay crisp without the BasicShadowMap stairsteps. Less
  pixelation for cleaner cross-engine comparison.
Cube is convex (no self-shadow). Cottage is textured (shadow nuances
hidden by brick pattern). Added a 3D capital E built from 4 boxes
(spine + top/mid/bot arms) — strongly concave with the arms shadowing
the spine and each other under standard lighting. Lets us judge
self-shadow correctness without texture interference, and works in
both engines as a single mesh (PolyCSS combines all box polygons into
one parse result; Three.js puts the 4 BoxGeometries in a Group with
castShadow + receiveShadow on each).

Dropdown options are now `cube` / `e` / `cottage`; default stays
`cube`.
The receiver-shadow SVG was positioned 5 CSS pixels off the receiver
surface along the face normal to avoid z-fighting with the underlying
polygons. At DEFAULT_TILE=50, that's 0.1 world units — visible as a
"gap" between the casting feature and the shadow it produces, because
the projected shadow content lands on a plane offset from the actual
receiver surface.

0.5 CSS px still wins the depth test on every browser the renderer
targets but is sub-pixel at typical zoom, so shadows now hug the
casting feature without the visible detachment the user reported on
the E-mesh floor cast and self-shadow.
apresmoi added 28 commits June 3, 2026 04:18
# Conflicts:
#	packages/polycss/src/api/createPolyOrbitControls.ts
…atlas

- buildTransform: emit rotateY(-rx) rotateX(-ry) rotateZ(-rz) and pivot at
  wrapper local origin (drop bbox-center pre/post translates +
  transform-origin override). Matches vanilla buildMeshTransform (#157).
- Pass mesh rotation to prepareReceiverFacePlanes / prepareCasterPolyItems
  so face planes + caster vertices follow rotated wrappers.
- Disable per-light raytrace occlusion in the baked atlas plan
  (lightOccludedPolyIndices = undefined). Three.js doesn't bake shadow
  into the diffuse atlas; the real shadow map darkens occluded geometry
  at render time. Matches vanilla createPolyScene.ts:1162.
- borderShape / cornerShapeSolid / projectiveSolid: always emit
  entry.shadedColor in baked mode instead of inheriting the wrapper
  --polycss-paint default when shadedColor matches it. Matches vanilla
  formatInitialSolidPaintStyle (commit 0423777).
- buildAtlasPages.applyTextureTint: multiply texture pixels in linear
  light (decode sRGB -> multiply per channel -> encode) instead of the
  canvas globalCompositeOperation=multiply which is sRGB x sRGB and
  noticeably darkens mid-tone textures (rock1 was ~3x too dark).
The gridShift removal (commit 7880492 'refactor(parser): remove gridShift
— bbox MIN at origin, like Three.js') shifted parsed vertex frames by a
constant offset, which produced tiny but deterministic differences in
optimizer output and changed which axis a synthetic MagicaVoxel row spans
in PolyCSS frame. Update the four stale literal counts so CI is green;
no behaviour or invariant changes:

- shark.glb lossy: 634 → 635
- opportunity.glb lossless: 1895 → 1892, lossy: 1667 → 1670
- chest.obj lossless: 258 → 259
- 80×1×1 vox row: assert range on xs (was ys) — MV +X now maps to CSS +X

Render-cost + seam invariants (unclosedPairs, maxResidualGapPx) remain
enforced.
- vue PolyMesh.setPolygons: also update polygonOverride after the
  updateStableTriangleDom fast path. Without it, any later reactive
  re-render (cameraTick, scene-context change) re-emits the <u> VNode
  from stale polygons.value and Vue patches the leaf style back to the
  pre-setPolygons transform — undoing the imperative write.
- polycss createPolyScene.test castShadow block: every test now mounts
  a floor receiver before the caster, matching commit 1bd0ede
  ('drop virtual ground fallback for three.js receive-shadow parity').
  Casters use backTriangle() so their normal opposes the default light.
  Transform assertions switched from translate3d to matrix3d to match
  the per-receiver-face SVG placement.
- polycss shadowSvg.test: ground SVG now sits inside the .polycss-shadows
  wrapper (commit a5b106a). Tests look up the wrapper and opt into
  debugAttrs=true so data-poly-shadow-* attributes are present.
@apresmoi apresmoi changed the title fix(react,vue): mirror three.js parity fixes into PolyMesh + atlas feat: three.js parity sweep — API, shadows, lighting, transforms across all renderers Jun 3, 2026
@apresmoi apresmoi merged commit 8fc77fd into main Jun 3, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant