From 37bfcd90c93f3832b2e7f22305e5e90e7f474ff7 Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Sat, 13 Jun 2026 19:34:36 -0300 Subject: [PATCH] feat: add game texture alignment APIs --- AGENTS.md | 14 +- bench/baselines/chicken-dynamic-az0.png | Bin 45501 -> 31973 bytes bench/baselines/chicken-dynamic-az120.png | Bin 44556 -> 32347 bytes bench/baselines/chicken-dynamic-az240.png | Bin 46016 -> 32000 bytes bench/baselines/rock1-dynamic-az0.png | Bin 1533574 -> 1208382 bytes bench/baselines/rock1-dynamic-az120.png | Bin 1500524 -> 1162394 bytes bench/baselines/rock1-dynamic-az240.png | Bin 1365350 -> 1121373 bytes bench/entries/renderStats.ts | 2 +- bench/notes/BENCH.md | 31 +- bench/perf-shared.mjs | 53 +-- bench/perf-visual.mjs | 36 +- packages/core/src/atlas/matrix.test.ts | 32 ++ packages/core/src/atlas/matrix.ts | 15 +- packages/core/src/atlas/packing.test.ts | 93 +++++ packages/core/src/atlas/packing.ts | 60 ++- packages/core/src/atlas/plan.ts | 4 +- packages/core/src/atlas/strategy.test.ts | 25 ++ packages/core/src/atlas/strategy.ts | 25 +- packages/core/src/atlas/textureLeaf.test.ts | 112 ++++++ packages/core/src/atlas/textureLeaf.ts | 130 ++++++ packages/core/src/atlas/textureSource.ts | 41 ++ packages/core/src/atlas/types.ts | 45 ++- packages/core/src/camera/camera.test.ts | 82 ++++ packages/core/src/camera/camera.ts | 85 ++++ packages/core/src/index.ts | 42 +- packages/core/src/merge/mergePolygons.test.ts | 33 ++ packages/core/src/merge/mergePolygons.ts | 30 ++ packages/core/src/scene/normalize.ts | 35 +- .../src/shadow/receiverFaceGroups.test.ts | 64 ++- .../core/src/shadow/receiverFaceGroups.ts | 7 + packages/core/src/types.ts | 29 ++ .../polycss/src/api/createPolyScene.test.ts | 42 +- packages/polycss/src/api/createPolyScene.ts | 111 +++++- .../polycss/src/api/scene/internalTypes.ts | 1 + packages/polycss/src/api/scene/transforms.ts | 17 +- packages/polycss/src/api/scene/types.ts | 16 + .../src/elements/PolySceneElement.test.ts | 19 + .../polycss/src/elements/PolySceneElement.ts | 43 +- packages/polycss/src/index.ts | 12 +- packages/polycss/src/render/atlas/emit.ts | 171 +++++++- packages/polycss/src/render/atlas/packing.ts | 9 +- .../src/render/atlas/renderPolygons.ts | 109 ++--- packages/polycss/src/render/atlas/types.ts | 10 +- packages/polycss/src/render/polyDOM.test.ts | 223 +++++++++++ .../polycss/src/render/renderStats.test.ts | 195 ++++++++- packages/polycss/src/render/renderStats.ts | 371 +++++++++++++++++ .../snapshot/exportPolySceneSnapshot.test.ts | 18 + .../src/snapshot/exportPolySceneSnapshot.ts | 23 +- packages/polycss/src/styles/styles.ts | 4 +- .../camera/PolyOrthographicCamera.test.tsx | 3 + .../src/camera/PolyOrthographicCamera.tsx | 18 +- .../src/camera/PolyPerspectiveCamera.test.tsx | 3 + .../src/camera/PolyPerspectiveCamera.tsx | 21 +- packages/react/src/camera/useCamera.ts | 49 ++- packages/react/src/index.ts | 49 ++- packages/react/src/renderStats.test.ts | 194 +++++++++ packages/react/src/renderStats.ts | 375 +++++++++++++++++- packages/react/src/scene/PolyMesh.test.tsx | 29 +- packages/react/src/scene/PolyMesh.tsx | 61 +++ packages/react/src/scene/PolyScene.test.tsx | 55 +++ packages/react/src/scene/PolyScene.tsx | 59 ++- packages/react/src/scene/atlas/atlasPoly.tsx | 118 +++++- packages/react/src/scene/atlas/filterPlans.ts | 9 + packages/react/src/scene/atlas/index.tsx | 2 +- .../react/src/scene/atlas/packing.test.ts | 24 ++ packages/react/src/scene/atlas/packing.ts | 9 +- .../react/src/scene/atlas/useTextureAtlas.ts | 16 +- packages/react/src/scene/events.ts | 2 + packages/react/src/scene/sceneContext.ts | 8 + packages/react/src/shapes/Poly.test.tsx | 23 ++ packages/react/src/shapes/Poly.tsx | 79 +++- packages/react/src/shapes/types.ts | 21 + packages/react/src/styles/styles.test.ts | 4 +- packages/react/src/styles/styles.ts | 4 +- .../src/camera/PolyOrthographicCamera.test.ts | 3 + .../vue/src/camera/PolyOrthographicCamera.ts | 10 + .../src/camera/PolyPerspectiveCamera.test.ts | 3 + .../vue/src/camera/PolyPerspectiveCamera.ts | 10 + packages/vue/src/camera/useCamera.ts | 56 ++- packages/vue/src/index.ts | 49 ++- packages/vue/src/renderStats.test.ts | 194 +++++++++ packages/vue/src/renderStats.ts | 375 +++++++++++++++++- packages/vue/src/scene/PolyMesh.test.ts | 25 ++ packages/vue/src/scene/PolyMesh.ts | 79 +++- packages/vue/src/scene/PolyScene.test.ts | 65 ++- packages/vue/src/scene/PolyScene.ts | 59 ++- packages/vue/src/scene/atlas/atlasPoly.ts | 113 +++++- packages/vue/src/scene/atlas/filterPlans.ts | 9 + packages/vue/src/scene/atlas/index.ts | 2 +- packages/vue/src/scene/atlas/packing.test.ts | 24 ++ packages/vue/src/scene/atlas/packing.ts | 9 +- .../vue/src/scene/atlas/useTextureAtlas.ts | 12 + packages/vue/src/scene/events.ts | 2 + packages/vue/src/scene/sceneContext.ts | 8 + packages/vue/src/shapes/Poly.test.ts | 23 ++ packages/vue/src/shapes/Poly.ts | 91 ++++- packages/vue/src/styles/styles.test.ts | 4 +- packages/vue/src/styles/styles.ts | 4 +- website/src/components/DocsHeader.astro | 7 +- website/src/components/SiteTitle.astro | 25 +- 100 files changed, 4704 insertions(+), 311 deletions(-) create mode 100644 packages/core/src/atlas/textureLeaf.test.ts create mode 100644 packages/core/src/atlas/textureLeaf.ts create mode 100644 packages/core/src/atlas/textureSource.ts create mode 100644 packages/react/src/renderStats.test.ts create mode 100644 packages/vue/src/renderStats.test.ts diff --git a/AGENTS.md b/AGENTS.md index a0058ad3..84b8cf39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ Public API is **mirrored** across React and Vue. Adding a hook on one side witho ## Rendering model — the mental model -**One visible `Polygon` → one leaf DOM element.** Leaves use canonical CSS primitives where possible and move scale into `matrix3d`; clipped solids use fixed primitives because their paint geometry becomes unstable when collapsed to 1px. Textured polygons still pack their local-2D bounding rect (`canvasW × canvasH`) into the atlas. The HTML tag *is* the render strategy — the renderer picks one tag per polygon based on its shape and material. +**One visible `Polygon` → one leaf DOM element.** Leaves use canonical CSS primitives where possible and move scale into `matrix3d`; clipped solids use fixed primitives because their paint geometry becomes unstable when collapsed to 1px. Atlas-backed textured polygons pack their local-2D bounding rect (`canvasW × canvasH`) into atlas pages; source-exact textured polygons may instead carry `textureImageSource` + `texturePresentation.backend="image"` and render as direct image leaves without atlas rasterisation. The HTML tag *is* the render strategy — the renderer picks one tag per polygon based on its shape and material. Raw MagicaVoxel `.vox` sources have a narrower baked-mode fast path: `parseVox` still returns the polygon mesh for bounds, fallback rendering, and public handles, but also preserves a `PolyVoxelSource` marker. Eligible vanilla, React, and Vue meshes render visible voxel quads as `` leaves inside persistent signed-face wrappers (`t`, `b`, `fl`, `br`, `fr`, `bl`), with canonical `matrix3d(...)` transforms and projected tile4 scanline order inside each mounted face. Camera-facing culling mounts/removes those face wrappers instead of removing thousands of live brush children from the mesh root. `.vox` normalization snaps to the nearest integer CSS cell size so direct voxel matrices use integer pixel coordinates without any scale wrapper; same-color shared voxel edges get a tiny matrix-space overscan to hide compositor seams without fattening exterior silhouette edges. Brush colors still receive baked Lambert shading from the scene lights. Callers may opt into lossy `.vox` palette merging and small local face-region cleanup before greedy meshing when authored palettes contain visually redundant colors; gallery and builder route this through Mesh resolution so `Lossy` may simplify palettes while `Lossless` keeps palette colors exact. Dynamic lighting, shadows, stable DOM animation, non-exact voxel geometry, and geometry replaced via `setPolygons` fall back to the polygon renderer. @@ -34,7 +34,7 @@ Voxel-shaped meshes are the exception to "all polygons stay mounted": meshes wit |---|---|---|---|---| | `` | **Quads** | Axis-aligned rectangle, or untextured convex quad when the homography passes stability guards on non-Safari engines | `background: currentColor` on a fixed 64px rectangle; affine and projective quads normalize their `matrix3d` to that primitive, with tiny solid bleed on projective quads to overlap antialias seams. Safari-family browsers skip the projective quad path and fall through because transformed projective rectangles composite incorrectly there. | None | | `` | **Border-shape clipped solid** | Untextured non-rect not caught by the exact corner-shape solid path, on browsers with CSS `border-shape` (Chromium + `pointer:fine` + `hover:hover`) | `border-color: currentColor` on a fixed 16px border-shape primitive, clipped by `border-shape: polygon(...)`; polygon bbox scale and tiny solid bleed are folded into `matrix3d` | None | -| `` | **Atlas slice** | Textured polygons, or untextured non-rect on browsers without `border-shape` | `background-image` slice of packed bitmap on an auto-budgeted fixed primitive (128px for desktop-class `textureQuality="auto"`, 64px for mobile-class `auto` and explicit numeric quality); atlas position/size and `matrix3d` scale are normalized to the slice, shared textured edges get low-alpha atlas pixels repaired during atlas generation, and solid fallbacks get same-color edge bleed to avoid dark alpha fringes | Bounding-rect area | +| `` | **Texture slice / atlas fallback** | Atlas-backed textured polygons, direct `textureImageSource` polygons, or untextured non-rect on browsers without `border-shape` | Atlas leaves use a packed bitmap slice on an auto-budgeted primitive (128px for desktop-class `textureQuality="auto"`, 64px for mobile-class `auto` and explicit numeric quality by default; `textureLeafSizing` can switch to local or raster dimensions). Direct image leaves use the caller's source URL and source rect directly, keep source lighting, and may use guarded affine or projective matrices for exact quad mapping. Atlas position/size, image position/size, filtering (`textureImageRendering`), readiness, projection, and source rect are emitted as PolyCSS-owned metadata so callers do not parse private style strings. | Atlas: bounding-rect area; direct image: none | | `` | **Stable solid triangle / corner-shape solid** | Triangles on non-WebKit engines; or untextured non-triangle polygons whose normalized outline is exactly a rectangle with one or more beveled corners on browsers with CSS `corner-shape` | Triangles use a 32px box with two beveled top corners and `background: currentColor` when CSS `corner-shape` support is present, progressively falling back to the CSS border-color triangle trick. Firefox uses a 96px border-triangle primitive to avoid large-perspective compositor banding. Exact corner-shape solids use a fixed 16px classed box with inline per-corner radii + `corner-*-shape: bevel` and `background: currentColor`. Tiny solid bleed is folded into `matrix3d`. WebKit/Safari falls through to `` for border triangles because transformed CSS border triangles composite incorrectly there. | None | Strategies are ordered cheapest → most expensive. The mesher's job is to maximise `` / `` / `` and minimise `` (see "Meshing implications" below). @@ -47,16 +47,16 @@ The `.vox` fast path emits plain `` elements inside `.polycss-voxel-face` wra ### Lighting modes (`PolyTextureLightingMode = "baked" | "dynamic"`) -- **Baked.** Lambert is computed once on the CPU per polygon, multiplied into the inline `color` (for ``/``/``) or into the rasterised atlas pixels (for ``). Moving a light requires explicit re-rasterising of affected lit 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. +- **Baked.** Lambert is computed once on the CPU per polygon, multiplied into the inline `color` (for ``/``/``) or into the rasterised atlas pixels (for atlas-backed ``). Direct image `` 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. -All solid/atlas tags work in both modes. 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`. +All solid and atlas-backed tags work in both modes. Direct image `` 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`. ### Meshing implications (what generators must respect) - **Polygon count is the dominant cost.** Each polygon is one DOM node, one `matrix3d`, one paint. Halving the polygon count is almost always worth a more complex mesher. - **Lossy optimization favors low DOM render cost.** The default `"lossy"` `loadMesh` / core import path first bakes solid texture swatches, merges visually redundant baked swatch colors, and tries endpoint-preserving static triangle simplification for eligible non-animated meshes. It then scores exact and approximate merge candidates by estimated render cost and keeps the cheapest direct candidate. Static simplification has a relaxed seam-key pass plus a stricter source-vertex fallback, and is accepted only when the final optimized DOM leaf count is lower than the baseline optimizer result. The polygon optimizer can also try a more aggressive triangle-pair merge candidate inside the same boundary displacement budget, but accepts it only when the render-cost win is material and whole-mesh seam diagnostics do not get worse. STL parse results are the conservative exception: they keep the lossless optimizer path and skip ray-based interior culling because public CAD/STL corpora frequently contain shell, winding, or topology quirks where false-positive culling is a visible data-loss bug. It avoids per-candidate seam-repair passes in the import path; targeted seam repair remains a lower-level helper for explicit repair workflows. -- **Fill ratio matters.** A textured polygon's atlas slice equals its local-2D bounding rect. Empty space inside that slice is wasted bitmap pixels. Prefer shapes with high `area / boundingRect.area`: +- **Fill ratio matters.** An atlas-backed textured polygon's atlas slice equals its local-2D bounding rect. Empty space inside that slice is wasted bitmap pixels. Direct image leaves avoid atlas memory, but only for source-exact surfaces with preserved source metadata and source lighting. Prefer atlas shapes with high `area / boundingRect.area`: - axis-aligned rectangle = 1.0 (and hits the fastest path) - right-isosceles triangle = 0.5 - skinny/long triangle ≪ 0.5 (worst case — many such triangles balloon atlas memory) @@ -87,8 +87,8 @@ 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`, `PolyAnimationMixer`, `PolyRenderStats`. -- **Functions:** `findPolyMeshHandle`, `injectPolyBaseStyles`, `collectPolyRenderStats`, `exportPolySceneSnapshot`. +- **Types:** `PolyDirectionalLight`, `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: ``, ``, ``, ``, ``, ``, ``, ``. Any new element follows the same shape (e.g. ``, ``). - **``:** flat textured "quad" whose "texture" is a live document (an `