Skip to content

Commit e38b25e

Browse files
authored
feat: add game texture alignment APIs (#69)
Co-authored-by: agustin-littlehat <minotopo@gmail.com>
1 parent e0ae21d commit e38b25e

100 files changed

Lines changed: 4704 additions & 311 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Public API is **mirrored** across React and Vue. Adding a hook on one side witho
2222

2323
## Rendering model — the mental model
2424

25-
**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.
25+
**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.
2626

2727
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 `<b>` 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.
2828

@@ -34,7 +34,7 @@ Voxel-shaped meshes are the exception to "all polygons stay mounted": meshes wit
3434
|---|---|---|---|---|
3535
| `<b>` | **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 |
3636
| `<i>` | **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 |
37-
| `<s>` | **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 |
37+
| `<s>` | **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 |
3838
| `<u>` | **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 `<s>` for border triangles because transformed CSS border triangles composite incorrectly there. | None |
3939

4040
Strategies are ordered cheapest → most expensive. The mesher's job is to maximise `<b>` / `<u>` / `<i>` and minimise `<s>` (see "Meshing implications" below).
@@ -47,16 +47,16 @@ The `.vox` fast path emits plain `<b>` elements inside `.polycss-voxel-face` wra
4747

4848
### Lighting modes (`PolyTextureLightingMode = "baked" | "dynamic"`)
4949

50-
- **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 `<s>`). 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.
50+
- **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.
5151
- **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.
5252

53-
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`.
53+
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`.
5454

5555
### Meshing implications (what generators must respect)
5656

5757
- **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.
5858
- **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.
59-
- **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`:
59+
- **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`:
6060
- axis-aligned rectangle = 1.0 (and hits the fastest path)
6161
- right-isosceles triangle = 0.5
6262
- 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
8787
- Every public export gets a `Poly` prefix. Exceptions are generic math types: `Vec2`, `Vec3`, `Polygon`, `PolyMaterial` (already prefixed).
8888
- **Hooks/composables:** `usePolyCamera`, `usePolyMesh`, `usePolySceneContext`, `usePolySelect`, `usePolySelectionApi`, `usePolyAnimation`.
8989
- **Components:** `PolyPerspectiveCamera`, `PolyOrthographicCamera`, `PolyOrbitControls`, `PolyMapControls`, `PolyTransformControls`, `PolySelect`, `PolyAxesHelper`, `PolyDirectionalLightHelper`, `PolyIframe`.
90-
- **Types:** `PolyDirectionalLight`, `PolyAmbientLight`, `PolyTextureLightingMode`, `PolyAnimationMixer`, `PolyRenderStats`.
91-
- **Functions:** `findPolyMeshHandle`, `injectPolyBaseStyles`, `collectPolyRenderStats`, `exportPolySceneSnapshot`.
90+
- **Types:** `PolyDirectionalLight`, `PolyAmbientLight`, `PolyTextureLightingMode`, `PolyTextureLeafSizing`, `PolyTextureBackend`, `PolyTextureImageRendering`, `PolyTextureImageLighting`, `PolyTextureProjection`, `PolyTexturePresentation`, `PolyTextureImageSource`, `PolyCameraProjection`, `PolyCameraSnapshot`, `PolyCameraSnapshotStats`, `PolyMeshTransformInput`, `PolySceneTransformInput`, `PolyAnimationMixer`, `PolyRenderStats`.
91+
- **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`.
9292
- **Vanilla factories:** `create*` names stay as-is (`createPolyScene`, `createTransformControls`, `createSelect`).
9393
- **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>`).
9494
- **`<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.
-13.2 KB
Loading
-11.9 KB
Loading
-13.7 KB
Loading
-318 KB
Loading
-330 KB
Loading
-238 KB
Loading

bench/entries/renderStats.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export { collectPolyRenderStats } from "@layoutit/polycss";
1+
export { BASE_TILE, collectPolyRenderStats } from "@layoutit/polycss";

bench/notes/BENCH.md

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -378,27 +378,28 @@ pnpm bench:visual # diff against baselines, exit 1 on fail
378378
pnpm bench:visual --record # capture new baselines instead
379379
pnpm bench:visual --tolerance 0.005 # tighter cutoff (default 0.01)
380380
pnpm bench:visual --mesh chicken # check just one mesh
381+
pnpm bench:visual --hud # include the debug overlay in screenshots
381382
```
382383

383384
The two test meshes were chosen because each exercises a different
384385
render path:
385386

386387
- **chicken** — flat-color materials (`Kd` only, no `map_Kd`) → CSS
387388
cascade-driven polygon path.
388-
- **rock1** — UV-mapped texture (`map_Kd rock1-surface.jpg`) → atlas-
389-
blob-clipped `<i>` background path.
389+
- **rock1** — UV-mapped texture (`map_Kd rock1-surface.jpg`) → atlas
390+
texture leaves.
390391

391392
A regression in either path shows up here. Add a new mesh to the
392393
`MESHES` constant (and `--record`) if you need to cover more ground.
393394

394395
### Atlas-ready wait
395396

396-
The harness polls until at least one `.polycss-scene i` has a
397-
non-empty `style.backgroundImage` before screenshotting. This catches
398-
the asynchronous atlas-blob handoff — `scene.add()` returns sync but
399-
the polygons stay invisible (`opacity:0`) until the atlas canvas
400-
finishes building and its blob URL gets assigned. A blind 800 ms wait
401-
used to race this and produce empty baselines.
397+
The harness polls texture leaves with `data-polycss-texture-backend`
398+
until each has `data-polycss-texture-ready="true"` before
399+
screenshotting. This catches the asynchronous atlas/direct-image
400+
handoff — `scene.add()` returns sync but textured polygons stay hidden
401+
until their paint source is ready. A blind timeout used to race this
402+
and produce empty baselines.
402403

403404
### Visual diff is vanilla-only
404405

@@ -476,13 +477,11 @@ the scenario genuinely runs at < 1 fps.
476477

477478
**Browser hangs or screenshots come up empty.**
478479
The atlas-ready poll has a 5 s timeout. If it expires you'll get a
479-
`TimeoutError`. That usually means a polygon never got
480-
`backgroundImage` set — could be a renderer regression. Open the page
481-
in `--headed` mode and check the console.
480+
`TimeoutError`. That usually means a texture leaf never reached
481+
`data-polycss-texture-ready="true"` — could be a renderer regression.
482+
Open the page in `--headed` mode and check the console.
482483

483484
**Recording a baseline that ends up empty / wrong.**
484-
The atlas-ready poll requires *at least one* `<i>` with a non-empty
485-
`backgroundImage`. If that loosened condition isn't enough for a
486-
specific mesh (e.g. all polys are culled at the chosen camera angle),
487-
either pick a non-degenerate angle or tighten the wait condition for
488-
that mesh.
485+
The atlas-ready poll requires all texture leaves in the scene to report
486+
ready. If that condition isn't enough for a specific mesh, either pick a
487+
non-degenerate angle or tighten the wait condition for that mesh.

0 commit comments

Comments
 (0)