From 4873dcb2a272cda757e7c298e0638e051230834e Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Tue, 19 May 2026 15:01:44 -0300 Subject: [PATCH] feat: add direct voxel matrix renderer --- AGENTS.md | 12 +- bench/TRACE_INVESTIGATION.md | 659 ++++++++++++++++ bench/VOXEL_FAST_PATH_HYPOTHESES.md | 422 +++++++++++ bench/compositor-topology-probe.mjs | 339 +++++++++ bench/trace-summary.mjs | 276 +++++++ bench/voxel-browser-summary.mjs | 85 +++ bench/voxel-cadence-summary.mjs | 184 +++++ bench/voxel-order-metrics.mjs | 711 ++++++++++++++++++ bench/voxel-progress-dashboard.html | 449 +++++++++++ bench/voxel-progress-dashboard.mjs | 688 +++++++++++++++++ bench/voxel-static-metrics.mjs | 607 +++++++++++++++ packages/core/README.md | 24 +- packages/core/src/helpers/boxPolygons.test.ts | 129 ++++ packages/core/src/helpers/boxPolygons.ts | 164 ++++ packages/core/src/helpers/index.ts | 2 + packages/core/src/index.ts | 6 +- .../core/src/merge/coverPlanarPolygons.ts | 64 +- .../core/src/merge/optimizePolygons.test.ts | 27 + packages/core/src/merge/optimizePolygons.ts | 491 +++++++++--- packages/core/src/parser/loadMesh.test.ts | 64 ++ packages/core/src/parser/parseVox.test.ts | 2 +- packages/core/src/parser/parseVox.ts | 6 +- .../core/src/parser/solidTextureSamples.ts | 9 - packages/core/src/voxel/voxelSlicePlanner.ts | 4 +- packages/polycss/README.md | 4 +- .../polycss/src/api/createPolyScene.test.ts | 58 +- packages/polycss/src/api/createPolyScene.ts | 36 +- packages/polycss/src/render/polyDOM.test.ts | 125 ++- packages/polycss/src/render/textureAtlas.ts | 205 +++-- ...voxelSliceRenderer.ts => voxelRenderer.ts} | 364 ++++----- packages/polycss/src/styles/styles.ts | 45 +- packages/react/README.md | 6 +- packages/react/src/index.ts | 4 + packages/react/src/scene/PolyMesh.tsx | 7 +- packages/react/src/scene/PolyScene.tsx | 7 +- .../react/src/scene/textureAtlas.test.tsx | 82 +- packages/react/src/scene/textureAtlas.tsx | 178 ++++- packages/react/src/shapes/types.ts | 4 +- packages/react/src/styles/styles.test.ts | 5 + packages/react/src/styles/styles.ts | 10 +- packages/vue/README.md | 6 +- packages/vue/src/index.ts | 4 + packages/vue/src/scene/PolyMesh.ts | 7 +- packages/vue/src/scene/PolyScene.ts | 7 +- packages/vue/src/scene/textureAtlas.test.ts | 56 +- packages/vue/src/scene/textureAtlas.ts | 178 ++++- packages/vue/src/shapes/Poly.ts | 4 +- packages/vue/src/styles/styles.test.ts | 5 + packages/vue/src/styles/styles.ts | 10 +- .../content/docs/components/poly-scene.mdx | 4 +- .../src/content/docs/guides/performance.mdx | 4 +- website/src/content/docs/guides/shapes.mdx | 19 + website/src/content/docs/guides/textures.mdx | 6 +- 53 files changed, 6278 insertions(+), 596 deletions(-) create mode 100644 bench/TRACE_INVESTIGATION.md create mode 100644 bench/VOXEL_FAST_PATH_HYPOTHESES.md create mode 100644 bench/compositor-topology-probe.mjs create mode 100644 bench/trace-summary.mjs create mode 100644 bench/voxel-browser-summary.mjs create mode 100644 bench/voxel-cadence-summary.mjs create mode 100644 bench/voxel-order-metrics.mjs create mode 100644 bench/voxel-progress-dashboard.html create mode 100644 bench/voxel-progress-dashboard.mjs create mode 100644 bench/voxel-static-metrics.mjs create mode 100644 packages/core/src/helpers/boxPolygons.test.ts create mode 100644 packages/core/src/helpers/boxPolygons.ts rename packages/polycss/src/render/{voxelSliceRenderer.ts => voxelRenderer.ts} (55%) diff --git a/AGENTS.md b/AGENTS.md index 278245cf..4c1dfd74 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 **One visible `Polygon` → one leaf DOM element.** Leaves use canonical CSS primitives where possible and move scale into `matrix3d`; `border-shape` uses a larger fixed primitive because its 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. -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`. Eligible vanilla meshes render that source through three axis hosts plus absolutely positioned rectangular brush leaves, using the voxcss `mergeVoxels: "3d"` slice planner rather than one `matrix3d` per polygon. `.vox` normalization snaps to the nearest integer CSS cell size so brush rectangles use integer pixel coordinates without any scale wrapper. Brush colors still receive baked Lambert shading from the scene lights. Dynamic lighting, shadows, stable DOM animation, and geometry replaced via `setPolygons` fall back to the polygon renderer. +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 meshes render exact visible voxel quads as hostless `` leaves with canonical `matrix3d(...)` transforms and projected tile4 scanline DOM order. `.vox` normalization snaps to the nearest integer CSS cell size so direct voxel matrices use integer pixel coordinates without any scale wrapper. Brush colors still receive baked Lambert shading from the scene lights. Dynamic lighting, shadows, stable DOM animation, non-exact voxel geometry, and geometry replaced via `setPolygons` fall back to the polygon renderer. Voxel-shaped meshes are the exception to "all polygons stay mounted": meshes with at most the six axis-aligned face normals, excluding helpers/auto-center-exempt meshes, automatically mount only camera-facing leaves and patch the mounted set when the camera or mesh rotation crosses a visible-normal boundary. Non-voxel meshes keep the full leaf DOM mounted; broad camera-dependent DOM culling is not worth the mutation cost. @@ -32,22 +32,22 @@ 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 | `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 | None | | `` | **Border-shape clipped solid** | Untextured non-rect on browsers with CSS `border-shape` (Chromium + `pointer:fine` + `hover:hover`) | `border-color: currentColor` on a fixed 64px 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 a fixed 128px primitive; 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 | -| `` | **Stable solid triangle** | Opt-in for triangles via `renderPolygonsWithStableTriangles` | CSS border-color triangle trick with a fixed canonical 64px border triangle; tiny solid bleed is 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 | +| `` | **Stable solid triangle** | Opt-in for triangles via `renderPolygonsWithStableTriangles` on non-WebKit engines | CSS border-color triangle trick with a fixed canonical 64px border triangle; tiny solid bleed is folded into `matrix3d`. WebKit/Safari falls through to `` because transformed CSS border triangles composite incorrectly there. | None | | `` | **Cast shadow leaf** | Per casting polygon when `castShadow: true` and dynamic lighting mode. Applies regardless of caster strategy — ``/``/``/`` all produce a `` shadow because only the polygon's outline matters, not its surface. | Same `border-color: currentColor` + `border-shape: polygon(...)` as ``, but transform composes `var(--shadow-proj)` to project the polygon onto the ground plane along the CSS-space light direction | None | Strategies are ordered cheapest → most expensive. The mesher's job is to maximise `` / `` and minimise `` (see "Meshing implications" below). -Callers can opt out of specific strategies via `strategies: { disable: ["b" | "i" | "u"] }` on `RenderTextureAtlasOptions`. Disabled strategies fall through the chain (`b → i → s`, `u → i → s`, `i → s`). `` is the universal fallback and cannot be disabled. +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`). `` is the universal fallback and cannot be disabled. -The voxel slice-brush fast path emits plain `` quad elements inside axis hosts. They intentionally reuse the cheap quad tag, but they are absolutely positioned brush rectangles rather than polygon strategy leaves and do not use one `matrix3d` per polygon. +The `.vox` fast path emits plain `` elements directly inside the mesh wrapper. They intentionally reuse the cheap quad tag, but they are exact voxel quads on a canonical 1px primitive with one `matrix3d(...)` per visible quad, ordered by projected tile4 scanline order. ### 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 re-rasterising affected polys. - **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 one var on the scene root — zero JS, no atlas redraw. -All solid/atlas tags work in both modes. The `.vox` slice-brush 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/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`. ### Meshing implications (what generators must respect) diff --git a/bench/TRACE_INVESTIGATION.md b/bench/TRACE_INVESTIGATION.md new file mode 100644 index 00000000..d18ea150 --- /dev/null +++ b/bench/TRACE_INVESTIGATION.md @@ -0,0 +1,659 @@ +# Trace Investigation + +This is the next phase after the voxel fast-path comparison. The goal is no +longer "close the voxcss gap". Polycss is already in the same performance +class and wins on several model classes. The next breakthrough has to come +from understanding the Chrome trace: which browser subsystem burns frame +budget, what DOM/CSS shape triggers it, and which renderer changes are worth +testing because the trace predicts them. + +## Current Read + +Earlier trace captures that established the compositor-heavy shape, current +voxel fast path, one run each: + +| Model | Leaves | FPS p95 | PAC ms/frame | Layerize ms/frame | DrawProps ms/frame | Draw ms/frame | Paint ms/frame | Raster ms/frame | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| `scene_vehicles1.vox` | 752 | 112.4 | 0.48 | 0.48 | 0.38 | 0.45 | 0.00 | 0.00 | +| `MechaGolem.vox` | 1789 | 62.6 | 1.11 | 1.11 | 1.09 | 1.20 | 0.01 | 0.00 | +| `AncientCrashSite.vox` | 5233 | 40.0 | 3.84 | 3.84 | 3.77 | 4.65 | 0.02 | 0.00 | +| `Garden.vox` | 7186 | 24.0 | 4.62 | 4.62 | 5.18 | 6.42 | 0.09 | 0.00 | + +`PAC` is `PaintArtifactCompositor::Update`. These trace events are nested, so +the columns are not additive. They are diagnostic landmarks. + +Deeper control traces, same DOM: + +| Model | Motion | FPS p95 | PAC ms/frame | DrawProps ms/frame | Script ms/frame | +| --- | --- | ---: | ---: | ---: | ---: | +| `Garden.vox` | no transform update | 111.2 | 0.00 | 0.00 | 0.08 | +| `Garden.vox` | repeated same transform value | 113.6 | 0.00 | 0.00 | 0.13 | +| `Garden.vox` | changing rotation | 20.5 | 4.65 | 5.15 | 0.37 | +| `AncientCrashSite.vox` | no transform update | 112.5 | 0.00 | 0.00 | 0.08 | +| `AncientCrashSite.vox` | repeated same transform value | 112.4 | 0.00 | 0.00 | 0.14 | +| `AncientCrashSite.vox` | changing rotation | 40.0 | 3.85 | 3.80 | 0.25 | +| `MechaGolem.vox` | no transform update | 112.4 | 0.00 | 0.00 | 0.07 | +| `MechaGolem.vox` | repeated same transform value | 111.1 | 0.00 | 0.00 | 0.13 | +| `MechaGolem.vox` | changing rotation | 62.3 | 1.10 | 1.07 | 0.16 | + +This separates three things: + +- Mounted DOM by itself is cheap when the composed transform does not change. +- Calling the same update path every frame is cheap when the transform value + stays identical. +- The expensive path appears when the root camera transform changes over the + preserve-3d subtree. + +The per-leaf camera-rotation slope is also consistent enough to be useful: + +| Scene | Renderer class | Leaves | PAC us/leaf | DrawProps us/leaf | Draw us/leaf | +| --- | --- | ---: | ---: | ---: | ---: | +| `scene_vehicles1.vox` | voxel brushes | 752 | 0.637 | 0.503 | 0.598 | +| `MechaGolem.vox` | voxel brushes | 1789 | 0.612 | 0.598 | 0.661 | +| `AncientCrashSite.vox` | voxel brushes | 5233 | 0.736 | 0.727 | 0.887 | +| `Garden.vox` | voxel brushes | 7186 | 0.647 | 0.717 | 0.866 | +| `ducky.glb` | normal polygon leaves | 471 | 0.722 | 0.979 | 1.520 | +| `apocalypse/car.glb` | normal polygon leaves | 3359 | 0.680 | 0.774 | 1.135 | + +So the camera-motion cost is not voxel-specific. It is the general cost of +changing a 3D root transform over many preserve-3d DOM leaves. Voxel scenes +benefit because they reduce the active leaf count; non-voxel scenes still pay +the same browser-side slope per active transformed leaf. + +Dynamic light rotation is a different problem. On `apocalypse/car.glb`, +`dynamic.light_rotate` spent roughly 40 ms/frame in style update and 7 ms/frame +in raster, while camera rotation spent roughly 2.3-2.5 ms/frame in PAC and +draw properties. Keep those investigations separate. + +Clean matrix-vs-slice cadence runs, current browser, five runs each: + +| Model | Path | FPS p95 | P99 ms | 1-vsync frames | 2-vsync frames | 3-vsync frames | 4+-vsync frames | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| `MechaGolem.vox` | matrix fallback | 117.6 | 9.2 | 100.0% | 0.0% | 0.0% | 0.0% | +| `MechaGolem.vox` | voxel slices | 117.6 | 9.0 | 100.0% | 0.0% | 0.0% | 0.0% | +| `Treasure.vox` | matrix fallback | 58.0 | 25.0 | 30.6% | 64.9% | 3.5% | 0.6% | +| `Treasure.vox` | voxel slices | 30.0 | 33.4 | 6.5% | 51.6% | 33.1% | 7.3% | +| `AncientCrashSite.vox` | matrix fallback | 39.8 | 33.4 | 7.5% | 64.4% | 20.5% | 3.7% | +| `AncientCrashSite.vox` | voxel slices | 58.4 | 25.0 | 14.9% | 82.5% | 2.5% | 0.0% | +| `Garden.vox` | matrix fallback | 24.3 | 41.8 | 4.9% | 29.0% | 39.2% | 24.5% | +| `Garden.vox` | voxel slices | 24.0 | 50.1 | 0.0% | 9.6% | 26.5% | 63.8% | +| `scene_vehicles1.vox` | matrix fallback | 117.6 | 9.1 | 100.0% | 0.0% | 0.0% | 0.0% | +| `scene_vehicles1.vox` | voxel slices | 119.0 | 9.1 | 100.0% | 0.0% | 0.0% | 0.0% | + +This reframes the adaptive matrix-vs-slice idea. Matrix and slice have the same +active leaf ranges and the same two visible-face transitions: + +| Model | Active leaves, matrix | Active leaves, voxel slices | +| --- | ---: | ---: | +| `MechaGolem.vox` | 1199-1789 | 1199-1789 | +| `Treasure.vox` | 2582-4010 | 2582-4010 | +| `AncientCrashSite.vox` | 4158-5240 | 4158-5240 | +| `Garden.vox` | 4668-7186 | 4668-7186 | + +The two paths are no longer separated by mounted leaf count. When one wins, it +wins by browser cadence: how often Chrome lands on 1/2/3/4+ vsync buckets. +Current trace samples still show the matrix path doing more per-frame PAC and +draw-property work than slices, even when matrix has better p95 on `Treasure`. +So the remaining question is a hidden scheduling/property-tree behavior, not a +simple "less compositor CPU" result. + +The wider clean corpus keeps that read. Use the corpus summarizers after adding +more runs: + +```sh +node bench/voxel-cadence-summary.mjs +node bench/voxel-static-metrics.mjs +node bench/voxel-browser-summary.mjs +``` + +Current corpus: 86 models, with validation runs preferred over exploratory +runs. + +| Class | Models | +| --- | --- | +| Matrix p95 win | `desert2`, `scene_hazmat`, `scene_house`, `scene_mechanic2`, `scene_sidewalk`, `Treasure` | +| Slice p95 win | `AncientCrashSite`, `armchair`, `christmas_tree`, `ff1`, `mailbox`, `obj_house3`, `obj_house8`, `obj_trashcan4`, `pyramid`, `scene_park` | +| Mostly flat / capped | 66 of 86 models | +| P99-only split | `Garden` favors matrix p99 | +| Slice p99-only split | `dual`, `scene_fall`, `scene_house3` favor slice p99 | + +No simple structural metric explains the full corpus yet. `scene_park` and +`scene_hazmat` have similar active leaves, screen fill, and broad model shape +but opposite winners. `HUT` has very high screen fill and is flat; +`AncientCrashSite` has lower screen fill and prefers slices; `Treasure` +prefers matrix. Local brush area, active leaves, visible planes, color count, +and screen fill each fail on at least one model. + +The static metrics pass adds one useful partial selector: visible shaded brush +color count, computed from the same polygon plans and baked light math the +voxel renderer uses. This is not the raw `.vox` source color count. With the +current 86-model corpus, `visibleShadedColors >= 52` captures +`scene_hazmat`, `scene_house`, `scene_mechanic2`, and `Treasure`, hits no +validated strong slice winners, and leaves `desert2` and `scene_sidewalk` +unexplained. It also hits many flat/capped scenes, so it is a safe-looking +partial gate, not a full classifier. Against always-slice it gives four p95 +wins and no p95 losses in the current corpus, but it does introduce one p99 +loss: `scene_house3`. A stricter diagnostic gate, +`visibleShadedColors >= 52 && visiblePlanes < 200`, keeps the same four p95 +wins and removes that p99 loss on the current corpus. + +The follow-up structural-neighbor sweep did not find a repeatable second rule +for `desert2` or `scene_sidewalk`. `desert2` sits among low-color/high-area +neighbors where `ff1`, `pyramid`, and `christmas_tree` prefer slices and +`MechaGolem`, `mecha`, `obj_house5`, and `StarMarineTrooper` are flat. +`scene_sidewalk`'s low-plane/high-fill neighbors were mostly flat or +slice-favored (`armchair`, `mailbox`, `obj_trashcan4`), so treat +`scene_sidewalk` as a browser-ceiling case, not a classifier anchor. + +Target-size sweeps do not explain the remaining stable matrix wins. On +`desert2`, slice local area moved from ~10M to ~34M px across target sizes +50-90, but slice stayed at ~30 FPS p95 while matrix stayed ~59.5 FPS p95. +On `scene_mechanic2`, slice p95 wandered between ~30 and ~39 FPS while +matrix stayed ~59 FPS p95. This rules out CSS cell size / local brush area as +the root cause for those matrix wins. + +A bench-only adaptive selector, `polycss-adaptive-shaded`, now computes the +same source-plan gate before mounting: + +```txt +use matrix when visibleShadedColors >= 52 && visiblePlanes < 200 +``` + +Short two-repeat route checks: + +| Model | Metrics | Matrix p95 | Slice p95 | Adaptive p95 | Adaptive route | Read | +| --- | --- | ---: | ---: | ---: | --- | --- | +| `Treasure.vox` | 58 colors, 132 planes | 40.0 | 30.0 | 40.0 | matrix | Correctly catches a slice-slow model. | +| `AncientCrashSite.vox` | 44 colors, 191 planes | 30.0 | 40.0 | 39.8 | slice | Correctly avoids a slice-favored model. | +| `scene_vehicles1.vox` | 53 colors, 90 planes | 113.6 | 113.6 | 111.1 | matrix | Flat/capped; routing costs no material p95 but adds nodes. | +| `scene_hazmat.vox` | 84 colors, 130 planes | 114.9 | 111.1 | 114.9 | matrix | Correctly routes, though this browser run is mostly capped. | +| `scene_house3.vox` | 80 colors, 217 planes | 39.8 | 39.8 | 39.8 | slice | The plane cutoff avoids the known p99-risk model. | +| `desert2.vox` | 21 colors, 102 planes | 111.1 | 59.2 | 59.5 | slice | Major miss; the selector is incomplete. | + +This is not ready as a production default. It proves the route can be computed +before mount and catches high-shaded slice-slow models, but `desert2` remains a +large matrix win outside the gate. The next useful selector work is to explain +`desert2` without adding slice-favored false positives. + +Browser probes confirm that browser mode changes the apparent class: + +| Model | Bundled Chromium headless | Chrome 148 headless | Canary 150 headless | Chrome 148 headed | +| --- | --- | --- | --- | --- | +| `scene_hazmat` | matrix | flat | matrix | not run | +| `scene_house` | matrix | matrix | matrix | flat | +| `scene_mechanic2` | matrix | matrix | matrix | matrix | +| `Treasure` | matrix | matrix | matrix | matrix-p99 | +| `desert2` | matrix | matrix | matrix | matrix | +| `scene_sidewalk` | matrix | flat | flat | not run | +| `AncientCrashSite` | slice | slice-p99 | slice | slice-p99 | +| `obj_house3` | slice | flat | slice | not run | +| `pyramid` | slice | flat | flat | not run | +| `scene_house3` | slice-p99 | flat | flat | not run | + +The useful read: the refined high-shaded gate is cross-browser safe in this +sample because it turns some wins into flats, not losses. `desert2` is the +only stable matrix win outside that gate. The low-color slice wins are often +ceiling-sensitive across installed browsers. Headed Chrome makes this stricter: +`scene_house` flattens even though headless browsers usually favor matrix, so +any production selector has to be justified as "avoid known slow slice cases", +not as "always faster". + +Five-repeat validation is mandatory before promoting a two-run split. The +expanded sweep produced several mirages that flattened under validation: +`scene_sumo`, `scene_parked`, `scene_hunt`, and `scene_house5`. The later +low-plane sweep found exploratory slice wins (`armchair`, `mailbox`, +`obj_trashcan4`) that should not become anchors until validated. + +Strong validated winners: + +| Model | Winner | Matrix p95 | Slice p95 | Matrix p99 | Slice p99 | Leaves | Nodes M/S | Planes | Colors | Screen | +| --- | --- | ---: | ---: | ---: | ---: | ---: | --- | ---: | ---: | ---: | +| `pyramid` | slice | 59.9 | 104.2 | 16.9 | 9.8 | 1774 | 3057/1812 | 68 | 19 | 1.247 | +| `scene_park` | slice | 59.9 | 103.1 | 16.8 | 10.0 | 1694 | 3210/1722 | 193 | 30 | 3.280 | +| `scene_hazmat` | matrix | 101.0 | 59.3 | 10.6 | 17.6 | 1586 | 2979/1624 | 130 | 59 | 3.181 | +| `ff1` | slice | 63.4 | 102.7 | 16.7 | 10.6 | 1569 | 3133/1627 | 54 | 2 | 1.925 | +| `scene_house` | matrix | 98.0 | 59.2 | 16.7 | 17.7 | 1619 | 2928/1647 | 182 | 69 | 3.215 | +| `AncientCrashSite` | slice | 23.8 | 57.8 | 63.1 | 20.7 | 5233 | 10459/5268 | 192 | 44 | 1.585 | +| `obj_house3` | slice | 29.9 | 59.2 | 41.6 | 17.4 | 1057 | 2179/1140 | 142 | 15 | 9.533 | +| `Treasure` | matrix | 57.8 | 32.5 | 25.0 | 33.4 | 4010 | 7121/4038 | 140 | 59 | 2.600 | +| `desert2` | matrix | 59.9 | 38.0 | 17.0 | 33.4 | 1984 | 4167/2012 | 102 | 21 | 4.866 | +| `scene_mechanic2` | matrix | 59.5 | 40.0 | 17.1 | 25.3 | 2216 | 4264/2244 | 166 | 89 | 2.409 | +| `obj_house8` | slice | 97.1 | 112.4 | 16.9 | 10.1 | 452 | 909/489 | 90 | 24 | 3.482 | +| `christmas_tree` | slice | 103.1 | 117.6 | 10.1 | 9.8 | 1464 | 2693/1493 | 73 | 13 | 1.332 | +| `scene_sidewalk` | matrix | 117.6 | 104.1 | 10.0 | 10.1 | 436 | 972/482 | 47 | 32 | 1.215 | + +Two focused DOM-shape probes closed: + +- Leaf `transform-style: flat` is not a simplification. It caused order-of- + magnitude regressions (`Garden` slice p95 ~24 -> ~2.4, `AncientCrashSite` + slice ~40 -> ~4.9, `MechaGolem` slice ~60 -> ~13.3). The current + `preserve-3d` leaf style appears to be part of Chrome's valid 3D fast path. +- Keeping the three voxel hosts but turning each brush into a 1px + `matrix3d(...)` scale/translate leaf collapsed local layout area but stayed + in the slice cadence class. On `Treasure`, where matrix fallback wins, the + host+brush-matrix variant stayed around 30 FPS p95 while matrix fallback + reached ~58 FPS p95. +- Removing the three voxel hosts and emitting direct matrix leaves from the + voxel renderer was not robust. After restoring visible backfaces it looked + close in static screenshots, but still had measurable image diffs and + regressed several models (`AncientCrashSite`, `Garden`, `army`, `desert2`). + It is useful evidence that host count is not the sole issue. + +The later direct-matrix revisit clarified that result. The first broken +prototype used singular-ish matrices for side planes. Adding the same kind of +normal column the real polygon matrix path uses fixed the flat/invalid +projection on `desert2` and exposed the actual transferable property: + +```txt +hosted slice brush: + axis host rotate + left/top/width/height + leaf translateZ + +matrix-like brush: + direct scene child + 1px primitive + matrix3d(rect basis, normal, translate) +``` + +Bench-only `polycss-voxlocal-direct-matrix` keeps the voxel source brush plan +and culling, but folds each axis host into a direct canonical matrix leaf. +Current one-run reads: + +| Model | Matrix p95 | Slice p95 | Direct matrix p95 | Direct split8 p95 | Read | +| --- | ---: | ---: | ---: | ---: | --- | +| `desert2.vox` | 113.6 | 59.5 | 107-110 | 59.9 | Direct canonical matrices transfer the win; splitting destroys it. | +| `Treasure.vox` | 40.0 | 30.0 | 30-39 | 29.9 | Direct shape helps inconsistently; full polygon matrix is still steadier. | +| `AncientCrashSite.vox` | 39.7 | 39.8 | 18-24 | 24.0 | Direct source matrices are a regression. | +| `ff1.vox` | 111.1 | 112.4 | 116.6 | not run | Capped/flat; direct matrix is safe here. | +| `pyramid.vox` | 114.9 | 112.4 | 60.2 | 59.9 | Direct source matrices regress a slice-favored model. | +| `scene_park.vox` | 111.3 | 117.6 | 107.6 | not run | Slice path remains better. | + +Trace on `desert2` confirms that this is no longer just a hidden cadence +artifact. Direct matrix source brushes lower compositor work: + +| Path | FPS p95 | P99 ms | 1x vsync | PAC ms/frame | DrawProps ms/frame | Draw ms/frame | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| Matrix fallback | 113.4 | 9.2 | 100.0% | 1.49 | 1.55 | 1.88 | +| Voxel slices | 59.5 | 17.1 | 37.1% | 1.33 | 1.27 | 1.63 | +| Voxel direct matrix | 107.5 | 16.8 | 95.6% | 0.76 | 0.77 | 0.90 | + +Trace on `AncientCrashSite` shows the constraint: direct matrices also lower +PAC/draw-props there, but cadence gets worse (`18.1` FPS p95, `62.8ms` p99). +So lower compositor-summary cost is necessary but not sufficient; draw +scheduling and/or the coarse source-rectangle geometry still matter. + +The exact-quad revisit found a stronger version of the same idea. Instead of +using merged source rectangles, `polycss-voxlocal-direct-matrix-exact` emits +one direct child `` per exact parsed voxel quad. That keeps the same active +leaf count as slices while using the matrix leaf shape: + +```txt +scene root + b matrix3d(...) + b matrix3d(...) + ... +``` + +The remaining variable is DOM order. Parsed polygon order transfers the +`desert2` and `Treasure` matrix wins, but leaves `AncientCrashSite` in the +bad 30 FPS p95 bucket. Coarse face grouping fixes `AncientCrashSite` but +breaks `desert2`/`Treasure`. Six face wrappers, whether hidden with +`display:none` or detached, also break the `desert2` win. That isolates the +candidate shape: + +```txt +direct child canonical matrix leaves, no transformed axis hosts, no face +wrappers, order chosen at cull-boundary time +``` + +Projected-depth ordering was the first ordering rule that worked across the +initial tradeoff set. It sorts the visible exact matrix leaves by approximate +camera depth only when the visible face signature changes; no per-frame leaf +updates are added. + +Validated with wall-time rotation, 5 repeats, 1280x800, bundled Chromium: + +| Model | Path | FPS p50 | FPS p95 | P99 ms | Read | +| --- | --- | ---: | ---: | ---: | --- | +| `AncientCrashSite.vox` | voxcss 3d | 59.9 | 39.4 | 33.4 | Baseline ceiling class. | +| `AncientCrashSite.vox` | voxel slices | 59.9 | 40.0 | 25.2 | Current accepted path. | +| `AncientCrashSite.vox` | exact direct matrix, parsed order | 59.9 | 30.0 | 33.6 | Bad order. | +| `AncientCrashSite.vox` | exact direct matrix, depth-front | 59.9 | 39.8 | 33.3 | Recovers p95, p99 still worse. | +| `AncientCrashSite.vox` | exact direct matrix, depth-back | 59.9 | 39.8 | 26.1 | Recovers p95 and nearly slice p99. | +| `desert2.vox` | voxcss 3d | 119.0 | 59.5 | 17.4 | Voxcss stays in 60 FPS p95 bucket. | +| `desert2.vox` | voxel slices | 60.2 | 59.3 | 17.4 | Current slice path is also 60 FPS. | +| `desert2.vox` | exact direct matrix, parsed order | 120.5 | 112.4 | 9.2 | Matrix win. | +| `desert2.vox` | exact direct matrix, depth-front | 120.5 | 112.4 | 9.2 | Keeps the win. | +| `desert2.vox` | exact direct matrix, depth-back | 120.5 | 112.4 | 9.3 | Keeps the win. | +| `Treasure.vox` | voxcss 3d | 59.9 | 29.9 | 33.6 | Voxcss/slices are slow here. | +| `Treasure.vox` | voxel slices | 41.0 | 29.8 | 41.7 | Current slice path is slow. | +| `Treasure.vox` | exact direct matrix, parsed order | 60.2 | 40.2 | 25.1 | Matrix win. | +| `Treasure.vox` | exact direct matrix, depth-front | 59.9 | 40.0 | 25.2 | Keeps the win. | +| `Treasure.vox` | exact direct matrix, depth-back | 60.2 | 40.1 | 25.2 | Keeps the win. | + +The trace read is supportive but not the primary proof, because tracing can +perturb the exact cadence bucket. In one trace pass, depth-back reduced script +and style time versus parsed order on `desert2`/`Treasure` and matched slice +p99 on `AncientCrashSite`; compositor totals were still large. Treat the +clean cadence validation as the decision signal and traces as explanation. + +The broader corpus disproved a single fixed depth order as the default. A +157-model exploratory pass and targeted validation found: + +| Model | Slice p95/p99 | Depth-front p95/p99 | Depth-back p95/p99 | Read | +| --- | ---: | ---: | ---: | --- | +| `obj_house3.vox` | 112.4/9.2 | 59.9/17.3 | 58.8/25.0 | Hard counterexample to global depth order. | +| `obj_house5.vox` | 114.9/9.1 | 113.6/9.2 | 59.9/16.8 | Direction-sensitive; front is fine, back breaks. | +| `HUT.vox` | 59.9/17.2 | 59.9/17.3 | 57.9/25.0 | Back has p99 risk; front is flat. | +| `skyscraper.vox` | 29.6/40.6 | 24.0/42.2 | 24.2/41.8 | Direct matrix order is not reliably better. | +| `army.vox` | 40.0/25.1 | 39.8/33.3 | 59.5/17.4 | Back is the win; front has p99 risk. | +| `house.vox` | 60.1/16.7 | 112.4/9.2 | 114.9/9.2 | Depth order is a large win. | +| `scene_mechanic2.vox` | 59.5/17.3 | 112.1/9.3 | 111.1/9.3 | Depth order is a large win. | +| `desert2.vox` | 59.4/17.4 | 111.1/9.2 | 112.4/9.2 | Depth order is a large win. | +| `Treasure.vox` | 30.0/33.9 | 58.0/25.0 | 58.7/25.0 | Depth order is a large win. | +| `AncientCrashSite.vox` | 40.0/25.2 | 39.8/25.9 | 40.0/25.6 | Flat and visually acceptable. | + +The strongest counterexample, `obj_house3`, isolates the issue further. It is +not rejecting direct matrix leaves. Existing polygon matrix, exact direct +matrix in parsed order, and global depth order all sit around 59 FPS p95, +while direct child face order returns to the slice-class 110+ FPS p95. So the +browser fast path is order-sensitive: + +```txt +same direct child matrix leaf shape +different DOM order -> different cadence bucket +``` + +Testing face-major plus depth-within-face order did not unify the paths. It +keeps the face-order rescue on `obj_house3`, but loses the depth-order wins on +`desert2`, `house`, and `scene_mechanic2`. The current best interpretation is +that there are at least two useful compositor ordering classes: + +- global projected-depth order for large wins such as `desert2`, `house`, + `scene_mechanic2`, `Treasure`, and back-order `army`; +- coarse face order for `obj_house3` and similar capped house-like cases. + +The strongest current explanation: + +- Matrix wins are caused by direct canonical matrix leaves that avoid the + axis-host transform chain and layout-sized brush surfaces. +- The normal column in `matrix3d` matters. Without it, side-plane matrices are + effectively degenerate and render flat/wrong. +- Merged source rectangles are not reliable enough. Naively splitting them + adds dirty transform nodes and loses the win. +- Exact voxel quads plus direct matrix leaves remain the current best one + leaf-shape candidate. +- A single fixed DOM order is not proven. Global projected-depth order wins + several heavy models but regresses `obj_house3`; face order fixes + `obj_house3` but loses key depth-order wins. +- The remaining proof work is now an order policy or a structural renderer + route. If the route only changes ordering while keeping the same direct + matrix leaf shape, it is still better than switching render strategies. + +The important read is stable: + +- Raster is not the limiting phase in these captures. +- Paint is not the limiting phase in these captures. +- Style and layout are too small to explain the frame time. +- The recurring cost is compositor/layer lifecycle: + `PaintArtifactCompositor::Update`, `Layerize`, + `LayerTreeImpl::UpdateDrawProperties`, `LayerTreeHostImpl::PrepareToDraw`, + and `MainFrame.Draw`. +- For camera rotation, active transformed leaf count is now the best first + predictor. It is not the whole model, but the per-leaf slope is stable enough + to guide renderer work. + +## Chromium Source Read + +Chromium's public RenderingNG docs match the trace shape we are seeing. Visual +changes can skip layout/pre-paint/paint when they are compositor-thread visual +effect animations, but the pipeline still has layerize, activate, aggregate, +and draw stages. Property trees are the transform/clip/effect/scroll state the +compositor uses to answer where content is on screen, and paint chunks are +layerized into cc layers by trading GPU memory against future update cost. + +The relevant source path is narrower: + +- `PaintArtifactCompositor::Update` is the expensive full path. It rebuilds + pending layers, runs `Layerizer(...).Layerize()`, creates/updates compositor + property tree nodes, and sets the layer list. +- `PaintArtifactCompositor::TryFastPathUpdate` handles repaint/raster-scroll + cases without full layerization. +- `PaintArtifactCompositor::DirectlyUpdateTransform` can skip full PAC + layerization only when the transform node is known composited and has active + transform animation. The call site in `paint_property_tree_builder.cc` + explicitly gates the downgrade from `kChangedOnlySimpleValues` to + `kChangedOnlyCompositedValues` on `transform.HasActiveTransformAnimation()`. +- Even the successful direct transform path updates the cc transform node, + marks the transform tree dirty, and calls `SetNeedsCommit()`. That means the + win is "skip PAC/layerize", not "free rotation". +- `TransformTree::UpdateAllTransforms` iterates transform nodes when dirty. + `draw_property_utils::CalculateDrawProperties` then updates property trees, + finds visible layers, computes screen/target transforms, visible rects, and + drawable content rects. This is the remaining per-frame cost after PAC is + gone. + +This explains why the current JS orbit path keeps showing PAC: polycss mutates +`sceneEl.style.transform` through `scene.setOptions()` every frame. The scene +root has `will-change: transform`, so it is composited, but a JS style mutation +is not the same as an active compositor transform animation for the direct +update gate. + +Quick source-driven probes confirm this on `apocalypse/car.glb` through the +vanilla bench page. Same DOM, baked lighting, trace enabled, 1280x800: + +| Motion | FPS p50 | FPS p95 | PAC count | PAC total ms | DrawProps total ms | Draw total ms | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| Static | 120.5 | 114.9 | 0 | 0.0 | 35.4 | 72.8 | +| JS `scene.setOptions({ rotY })` | 13.3 | 13.2 | 40 | 91.1 | 103.9 | 151.4 | +| CSS keyframe `transform` on `.polycss-scene` | 59.9 | 13.4 | 0 | 0.0 | 186.2 | 303.6 | +| Running WAAPI `transform` animation | 59.9 | 13.4 | 0 | 0.0 | 181.5 | 298.5 | +| Paused WAAPI animation scrubbed with `currentTime` from JS | 13.3 | 12.0 | 39 | 88.9 | 101.8 | 151.3 | +| Scroll-timeline animation scrubbed by JS `scrollLeft` | 13.3 | 12.0 | 38 | 81.2 | 158.0 | 199.6 | + +The exact FPS from these probes is trace-perturbed and should not be treated as +a shipping benchmark. The source-level result is the important part: +declarative running transform animation removes PAC, but draw-property and draw +costs remain large for a big preserve-3d subtree. JS scrubbing an otherwise +compositor-backed mechanism (`Animation.currentTime` or `scrollLeft`) does not +preserve the PAC win. + +The first synthetic topology probe confirms the lower-level transform-node +cost. It used 2500 `` leaves under the same running CSS-animated +preserve-3d root, so PAC stayed zero and the comparison isolates draw-property +and draw work: + +| Leaf topology | FPS p95 | PAC ms/frame | DrawProps ms/frame | Draw ms/frame | +| --- | ---: | ---: | ---: | ---: | +| `left/top`, no leaf transform | 112.4 | 0.000 | 0.016 | 0.066 | +| 2D `translate(...)` leaf transform | 113.6 | 0.000 | 0.017 | 0.066 | +| `translateZ(0)` leaf transform | 112.4 | 0.000 | 0.639 | 1.851 | +| Real `translateZ(...)` leaf transform | 111.1 | 0.000 | 0.914 | 2.310 | +| `matrix3d(...)` leaf transform | 112.4 | 0.000 | 0.956 | 2.276 | + +This is the clearest browser-level result so far: a syntactically 3D leaf +transform, even `translateZ(0)`, moves the page into the expensive transform +tree / draw-properties class. A 2D translate is decomposed or otherwise handled +like `left/top`. That directly matches `PendingLayer::DecompositeTransforms`, +which only decomposes identity/2D translations. + +The depth-grouping follow-up moved leaf `translateZ(...)` into one wrapper per +axis/depth plane while keeping the same leaf count. This gives a sharper +threshold than the earlier real-renderer wrapper tests: + +| Leaves | Root motion | Variant | FPS p95 | P99 ms | PAC ms/frame | DrawProps ms/frame | Draw ms/frame | +| ---: | --- | --- | ---: | ---: | ---: | ---: | ---: | +| 2500 | JS transform mutation | leaf-z17 | 113.6 | 9.3 | 1.131 | 1.427 | 2.007 | +| 2500 | JS transform mutation | group-z17 | 113.6 | 9.2 | 0.419 | 0.033 | 0.083 | +| 2500 | JS transform mutation | leaf-z50 | 116.3 | 9.2 | 1.130 | 1.468 | 2.036 | +| 2500 | JS transform mutation | group-z50 | 117.6 | 9.1 | 0.445 | 0.062 | 0.163 | +| 2500 | JS transform mutation | leaf-z250 | 40.0 | 25.1 | 1.151 | 1.449 | 2.049 | +| 2500 | JS transform mutation | group-z250 | 113.6 | 16.6 | 0.522 | 0.249 | 0.494 | +| 5000 | JS transform mutation | leaf-z17 | 59.5 | 17.3 | 2.508 | 3.162 | 4.139 | +| 5000 | JS transform mutation | group-z17 | 114.9 | 9.2 | 0.807 | 0.035 | 0.155 | +| 5000 | JS transform mutation | leaf-z50 | 40.0 | 25.7 | 2.537 | 3.236 | 4.197 | +| 5000 | JS transform mutation | group-z50 | 29.9 | 41.6 | 0.825 | 0.060 | 0.294 | +| 5000 | JS transform mutation | leaf-z250 | 29.7 | 33.9 | 2.585 | 3.417 | 4.623 | +| 5000 | JS transform mutation | group-z250 | 13.3 | 75.0 | 26.844 | 1.312 | 2.540 | +| 5000 | CSS transform animation | leaf-z17 | 59.5 | 16.9 | 0.000 | 1.773 | 4.072 | +| 5000 | CSS transform animation | group-z17 | 116.3 | 9.2 | 0.000 | 0.025 | 0.154 | +| 5000 | CSS transform animation | leaf-z50 | 58.8 | 24.9 | 0.000 | 1.912 | 4.213 | +| 5000 | CSS transform animation | group-z50 | 29.3 | 41.7 | 0.000 | 0.046 | 0.306 | +| 5000 | CSS transform animation | leaf-z250 | 20.0 | 50.6 | 0.000 | 1.896 | 4.130 | +| 5000 | CSS transform animation | group-z250 | 8.0 | 133.2 | 0.000 | 0.678 | 2.233 | + +This is not a green light for depth wrappers as the default renderer. It +explains why the earlier real-renderer wrapper attempts lost: many real `.vox` +models have too many visible depth planes. Strong current winners sit at +47-191 visible planes (`scene_sidewalk` 47, `ff1` 54, `pyramid` 68, +`desert2` 102, `Treasure` 132, `AncientCrashSite` 191). The synthetic result +only looks clean at very low wrapper count. Around 50 wrappers, trace events +look cheap but cadence can get worse; by 250 wrappers the wrapper hierarchy is +catastrophic. The actionable hypothesis is therefore a strict low-plane gate, +not a replacement for leaf `translateZ(...)`. + +A bench-only real-model variant, `polycss-voxlocal-depth-groups`, then tested +that gate against actual voxel plans: + +| Model | Visible planes | Current voxlocal p95 | Depth groups p95 | Read | +| --- | ---: | ---: | ---: | --- | +| `armchair.vox` | 22 | 109.9-112.4 | 108.7-109.9 | Capped/flat; extra nodes do not buy anything. | +| `obj_trashcan4.vox` | 16 | 109.9-111.1 | 111.0-112.3 | Capped/flat within noise. | +| `mailbox.vox` | 28 | 109.9 | 112.4-113.6 | Capped/flat within noise. | +| `scene_sidewalk.vox` | 47 | 113.6-116.3 | 111.1-112.4 | Near the unstable zone; slight regression. | +| `ff1.vox` | 54 | 114.9 | 29.9 | Confirms the 50-wrapper synthetic warning. | + +So the low-plane gate has no current product payoff. Available low-plane real +models are already near the browser cadence ceiling, and the first meaningful +near-threshold model regresses hard. Reopen only if we find a high-leaf, +sub-30-visible-plane real scene. + +An early synthetic distribution probe used 1200 `matrix3d` leaves and JS root +rotation, varying only projected distribution: + +| Distribution | FPS p95 | PAC ms/frame | DrawProps ms/frame | Draw ms/frame | +| --- | ---: | ---: | ---: | ---: | +| Clustered | 112.4 | 0.609 | 0.629 | 0.785 | +| Spread | 116.3 | 0.592 | 0.757 | 0.942 | +| Overlap-heavy | 111.1 | 0.594 | 0.758 | 0.919 | + +This does not prove a layerization-sparsity win yet: PAC/layerize were almost +the same, and a shorter rerun was noisier. Treat this as a conditional +secondary hypothesis: projected distribution may move draw-properties and draw +at equal leaf count, but it needs controlled repeats before it can guide +renderer work. + +Actionable consequences: + +- Test a product-shaped declarative camera animation path separately from + interactive drag. If it wins cleanly, auto-rotate demos can use CSS/WAAPI + compositor animations while pointer-driven controls stay imperative. +- Do not expect `will-change` alone to solve JS camera rotation. It can force + compositing, but the direct-update source gate still wants active transform + animation. +- Do not chase more nested coordinate wrappers as a PAC fix. More transform + nodes increase property-tree and draw-property work, which is exactly the + remaining source path. +- The next non-voxel optimization target is still active transformed leaf + count, render-surface count, and transform-node count. For voxel scenes the + accepted fast path attacks active leaves; for non-voxel scenes the equivalent + lever is mesh reduction / merging / LOD, not transform-chain cleverness. + +## Next Actionable Hypotheses + +| ID | Hypothesis | Why it is plausible | Test | Accept if | Reject if | +| --- | --- | --- | --- | --- | --- | +| C1 | A dedicated declarative auto-rotate path can skip PAC for demos. | Chromium gates direct transform updates on active transform animation; CSS keyframes and running WAAPI both showed zero PAC. | Add a bench-only `motion=css-rot` or `motion=waapi-rot` page mode, run clean FPS and traces on `AncientCrashSite`, `Garden`, `Treasure`, `apoc-car`, and `ducky`, then screenshot-check at fixed angles. | PAC stays zero and clean p95/p99 improve without visual drift. | Clean cadence does not improve, or screenshots/interaction semantics become messy. | +| C2 | JS-scrubbed compositor animations are not a viable interactive camera fix. | Paused WAAPI `currentTime` and JS `scrollLeft` scroll-timeline probes both hit PAC once per frame. | Repeat once in headed Chrome/Canary to rule out a headless artifact, then close. | A current browser shows zero PAC while scrubbed from JS. | PAC count tracks frame count again. | +| C3 | The remaining slope is proportional to dirty 3D transform nodes, not just DOM leaves. | `TransformTree::UpdateAllTransforms` iterates transform nodes when dirty; `PendingLayer::DecompositeTransforms` only removes identity/2D-translation transforms. The synthetic topology probe shows `translateZ(0)`, real `translateZ`, and `matrix3d` are far more expensive than `left/top` or 2D translate. | Keep `bench/compositor-topology-probe.mjs` in the loop and repeat in headed/current browsers; use it as the benchmark for any future DOM topology idea. | Already accepted as a browser cost model. | Reopen only if another browser version makes 3D leaves decompose cheaply. | +| C4 | Projected distribution/overlap may be a secondary cost model after leaf count and 3D transform-node count. | One longer synthetic distribution run moved DrawProps/Draw by about 20% at equal `matrix3d` leaf count, though PAC/layerize stayed similar; a shorter rerun was noisier. | Extend the synthetic probe with controlled screen coverage and optional intrusive layer counts; then compare real models with similar leaves but different bounds. | Distribution explains stable model-to-model variance that leaf count misses. | Repeated controlled runs collapse to noise. | +| C5 | Splitting one preserve-3d scene into multiple independently animated islands is only useful if it reduces active leaves per dirty transform tree enough to offset extra roots. | Source says more transform nodes are costly, but smaller dirty subtrees may reduce draw-property work if only one island moves. | Synthetic scene with N leaves split into 1/2/4 mesh roots; animate one root vs all roots; compare draw-property slope and visual correctness. | Animating one island is cheaper in proportion to its leaves and all-roots is not much worse than one root. | Extra roots/render surfaces dominate. | +| C6 | Depth-plane wrappers may be viable only for tiny visible-plane-count voxel scenes. | Synthetic depth grouping is excellent at 17 wrappers and 5000 leaves, but 50 wrappers already loses cadence and 250 wrappers collapses. | Done in `polycss-voxlocal-depth-groups` on available low-plane real models plus nearby counterexamples. | Reopen only if a high-leaf, sub-30-visible-plane real scene appears. | Current corpus has no useful payoff: sub-30-plane scenes are capped/flat, and `ff1` at 54 planes regresses to ~30 FPS p95. | +| C7 | The adaptive matrix-vs-slice gate needs a second structural predicate for low-color matrix wins. | `visibleShadedColors >= 52 && visiblePlanes < 200` catches `Treasure`/high-shaded wins and avoids `AncientCrashSite`/`scene_house3`, but misses `desert2`, which is still a large matrix win. | Search source-plan metrics for a predicate that captures `desert2` without hitting validated strong slice winners; then rerun `polycss-adaptive-shaded` or a new adaptive case. | Captures `desert2` and keeps p95/p99 neutral on `ff1`, `pyramid`, `christmas_tree`, `AncientCrashSite`, and `scene_park`. | Any predicate that catches `desert2` also routes strong slice winners to matrix. | +| C8 | Hostless direct canonical matrix brushes are the portable part of the matrix win. | `polycss-polybox` proved parsed polygons in axis hosts still fall to slice cadence; `polycss-voxlocal-direct-matrix` proved hostless canonical matrices transfer the win on `desert2`. | Turn the bench prototype into a cleaner renderer experiment with visual diff gates. Test unsplit source brushes, exact parsed brushes, and a cheap predicate for source plans that stay browser-friendly. | Direct matrix source brushes pass visual checks and improve validated p95/p99 on a class broader than `desert2` without hurting slice-favored models. | The source-plan predicate collapses, or visual-correct direct matrices only reproduce the existing polygon fallback. | +| C9 | Exact direct matrix leaves can be the single voxel leaf shape, but DOM order needs a compositor-aware policy. | Depth order keeps the `desert2`/`Treasure`/`house`/`scene_mechanic2`/`army` wins, but `obj_house3` needs face/top-first order and `AncientCrashSite` rejects normal/depth-band variants. Static face permutations, face-normal order, face-depth, face-block, and depth-band hybrids all have hard counterexamples. | Stop adding face-order permutations. Build a metric for projected overlap and depth-order inversions at cull-boundary angles, then validate it on the hard split set. | One leaf shape plus a geometry-derived order policy is neutral-or-better than slices on validated p95/p99 and passes screenshots. | Any policy still leaves strong slice-favored regressions, requires benchmark feedback, or fails visual checks. | +| C10 | The order win is a cadence threshold, not lower measured main-thread work per frame. | Same-node-count traces on `scene_mechanic2` and `obj_house3` show PAC, DrawProps, Draw, paint, raster, and script are nearly identical per inferred frame between fast and slow orders; the difference is 1x-vsync share. Chromium docs also put preserve-3d quad sorting/intersection in the compositor frame path. | Add overlap/inversion metrics and, if needed, an intrusive layer/quad diagnostic only after clean FPS runs. | The metric predicts 1x-vsync vs 2x/3x-vsync cadence without relying on model names. | Per-frame trace groups start diverging materially under cleaner instrumentation. | + +## Trace Rules + +- Do not optimize against `RunTask` by itself. It is a wrapper around nested + work and mostly tells us the frame was expensive. +- Do not sum top trace events as if they were exclusive. Many are nested. +- Compare per-frame event medians, not only total trace milliseconds. +- Keep `LayerTree.enable` out of primary FPS runs. It perturbs the path. +- Keep trace and raw-sample diagnostics separate from clean cadence sweeps. + Mid-sized models can jump back to a perfect 120Hz cadence under diagnostic + capture, even when five clean repeats show a stable split. +- Always record browser executable, headless/headed mode, DPR, warmup, sample + window, viewport, model, leaves, and trace categories. + +## Tooling + +Use the trace summarizer for any result JSON containing trace summaries: + +```sh +node bench/trace-summary.mjs bench/results/.json +``` + +Use the synthetic compositor probe for browser-shape hypotheses: + +```sh +node bench/compositor-topology-probe.mjs +node bench/compositor-topology-probe.mjs --mode=topology --leaves=5000 +node bench/compositor-topology-probe.mjs --mode=distribution --headed +node bench/compositor-topology-probe.mjs --mode=depth-groups --root=js --leaves=5000 +``` + +Useful current voxel command pattern: + +```sh +TRACE=1 REPEATS=1 WARMUP_MS=1000 SAMPLE_MS=2500 PRINT_JSON=0 \ + CASES=polycss-baked-voxzoom MODEL_FILE=Garden.vox POLY_ZOOM=voxcss \ + node bench/results/ancient-rotation-compare.mjs +``` + +Then summarize: + +```sh +node bench/trace-summary.mjs bench/results/garden-vox-rotation-compare.json +``` + +## Trace-Backed Hypotheses + +| ID | Status | Hypothesis | What would prove it | +| --- | --- | --- | --- | +| T1 | Accepted | The real camera-motion cost is 3D property-tree/compositor maintenance, not paint. | Current traces show PAC/layerize/draw-props dominate while paint/raster stay near zero. | +| T2 | Accepted | Chrome rebuilds compositor state when the root transform changes over the preserve-3d subtree. | Static and repeated-same-value traces drop PAC/layerize/draw-props to zero; changing rotation restores them. | +| T3 | Watch | Bounds complexity may modulate the per-leaf slope. | Models with similar leaf counts but different projected bounds should separate in `DrawProperties`/`PrepareToDraw` per frame. | +| T4 | Accepted | A cheap first-order predictor is active transformed leaves times a browser slope. | Voxel and non-voxel camera traces cluster around ~0.6-0.75 us/leaf for PAC and draw-props, with draw slightly higher. | +| T5 | Test next | The next valid ceiling prototype must lower PAC/draw-props while preserving the exact 3D visual contract. | A prototype beats current polycss on `PAC ms/frame` and screenshots, not just p95 FPS. | +| T6 | Test next | Dynamic-light perf is a separate cascade/raster problem, not the camera compositor problem. | Dynamic light traces should be tracked with style/raster columns and not mixed with camera-motion conclusions. | +| T7 | Flat | Moving voxel `translateZ` to depth-plane wrappers reduces per-leaf transform cost only at very low wrapper count. | Synthetic probes show a clean 17-wrapper ceiling case, but real sub-30-plane models are already capped and `ff1` at 54 planes regresses hard. No current production path. | +| T8 | Rejected | Canonical 1x1 voxel brushes with `translate3d(... ) scale(...)` reduce layout/GPU bounds enough to improve FPS. | Screenshot smoke was clean, and local layout area collapsed, but FPS was flat across `MechaGolem`, `Treasure`, `stairs`, `desert2`, `army`, `Garden`, `AncientCrashSite`, `HUT`, and `scene_vehicles1`. | +| T9 | Rejected | `backface-visibility:hidden` is the missing voxel fast path. | Plain hidden backfaces improves several models but fails visual checks badly. Face-flipped hidden backfaces passes screenshot smoke, but FPS is flat. | +| T10 | Watch | Some voxel models should use the matrix renderer instead of slice brushes. | Current 86-model corpus: matrix wins `desert2`, `scene_hazmat`, `scene_house`, `scene_mechanic2`, `scene_sidewalk`, `Treasure`; slice wins `AncientCrashSite`, `armchair`, `christmas_tree`, `ff1`, `mailbox`, `obj_house3`, `obj_house8`, `obj_trashcan4`, `pyramid`, `scene_park`; 66 models are flat/capped. `visibleShadedColors >= 52 && visiblePlanes < 200` is the only safe-looking partial matrix gate; it misses stable `desert2` and browser-sensitive `scene_sidewalk`. | +| T11 | Rejected | Leaf `transform-style: flat` reduces property-tree work. | It catastrophically regressed matrix and slice paths across all tested voxel models. Leaves have no transformed children, but Chrome still needs them in the preserve-3d path for this renderer. | +| T12 | Rejected | Keep voxel hosts but encode brush rectangles as 1px `matrix3d(...)` leaves. | Local layout area collapsed from tens of millions of px to thousands, but FPS stayed flat or regressed. It did not inherit the plain matrix fallback's cadence on `Treasure`. | +| T13 | Conditional | Remove voxel hosts and emit direct canonical matrix leaves from the voxel renderer. | The corrected normal-column prototype transfers the `desert2` matrix win and lowers PAC/draw-props, but still regresses `AncientCrashSite`, `pyramid`, and `scene_park`; splitting large source rectangles makes it worse. This is a candidate renderer shape only behind a strong source-plan predicate or with exact polygon granularity. | + +## Next Work + +1. Treat camera rotation as a per-active-leaf compositor budget. Renderer + changes are interesting only if they reduce active transformed leaf count or + the per-leaf PAC/draw-props slope while preserving visuals. +2. Treat adaptive matrix-vs-slice as a cadence-classifier problem. Prototype + the `visibleShadedColors >= 52 && visiblePlanes < 200` gate as a + benchmark-only adaptive case before changing defaults; it should prove + net-neutral-or-better on p95 and p99, not just p95, and keep validation + repeats as the source of truth. +3. Capture raw trace events for a short run when needed. The current summary is + enough for direction, but raw events are needed to inspect property-tree and + compositor internals more deeply. +4. Keep dynamic-light traces separate. That path is style/raster-heavy and + should not drive camera-renderer decisions. +5. Do not pursue depth wrappers, split direct matrices, or voxel + backface-hidden variants without a new trace signal. Hostless direct + canonical matrices remain open because they transferred the `desert2` win, + but only as a visual-gated renderer experiment. +6. The two useful optimization paths left are: + - adaptive matrix-vs-slice selection for `.vox` models, once cadence can be + predicted safely; + - actual active-leaf reduction through better exact voxel planning or + better non-voxel mesh merging. diff --git a/bench/VOXEL_FAST_PATH_HYPOTHESES.md b/bench/VOXEL_FAST_PATH_HYPOTHESES.md new file mode 100644 index 00000000..765b8d80 --- /dev/null +++ b/bench/VOXEL_FAST_PATH_HYPOTHESES.md @@ -0,0 +1,422 @@ +# Voxel Fast Path Hypotheses + +Actionable ledger for the `.vox` renderer performance investigation. This file +tracks DOM shape, renderer strategy, paint/composite cost, and hidden browser +fast paths. It intentionally avoids website UI hypotheses. + +## Status Legend + +| Status | Meaning | +| --- | --- | +| ✅ Accepted | Keep this direction unless stronger evidence appears. | +| ❌ Rejected | Do not retest without a new reason. | +| 🧪 Test next | High-signal experiment to run soon. | +| ⚠️ Conditional | Promising only if a cheap, general gate predicts wins. | +| 🟡 Flat | Tested; no useful movement. | +| 🔬 Ceiling | Useful to understand an upper bound, not current architecture. | +| 👀 Watch | Track while testing nearby hypotheses. | + +## Marking Rules + +- Mark ✅ only when the result works across renderer-only voxel model classes: + dense, sparse, tall, wide, flat, multi-color, high-plane-count, and noisy. +- Mark ❌ when a hypothesis regresses p95/p99 materially, fails visual checks, + or contradicts trace evidence. +- Mark ⚠️ only when the win is real but needs a cheap structural predicate. +- Keep raw FPS tables and traces in result artifacts; keep this file focused on + hypotheses and decisions. + +## Current Baseline + +Current accepted voxel fast path: + +| Layer | Shape | +| --- | --- | +| Scene | One transform/perspective scene root. | +| Mesh | One `.polycss-mesh` wrapper. | +| Voxel hosts | Three axis hosts: `x`, `y`, `z`. | +| Leaves | Plain `` brush rectangles. | +| Positioning | `left/top/width/height` plus one leaf `translateZ(...)`. | +| Culling | Only camera-facing face directions are mounted. | + +Current read: + +- Extra 3D wrappers are expensive. +- Lower DOM count alone is not a reliable cost model. +- Brush leaves need `transform-style: preserve-3d` and visible overflow. +- A1 traces show the current polycss-vs-voxcss delta is compositor/layerization + work, especially `PaintArtifactCompositor::Update`, `Layerize`, and + `LayerTreeImpl::UpdateDrawProperties`. Raster is zero in these samples. +- `LayerTree.enable` is intrusive enough to perturb FPS; use it only as an + opt-in layer-shape diagnostic, not as the primary FPS comparison. +- Naive scene-box variants can hit a much faster compositor path on + `AncientCrashSite.vox`, but current versions are visually invalid: blank, + cropped, or off-center. Origin-pinned, scene-matrix-compensated, and + mesh-compensated versions preserve the image but lose the win, so the fast + path appears tied to the invalid transform chain itself. +- Corpus runs must normalize visual fit before conclusions. Fixed + `POLY_ZOOM=0.35` cropped `army.vox` badly; the apparent voxshell win there + was mostly a different fit, not a renderer win. +- First zoom-normalized corpus pass: `AncientCrashSite`, `Treasure`, `army`, + and `HUT` are roughly flat; `Garden`, `skyscraper`, and `MechaGolem` favor + polycss; `scene_vehicles1` slightly favors voxcss. Brush count alone does + not predict those outcomes. +- Mounted brush count changes can explain isolated p99 spikes, but not steady + slow paths. `army.vox` voxcss had a 58ms frame at a brush-count transition; + `Garden.vox` voxcss kept a constant brush count and still stayed slow. +- A12 structural metrics improved observability but did not produce a complete + predictor yet. Brush count, local area, plane-fill ratio, and screen bounds + each fail on at least one representative model. +- Matched-zoom `targetSize` sweeps from 50 to 90 changed local brush area by + several multiples but were flat on `Garden`, `skyscraper`, `MechaGolem`, and + `AncientCrashSite`. +- Equivalent `
` voxel brushes, leaf `will-change: auto`, a broader + voxel-brush CSS reset, and `inert`/`aria-hidden` were flat. Host clipping was + visually invalid. +- DPR 1 vs DPR 2 in headless Chromium was flat on `Garden`, `MechaGolem`, and + `AncientCrashSite`; the browser-mode question is now headed/current-vs-canary + rather than device pixel ratio. +- Headed/system/Canary browser mode changes absolute ceilings and can shrink + apparent gaps. `MechaGolem` is a major example: bundled Playwright Chromium + made polycss look far ahead, while installed Chrome/Canary put polycss and + voxcss near parity. `Garden` still favored polycss across browser modes. +- Paint and style are not the current bottleneck. On high-color `army.vox`, + traces showed single-digit milliseconds in style/paint across the whole + sample while compositor/layer lifecycle was hundreds of milliseconds. +- Synthetic classes are useful. Dense cubes and thin slabs are flat, sparse + separated voxels favor voxcss, and noisy/high-color scenes are mostly flat; + this exposes thresholds that the gallery corpus alone hides. +- Matrix-vs-slice fallback selection is real but only partly predictable. An + 86-model cadence corpus has matrix p95 wins on `desert2`, `scene_hazmat`, + `scene_house`, `scene_mechanic2`, `scene_sidewalk`, and `Treasure`; slice + p95 wins on `AncientCrashSite`, `armchair`, `christmas_tree`, `ff1`, + `mailbox`, `obj_house3`, `obj_house8`, `obj_trashcan4`, `pyramid`, and + `scene_park`; 66 models are flat or capped. Active leaves, local area, + visible planes, raw source color count, and screen fill each have + counterexamples. +- Visible shaded brush color count is a useful partial selector, and it is + different from raw source color count. `visibleShadedColors >= 52`, computed + from the current polygon brush plan and baked lighting, captures four + validated strong matrix wins (`scene_hazmat`, `scene_house`, + `scene_mechanic2`, `Treasure`) and no validated strong slice wins, but it + misses `desert2` and `scene_sidewalk`, hits many flat/capped scenes, and + creates one p99 regression on `scene_house3` if used by itself. Adding + `visiblePlanes < 200` keeps the same p95 wins and removes that p99 loss in + the current corpus. +- Browser probes on Chrome 148 and Canary 150 keep the refined high-shaded + gate safe but not universally profitable: `scene_house`, `scene_mechanic2`, + `Treasure`, and `desert2` stay matrix; `scene_hazmat` can flatten in Chrome; + `scene_sidewalk`, `pyramid`, and `obj_house3` are browser-ceiling sensitive. +- Headed Chrome tightens that warning: `scene_house` flattens in headed mode + even though it is a headless matrix win. Treat the selector as a guard + against known slice slow paths, not a guaranteed speedup. +- A bench-only adaptive route (`polycss-adaptive-shaded`) proves the + high-shaded gate can be computed before mount and route the renderer. It + catches `Treasure`, keeps `AncientCrashSite` and `scene_house3` on slices, + and routes capped high-shaded models as expected. It still misses `desert2`, + which remains a large low-color matrix win, so the selector is incomplete. +- Target-size sweeps on `desert2` and `scene_mechanic2` rule out CSS cell size + and local brush area as the cause of the matrix wins. Slice area changed by + several multiples while p95 stayed in the same cadence bucket. +- Five-repeat validation is required for selector work. Two-run sweeps created + apparent wins on `scene_sumo`, `scene_parked`, `scene_hunt`, and + `scene_house5` that disappeared under validation. +- Existing non-product prototypes are not valid ceiling proof. `polycss-slice- + proto` can improve p95, but screenshots fail the visual check by flattening + or otherwise changing the 3D render. +- Chromium source confirms a real but partial fast path for declarative + transform animation. Active compositor transform animation can directly + update the cc transform node and skip full `PaintArtifactCompositor::Update`; + JS `style.transform` mutation from `scene.setOptions()` does not hit that + gate in our traces. The direct path still marks the transform tree dirty, so + `LayerTreeImpl::UpdateDrawProperties` and draw remain the next bottleneck. +- Follow-up probes narrow that animation fast path: running CSS keyframes and + running WAAPI skip PAC, but paused WAAPI scrubbed with `currentTime` and a + scroll-timeline scrubbed by JS `scrollLeft` both fall back into PAC. Treat + declarative animation as an auto-rotate-only candidate until a browser probe + proves otherwise. +- Synthetic depth-group probes reopen depth wrappers only under a very strict + gate. Moving leaf `translateZ(...)` into one wrapper per depth plane is a big + win at 17 wrappers and 5000 leaves, but 50 wrappers already loses cadence and + 250 wrappers collapses. Most important real models sit well above the clean + range (`AncientCrashSite` 191 visible planes, `Treasure` 132, `desert2` 102, + `pyramid` 68, `ff1` 54, `scene_sidewalk` 47), so depth wrappers remain + rejected as the default renderer shape. +- A bench-only real-model depth-group variant confirms there is no current + product payoff. Available sub-30-plane models (`armchair`, + `obj_trashcan4`, `mailbox`) are already capped/flat, `scene_sidewalk` at 47 + planes is slight regression/noise, and `ff1` at 54 planes collapses to ~30 + FPS p95. +- A corrected hostless direct-matrix voxel prototype identifies the portable + part of matrix wins. `polycss-polybox` showed that parsed polygons inside + axis hosts still fall to slice cadence on `desert2`; `polycss-voxlocal- + direct-matrix` folds the axis host into each leaf's canonical `matrix3d` + with a normal column and transfers the `desert2` win. It still regresses + `AncientCrashSite`, `pyramid`, and `scene_park`, and splitting large source + rectangles loses the win, so this is not a default renderer yet. +- Exact direct matrix remains the strongest one-leaf-shape candidate, but the + hard part is DOM/paint order. Global projected-depth order transfers matrix + wins to `desert2`, `house`, `scene_mechanic2`, `Treasure`, and `army`, while + fixed face order rescues `obj_house3` and stays neutral on + `AncientCrashSite`/`skyscraper`. +- Hybrid order attempts did not produce a universal rule. Face-depth sorting + and depth bands with face locality regress key cases; alternate static face + orders and face-normal order trade one model class for another. The most + important trace result: same-node-count order flips have almost identical + PAC/DrawProps/Draw cost per frame, but radically different vsync cadence. + That points at Chromium's 3D compositor sorting/overlap critical path rather + than normal style, layout, paint, or node-count costs. +- A31's first compositor-order metric closes the naive version of that idea. + `bench/voxel-order-metrics.mjs` now samples visible exact-matrix voxel leaves + over rotation and counts projected AABB overlaps, depth-order inversions, + crossing/tie pairs, overlap components, face switches, and depth jumps. The + simple depth-inversion metric does not predict FPS: pure depth order has zero + inversions but is slow on `obj_house3`, `army` depth-front, and + `scene_mechanic2`, while parsed/source order can be fast with 30-40% + overlapping depth inversions. The next test should preserve source/spatial + locality while applying depth only at block scale. +- Two-run A31 order sweep, `REPEATS=2`, `WARMUP_MS=1000`, `SAMPLE_MS=3000`, + `MOTION=rotate-time`: exact parsed order is now a major candidate, not just + global depth. It wins or stays within the fast bucket on `desert2`, `house`, + `scene_mechanic2`, `Treasure`, `army`, `obj_house5`, and `skyscraper`, but + still fails `obj_house3` and `AncientCrashSite`. +- A32 rejects source-block-depth ordering as a universal direct-matrix order. + Keeping source order inside fixed-size blocks and sorting only block roots by + projected depth is a useful middle form: it gets close on `obj_house5`, + `desert2`, `scene_mechanic2`, `Treasure`, and `AncientCrashSite`. It still + loses badly on `obj_house3`, misses `army` and `skyscraper`, and the winning + front/back direction plus block size are model-specific. This is a reusable + diagnostic primitive, not a renderer policy. +- A33 is the strongest order result so far. Projected screen-space tile groups + with exact direct-matrix leaves beat the prior A31 best on 8 of 9 quick + two-run models, with `skyscraper` still in its capped/noisy class. The best + tile policy remains model-specific, but `tile4-scanline-forward` is the first + plausible single policy: it rescues `obj_house3`, stays in the high bucket on + `obj_house5`, `desert2`, `house`, `scene_mechanic2`, `Treasure`, and + `AncientCrashSite`, and improves `army` over A31 exact. It needs validation + before becoming a renderer direction. +- A34 validates `tile4-scanline-forward` as a broad default candidate versus + the current slice shape, but not as the full solution. With five repeats and + only the minimum comparator set, it produces large p95 wins on + `obj_house3`, `obj_house5`, `desert2`, `house`, `scene_mechanic2`, and + `Treasure`, is neutral on `AncientCrashSite`, modest on `skyscraper`, and + unstable/near-flat on `army`. The next single-strategy validation is + `tile4-depth-front`, because A33 suggests it may cover weak scanline cases. +- Probe A35 rejects `tile4-depth-front` as the one strategy. It helps `army` + and ties several high-bucket models, but collapses `obj_house3` and + `Treasure`; its hard-set average is lower than A34 `tile4-scanline-forward`. + Do not pursue a gate here: the requirement is one strategy, so the useful + path is testing nearby single scanline policies. +- Probe A36 rejects nearby scanline tile sizes as replacements. `tile3`, + `tile5`, and `tile6` each have hard counterexamples and lower hard-set + averages than validated `tile4-scanline-forward`. The next one-strategy idea + should keep tile4 grouping and vary only tile traversal order, e.g. + serpentine or Morton/Z-order locality. +- Probe A37 rejects tile4 serpentine and Morton traversal as replacements. + Morton helps `desert2`/`Treasure` slightly and serpentine helps `army`, but + both lose `obj_house3`; serpentine also loses `house`, and both have lower + hard-set averages than row-major `tile4-scanline-forward`. The incumbent + remains the only broad one-strategy candidate. + +Latest hard-split validation, p95 FPS medians: + +| Model | Slice | Face | Normal F | Depth F | Depth B | Read | +| --- | ---: | ---: | ---: | ---: | ---: | --- | +| `obj_house3.vox` | 112.6 | 114.9 | 111.2 | 59.9 | 58.8 | Needs top/face locality. | +| `obj_house5.vox` | 116.3 | 113.6 | 114.9 | 114.9 | 59.9 | Front/top orders safe; depth-back bad. | +| `desert2.vox` | 59.7 | 59.9 | 59.9 | 113.6 | 114.9 | Needs projected depth. | +| `house.vox` | 59.9 | 113.6 | 59.9 | 116.3 | 116.3 | Depth is best; face is usable. | +| `scene_mechanic2.vox` | 59.5 | 59.9 | 59.9 | 114.9 | 115.1 | Needs projected depth. | +| `Treasure.vox` | 29.9 | 30.0 | 40.0 | 57.6 | 40.0 | Needs depth-front for the high bucket. | +| `army.vox` | 40.0 | 40.2 | 30.0 | 30.1 | 58.5 | Needs depth-back. | +| `AncientCrashSite.vox` | 39.8 | 40.0 | 30.0 | 39.8 | 39.9 | Face/top neutral; normal sorting bad. | +| `skyscraper.vox` | 29.8 | 29.7 | 29.9 | 24.0 | 29.9 | Mostly capped; depth-front bad. | + +Latest A31 two-run order-metric sweep, p95 FPS medians: + +| Model | Slice | Exact | Face | Normal F | Depth F | Depth B | Read | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- | +| `obj_house3.vox` | 59.9 | 40.0 | 109.3 | 109.9 | 39.7 | 30.0 | Needs face/normal-front locality; depth correctness is actively bad. | +| `obj_house5.vox` | 59.9 | 113.0 | 112.4 | 111.7 | 113.0 | 59.9 | Many front/local orders are fast; depth-back is bad. | +| `desert2.vox` | 59.7 | 114.3 | 58.8 | 59.7 | 112.3 | 111.1 | Parsed/depth-like order wins; face locality is bad. | +| `house.vox` | 59.8 | 112.3 | 59.9 | 59.5 | 109.0 | 59.9 | Parsed/depth-front order wins; face locality is bad. | +| `scene_mechanic2.vox` | 40.0 | 111.7 | 59.3 | 59.3 | 59.9 | 59.9 | Parsed source order wins; pure depth no longer explains it. | +| `Treasure.vox` | 39.2 | 58.6 | 39.9 | 49.4 | 58.7 | 58.1 | Parsed and depth are tied in the fast bucket. | +| `army.vox` | 39.8 | 49.3 | 39.8 | 30.0 | 30.0 | 40.0 | Parsed order is best in this quick run. | +| `AncientCrashSite.vox` | 34.7 | 29.9 | 39.9 | 30.0 | 39.0 | 39.6 | Face/top/depth are neutral; exact parsed is bad. | +| `skyscraper.vox` | 21.5 | 29.8 | 29.9 | 29.8 | 26.7 | 34.5 | Mostly capped; depth-back is best in this quick run. | + +Latest A32 source-block-depth sweep, p95 FPS medians: + +| Model | Best A32 | Strategy | Prior A31 best | Delta | Read | +| --- | ---: | --- | ---: | ---: | --- | +| `obj_house3.vox` | 59.6 | block128-depth-back | 109.9 | -50.3 | Source blocks do not preserve the face/normal locality this model needs. | +| `obj_house5.vox` | 111.9 | block64-depth-front | 113.6 | -1.7 | Coarse front depth plus source locality is close to the best static order. | +| `desert2.vox` | 114.3 | block128-depth-back | 114.3 | -0.1 | Source-block depth transfers the matrix win. | +| `house.vox` | 108.1 | block128-depth-front | 112.3 | -4.2 | Only one block size/direction reaches the high bucket. | +| `scene_mechanic2.vox` | 111.1 | block64-depth-front | 111.7 | -0.6 | Coarse front depth transfers most of the parsed-order win. | +| `Treasure.vox` | 58.4 | block64-depth-front | 58.7 | -0.3 | Source-block depth stays in the high bucket. | +| `army.vox` | 40.2 | block256-depth-front | 49.3 | -9.1 | Block depth cannot reproduce parsed-order benefit. | +| `AncientCrashSite.vox` | 39.8 | block64-depth-front | 39.9 | -0.1 | Neutral; does not improve the hard case. | +| `skyscraper.vox` | 30.0 | block256-depth-front | 34.5 | -4.4 | Still capped/slow relative to depth-back. | + +Latest A33 projected-tile sweep, p95 FPS medians: + +| Model | Best A33 | Strategy | Prior A31 best | Delta | Read | +| --- | ---: | --- | ---: | ---: | --- | +| `obj_house3.vox` | 114.9 | tile4-scanline-forward | 109.9 | +5.1 | Screen scanline order rescues the model that source/depth could not. | +| `obj_house5.vox` | 115.6 | tile4-scanline-reverse | 113.6 | +2.0 | Many tile orders are high; direction is not fragile here. | +| `desert2.vox` | 117.6 | tile4-depth-front | 114.3 | +3.3 | Tile depth-front beats parsed/depth order. | +| `house.vox` | 112.4 | tile8-depth-front | 112.3 | +0.1 | Ties the prior best; tile8 scanline variants are bad. | +| `scene_mechanic2.vox` | 117.0 | tile8-scanline-forward | 111.7 | +5.2 | Screen grouping beats parsed source order. | +| `Treasure.vox` | 59.7 | tile4-depth-back | 58.7 | +1.0 | Still around the high bucket; no major new ceiling. | +| `army.vox` | 58.8 | tile8-scanline-reverse | 49.3 | +9.6 | Screen grouping finds the missing win that source blocks missed. | +| `AncientCrashSite.vox` | 40.0 | tile4-depth-front | 39.9 | +0.1 | Neutral; this model remains at its ~40 p95 class. | +| `skyscraper.vox` | 30.0 | tile4-scanline-forward | 34.5 | -4.5 | Still capped/noisy; quick A31 depth-back needs validation before treating this as a regression. | + +Latest A34 `tile4-scanline-forward` validation, p95 FPS medians: + +| Model | Slice | Tile4 scanline F | Delta | Tile p50 | Tile p99 ms | Read | +| --- | ---: | ---: | ---: | ---: | ---: | --- | +| `obj_house3.vox` | 59.9 | 113.6 | +53.8 | 120.5 | 9.2 | Validated win; this rescues the main face/locality counterexample. | +| `obj_house5.vox` | 59.9 | 113.4 | +53.6 | 120.5 | 9.2 | Validated win. | +| `desert2.vox` | 59.5 | 113.6 | +54.1 | 120.5 | 9.2 | Validated win, though A33 depth-front was slightly higher. | +| `house.vox` | 59.9 | 114.7 | +54.9 | 120.5 | 9.3 | Validated win. | +| `scene_mechanic2.vox` | 40.0 | 113.5 | +73.5 | 120.5 | 9.2 | Validated win. | +| `Treasure.vox` | 30.5 | 58.5 | +28.0 | 60.2 | 24.0 | Validated win into the ~60 FPS class. | +| `army.vox` | 39.8 | 42.1 | +2.2 | 60.2 | 25.1 | Not enough; runs split between ~40 and ~58 p95. | +| `AncientCrashSite.vox` | 39.8 | 39.8 | +0.0 | 59.9 | 27.9 | Neutral; this model remains in the ~40 p95 class. | +| `skyscraper.vox` | 23.7 | 29.9 | +6.2 | 40.0 | 39.8 | Modest win, still capped/slow. | + +Probe A35 `tile4-depth-front` single-strategy check, p95 FPS medians: + +| Model | Slice | Scanline F | Depth F | Depth - scanline | Read | +| --- | ---: | ---: | ---: | ---: | --- | +| `obj_house3.vox` | 59.9 | 113.6 | 59.9 | -53.8 | Depth-front fails the face/locality class. | +| `obj_house5.vox` | 59.9 | 113.4 | 114.7 | +1.2 | Tie. | +| `desert2.vox` | 59.5 | 113.6 | 113.6 | -0.0 | Tie. | +| `house.vox` | 59.9 | 114.7 | 111.1 | -3.6 | Scanline is better. | +| `scene_mechanic2.vox` | 40.0 | 113.5 | 114.9 | +1.4 | Tie. | +| `Treasure.vox` | 30.5 | 58.5 | 40.0 | -18.5 | Depth-front loses the high bucket. | +| `army.vox` | 39.8 | 42.1 | 48.7 | +6.7 | Depth-front helps but not enough to justify losing other models. | +| `AncientCrashSite.vox` | 39.8 | 39.8 | 39.8 | +0.0 | Tie. | +| `skyscraper.vox` | 23.7 | 29.9 | 29.9 | +0.0 | Tie. | + +Probe A36 scanline tile-size sweep, p95 FPS medians: + +| Model | Tile3 | Tile4 | Tile5 | Tile6 | Read | +| --- | ---: | ---: | ---: | ---: | --- | +| `obj_house3.vox` | 59.7 | 113.6 | 111.1 | 111.7 | Tile3 fails; tile4 remains best. | +| `obj_house5.vox` | 111.0 | 113.4 | 114.3 | 113.7 | Tile5 is slightly higher in a two-run probe. | +| `desert2.vox` | 113.0 | 113.6 | 84.6 | 84.6 | Tile5/6 are unstable or slow. | +| `house.vox` | 59.9 | 114.7 | 61.2 | 112.4 | Tile3/5 fail. | +| `scene_mechanic2.vox` | 114.2 | 113.5 | 108.7 | 60.2 | Tile6 fails; tile3 only marginally higher. | +| `Treasure.vox` | 58.1 | 58.5 | 58.3 | 58.3 | Flat; tile4 remains best. | +| `army.vox` | 40.0 | 42.1 | 39.9 | 40.0 | No tile-size variant fixes the weak case. | +| `AncientCrashSite.vox` | 39.8 | 39.8 | 30.1 | 39.5 | Tile5 fails; tile3/tile4 tie. | +| `skyscraper.vox` | 30.0 | 29.9 | 30.0 | 30.0 | Flat. | + +Probe A37 tile4 traversal sweep, p95 FPS medians: + +| Model | Row-major | Serpentine | Morton | Read | +| --- | ---: | ---: | ---: | --- | +| `obj_house3.vox` | 113.6 | 60.2 | 59.9 | Row-major is required for the main counterexample. | +| `obj_house5.vox` | 113.4 | 112.4 | 111.7 | Row-major holds. | +| `desert2.vox` | 113.6 | 112.7 | 114.9 | Morton slightly higher in a two-run probe. | +| `house.vox` | 114.7 | 59.9 | 107.7 | Serpentine fails; row-major holds. | +| `scene_mechanic2.vox` | 113.5 | 112.3 | 111.7 | Row-major holds. | +| `Treasure.vox` | 58.5 | 48.7 | 58.8 | Morton ties/slightly higher; serpentine loses. | +| `army.vox` | 42.1 | 48.9 | 44.0 | Serpentine helps, but not enough to offset losses. | +| `AncientCrashSite.vox` | 39.8 | 39.8 | 40.0 | Flat. | +| `skyscraper.vox` | 29.9 | 30.0 | 30.0 | Flat. | + +## Accepted Decisions + +| ID | Status | Decision | Why it stays | +| --- | --- | --- | --- | +| D1 | ✅ Accepted | Preserve raw `PolyVoxelSource` and render eligible `.vox` meshes through a dedicated fast path. | This produced the major win over polygon `matrix3d` leaves. | +| D2 | ✅ Accepted | Keep the shallow three-axis-host DOM shape. | It beat every wrapper-heavy variant tested. | +| D3 | ✅ Accepted | Keep one leaf `translateZ(...)` per brush as the default renderer. | Real high-plane models regressed with depth wrappers, and synthetic probes show wrapper count becomes unstable around 50 planes. Only a strict low-plane benchmark gate is still open. | +| D4 | ✅ Accepted | Prefer exact parsed voxel quads over source overpaint for default rendering. | The exact path preserves visual correctness; lower-node source variants did not reliably win. | +| D5 | ✅ Accepted | Keep camera-facing culling. | Mounting all faces regressed hard. | +| D6 | ✅ Accepted | Keep integer CSS cell snapping for `.vox` normalization. | It avoids a scale wrapper and keeps brush coordinates on integer pixels. | +| D7 | ✅ Accepted | Normalize visual fit before comparing voxel renderer FPS. | Fixed zoom can crop or resize large voxel scenes enough to change the benchmark question. | + +## Active Hypotheses + +| ID | Status | Hypothesis | Experiment | Mark ✅ if | Mark ❌ if | +| --- | --- | --- | --- | --- | --- | +| A1 | ✅ Accepted | Polycss and voxcss differ mainly in compositor/layerization work, not raster/layout. | Done on `AncientCrashSite.vox` and `Treasure.vox`: polycss shows higher `PaintArtifactCompositor::Update`, `Layerize`, and draw-property work; raster is zero for both engines. | Next tests can target style/transform/layer-shape causes. | Reopen only if broader traces contradict the compositor/layerization read. | +| A2 | ❌ Rejected | Computed-style differences point mainly at scene shell/transform shape, not raster/layout. | Scene-shell differences identified the invalid 120fps path, but visual-preserving compensation, perspective, tags, CSS resets, and containment did not keep the win. | Reopen only with a new visual-preserving shell form. | All current shell/style variants are flat or visually invalid. | +| A3 | ✅ Accepted | Current spot checks are hiding model classes. | Done on a starter corpus with zoom-normalized polycss/voxcss comparison. | Keep corpus sweeps as the default proof path. | Reopen only if later full-corpus data collapses to one uniform behavior. | +| A4 | 🟡 Flat | Host containment is useful for a structural class of models. | `polycss-shell-host-only` was flat/noisy on `Garden`, `MechaGolem`, `scene_vehicles1`, `AncientCrashSite`, and `army`; `polycss-baked-voxshell` changed visual fit and is not a clean containment result. | Reopen only with a narrower containment-only variant or layer-bound evidence. | Host-only containment remains flat across representative classes. | +| A5 | ❌ Rejected | A visual-preserving scene box/transform chain may keep the compositor fast path. | Tested naive `scene-size`, `scene-position`, `host-size`, `box-only`, origin-pinned boxes, scene-matrix compensation, and mesh compensation on `AncientCrashSite.vox`. | Reopen only if a different shell form preserves both visual output and the fast compositor path. | Visual-preserving variants drop back to baseline; fast variants are blank, cropped, or off-center. | +| A6 | 🟡 Flat | Perspective placement/value changes compositor behavior. | Tested current huge finite perspective against `perspective:none` and `100000px` on `AncientCrashSite`, `Garden`, `MechaGolem`, `skyscraper`, and `scene_vehicles1`. | Reopen only for a different transform-chain placement, not value alone. | Perspective value changes were visually equivalent and flat. | +| A7 | 🟡 Flat | Target size/cell size changes compositor pressure enough to matter after visual fit is normalized. | Matched-zoom sweep from target 50/60/70/80/90 on `Garden`, `skyscraper`, `MechaGolem`, and `AncientCrashSite`. | Reopen only if a different matched-fit policy changes actual screen bounds or brush count. | Local area changed by multiples while p95/p99 stayed effectively flat. | +| A8 | 🟡 Flat | A different brush tag hits a cheaper UA/style path. | Compared `` against equivalent `
` voxel brush leaves across representative corpus. | Reopen only with another tag/style pair that is visually equivalent and moves trace/FPS. | Equivalent `
` brushes track `` within noise. | +| A9 | 🟡 Flat | A minimal voxel-only CSS reset reduces style or paint cost. | Tested leaf `will-change: auto` and a broader voxel-only reset for box/font/background-repeat/will-change. | Reopen only with trace evidence for style-rule cost. | CSS reset variants were flat; `army` apparent wins collapsed under longer runs. | +| A10 | 🟡 Flat | Accessibility/event trees add measurable overhead for thousands of semantic leaves. | Added `aria-hidden`/`inert` to the polycss mesh subtree across representative corpus. | Reopen only outside headless rotation or with accessibility-tree-specific evidence. | FPS/task metrics stayed flat. | +| A11 | 🟡 Flat | Paint chunks are fragmented by color/style cardinality. | Traced high-color `army`, medium-color `Garden`, and low-color `MechaGolem`. Style/paint stayed tiny relative to compositor/layer lifecycle. | Reopen only if a future renderer increases paint/raster time. | Paint/style are not the current bottleneck. | +| A12 | ✅ Accepted | The planner needs a browser/compositor cost model, not a rectangle-count minimum. | Added brush area, depth-plane count, color count, local union/overdraw, plane fill, screen bounds, browser-mode checks, and synthetic classes. | Future planner work should use compositor/browser metrics and synthetic thresholds. | Reopen only if a simple DOM metric later predicts the corpus. | +| A13 | ❌ Rejected | View-dependent mounted brush count explains p95/p99 drops broadly. | Sampled mounted brushes during rotation. Counts changed on several models, but `Garden.vox` voxcss stayed slow with constant brush count. | Reopen only as a narrow mutation-spike hypothesis. | Stable mounted count can still have poor p95/p99. | +| A14 | ❌ Rejected | Cull-boundary mutation is the parity path. | Sampled transition spikes, then tested a cull-freeze diagnostic on `army` and `scene_vehicles1`. Removing swaps did not improve the path and could regress by holding the wrong visible set. | Reopen only with a concrete predictive/batched swap algorithm, not freeze/no-swap. | Cull changes can cause isolated spikes, but are not the broad parity gap. | +| A15 | ✅ Accepted | Browser mode changes the apparent fast path. | DPR 1/2 was flat; headed mode changed absolute cadence; installed Chrome/Canary changed `MechaGolem` from a large polycss win to near parity. | Benchmark reports must include browser executable, headed/headless, and DPR. | Reopen if CI/browser standardization makes this stable again. | +| A16 | 🟡 Flat | CSS rule matching remains a cost even for voxel fast path. | Minimal CSS/reset variants were flat, and traces showed style recalc is tiny compared with compositor/layer lifecycle. | Reopen only with trace evidence that style recalc dominates. | Current traces show compositor/raster lifecycle dominates instead. | +| A17 | ❌ Rejected | Host clipping/isolation can reduce paint bounds without leaf clipping. | Host containment was flat; host `overflow: clip` plus `contain: paint` was screenshot-tested on `AncientCrashSite.vox`. | Reopen only with a different boundary that passes screenshots. | Host clipping split the slice planes and dropped visible geometry. | +| A18 | ❌ Rejected | Existing non-product prototypes establish a valid ceiling. | Ran `polycss-slice-proto` and `polycss-polybox` on `AncientCrashSite`, `Garden`, and synthetic sparse grid. `slice-proto` can be faster but fails visual checks; `polybox` is not a consistent win. | Reopen only with a render-correct ceiling prototype. | Current prototypes change the visual render or do not beat the fast path. | +| A19 | ✅ Accepted | Benchmark instrumentation can perturb results. | `LayerTree.enable` changed the AncientCrashSite FPS path, especially for polycss; keep layer-tree capture opt-in and separate from FPS traces. | Use low-intrusion traces for FPS comparisons and isolated diagnostic runs for layer counts. | Reopen if a cheaper layer-shape probe is found. | +| A20 | ✅ Accepted | Synthetic model classes are needed to expose thresholds. | Generated dense cube, sparse grid, thin slab, and noisy/high-color `.vox` models. Dense/thin cases are flat; sparse separated voxels favor voxcss; noisy/high-color is mostly flat. | Keep synthetic classes in future benchmark sweeps. | Reopen only if synthetic behavior diverges from future real-model findings. | +| A21 | ⚠️ Conditional | Some `.vox` models should route to the matrix fallback instead of voxel slices. | 86-model cadence corpus with `bench/voxel-cadence-summary.mjs`, static plan metrics from `bench/voxel-static-metrics.mjs`, Chrome/Canary probes from `bench/voxel-browser-summary.mjs`, and bench-only `polycss-adaptive-shaded`. | Mark accepted only if a cheap static predicate predicts validated matrix wins without hurting slice-favored models. Current partial gate: `visibleShadedColors >= 52 && visiblePlanes < 200`; `desert2` remains unexplained. | Reject if the winner remains unstable after validation or if a predicate that catches `desert2` also routes strong slice winners to matrix. | +| A22 | 🧪 Test next | Declarative camera animation can skip PAC for auto-rotate scenes, but cannot solve interactive JS rotation by itself. | Chromium source read plus `apocalypse/car.glb` probes: JS rotation hit PAC every sample frame; CSS keyframe and running WAAPI hit zero PAC; paused WAAPI `currentTime` and JS `scrollLeft` scroll-timeline probes still hit PAC. Build a bench-only WAAPI/CSS camera mode and run voxel + non-voxel traces without treating trace FPS as final. | Accept a separate auto-rotate path if validated runs show lower PAC and better cadence without visual drift or API contortions. | Reject as a general renderer fix if interactive pointer-driven controls still require JS transform mutation and draw-property cost remains dominant. | +| A23 | ✅ Accepted | Dirty 3D transform-node count is the next lower-level cost model after active DOM leaves. | `bench/compositor-topology-probe.mjs` confirmed the source read: at equal 2500 leaves, `left/top` and 2D `translate` were near-free in draw properties, while `translateZ(0)`, real `translateZ`, and `matrix3d` were ~40-60x higher. | Use this as the browser-shape benchmark for future topology ideas. | Reopen only if another Chromium version makes 3D leaves decompose cheaply. | +| A24 | ⚠️ Conditional | Projected distribution/overlap may explain model variance that leaf count misses. | One longer synthetic distribution probe at equal 1200 `matrix3d` leaves moved DrawProps/Draw by about 20%, but PAC/layerize stayed similar; a shorter rerun was noisier. | Promote if controlled screen-coverage runs and real-model pairs show the same separation. | Reject if the effect collapses under controlled repeats. | +| A25 | 🟡 Flat | Depth-plane wrappers may help only tiny visible-plane voxel scenes. | `bench/compositor-topology-probe.mjs --mode=depth-groups` kept leaf count fixed and moved Z from leaves to depth wrappers. At 5000 leaves, 17 wrappers improved p95/p99 and almost erased draw-property cost; 50 wrappers had cheap trace events but worse cadence; 250 wrappers collapsed. The bench-only `polycss-voxlocal-depth-groups` variant then found no useful real-model win: sub-30-plane models are capped/flat and `ff1` at 54 planes regresses to ~30 FPS p95. | Reopen only if a high-leaf, sub-30-visible-plane real model appears. | Current real corpus has no shippable depth-wrapper gate. | +| A26 | ⚠️ Conditional | Hostless direct canonical matrix brushes are the transferable part of matrix wins. | `polycss-polybox` uses parsed polygons but keeps axis hosts and falls to slice cadence; `polycss-voxlocal-direct-matrix` removes hosts, uses 1px leaves, folds scale/orientation/depth into `matrix3d`, and transfers the `desert2` win while lowering PAC/draw-props. | Accept only if a visual-gated direct-matrix renderer improves a validated model class without regressing slice-favored models. Candidate subtests: source-plan predicate, exact parsed polygon granularity, and visual diff against current slices. | Reject if direct matrices only win when visually different, or if the safe version is just the existing polygon matrix fallback. | +| A27 | ⚠️ Conditional | Exact direct matrix leaves can be the single voxel leaf shape, but order must be solved at the compositor/overlap level. | Same exact `` matrix leaves, same active nodes, and same culling produce opposite cadence depending only on DOM order. Global depth wins `desert2`, `house`, `scene_mechanic2`, `Treasure`, and `army`; face/top-first order rescues `obj_house3` and keeps `AncientCrashSite` neutral. | Promote if a cheap geometry/order policy is neutral-or-better than slices on validated p95/p99 while keeping the same direct matrix leaf shape. | Reject if the policy must become benchmark-feedback routing or if visual checks fail. | +| A28 | ❌ Rejected | A single static or face-normal order can replace model routing. | Tested fixed side-first, side-reverse, top-reverse, face-normal-front/back, face-depth, face-block, and depth-band variants on the hard split set. `obj_house3` rejects side-first/reverse and depth; `desert2` rejects top/normal-front; `house` rejects normal-front; `AncientCrashSite` rejects normal sorting and bands; `Treasure` still needs per-leaf depth for the high bucket. | Reopen only with a new order derived from Chromium sorting behavior, not another static face permutation. | Current static/normal permutations all have hard counterexamples. | +| A29 | ❌ Rejected | Depth bands with face locality bridge face order and projected-depth order. | 4- and 8-band variants reduce mounted nodes but regress `obj_house3`, `army`, `AncientCrashSite`, and `skyscraper`; `Treasure` still stays below global depth. | Reopen only if a different banding rule is tied to measured compositor overlap/sorting, not node count. | Fewer active nodes performed worse, so this is not the missing invariant. | +| A30 | ✅ Accepted | Order-sensitive wins are cadence/scheduler threshold effects, not lower per-frame main-thread compositor work. | One-run traces on `scene_mechanic2` and `obj_house3` with same leaves/nodes show PAC, DrawProps, Draw, paint, raster, and script per inferred frame are nearly identical between fast and slow orders. The difference is the share of 1x-vsync vs 2x/3x-vsync frames. | Next work should inspect overlap/sorting/damage/GPU critical path, not style/layout/PAC totals. | Reopen only if low-intrusion traces on more models show per-frame compositor groups diverging materially. | +| A31 | ❌ Rejected | Projected overlap/order inversions predict when depth order is required. | Added `bench/voxel-order-metrics.mjs` and ran the hard split set with two repeats per strategy. Overlap count, crossing rate, and depth-inversion rate are useful diagnostics, but the naive inversion predictor fails: zero-inversion depth order can be slow, and parsed order can be fast with many inversions. | Reopen only with a more specific Chromium/BSP metric than AABB overlap and average-depth inversion. | Current metric does not correlate with validated or exploratory p95/p99 by order. | +| A32 | ❌ Rejected | Source/spatial locality plus coarse depth ordering may beat pure face and pure depth order. | Tested source-order blocks of 32, 64, 128, and 256 leaves, sorted by average projected depth front/back, on the hard split set with two repeats. | Reopen only if the block definition is derived from a new browser/compositor metric rather than fixed source chunks. | Fixed source chunks are model-specific: they transfer some wins but fail `obj_house3`, miss `army`/`skyscraper`, and do not beat the A31 Pareto frontier. | +| A33 | ⚠️ Conditional | Projected screen-space grouping may match Chromium's 3D overlap/sorting work better than source-order blocks. | Tested 4x4 and 8x8 projected screen tiles with depth-front/back and scanline-forward/reverse ordering on the hard split set with two repeats. A34 then validated `tile4-scanline-forward` against slices with five repeats; A35 rejects `tile4-depth-front` as the one strategy. | Promote only if one deterministic tile policy validates with screenshots and remains neutral-or-better than slices across the hard set. | Reject if the remaining win requires routing/gates or if visual checks fail. | +| A34 | ❌ Rejected | Tile4 spatial locality is useful, but row-major traversal may not be the best one-strategy order. | Tested single-strategy tile4 serpentine and Morton/Z-order traversals as new strategy IDs only. | Reopen only with a new traversal justified by a browser/compositor model. | Serpentine and Morton both lose `obj_house3` and remain below tile4 row-major average. | + +## Closed Rejections + +| ID | Status | Rejected hypothesis | Reason | +| --- | --- | --- | --- | +| R1 | ❌ Rejected | Naive scene/mesh/host boxes without visual-preserving transform compensation. | Some scene-box variants hit a fast compositor path, but screenshots were blank, cropped, or off-center; not acceptable as a renderer change. | +| R2 | ❌ Rejected | Matrix atom split for voxel scenes. | Did not beat the dedicated voxel slice renderer. | +| R3 | ❌ Rejected | Inner depth hosts or sibling `(axis, depth)` wrappers as a default renderer. | Extra transformed 3D hierarchy was much slower on real high-plane models. Synthetic probes reopen only a strict low-visible-plane benchmark gate. | +| R4 | ❌ Rejected | Nested cartesian coordinate wrappers. | Incompatible with merged rectangles and expected wrapper explosion. | +| R5 | ❌ Rejected | `matrix3d` inside brush leaves while keeping the three voxel hosts. | Host+brush matrix did not beat `left/top/width/height + translateZ`; the promising version removes the axis hosts entirely and uses a non-degenerate normal column. | +| R6 | ❌ Rejected | Mount all six face directions. | Extra active DOM dominated any mutation savings. | +| R7 | ❌ Rejected | Hide pooled leaves instead of removing unused leaves. | Neutral to worse. | +| R8 | ❌ Rejected | Sort brushes by axis/depth/area/color as a default. | Regressed in spot checks. | +| R9 | ❌ Rejected | Split large brushes into smaller rectangles. | More leaves and worse p95. | +| R10 | ❌ Rejected | Source overpaint or no-overlap source planner as default. | Fewer leaves did not reliably win and overpaint risks visuals. | +| R11 | ❌ Rejected | Brush `transform-style: flat`. | Catastrophic regression. | +| R12 | ❌ Rejected | Brush `overflow: hidden`. | Catastrophic regression. | +| R13 | ❌ Rejected | Brush `backface-visibility: hidden`. | Neutral to worse and visually risky. | +| R14 | ❌ Rejected | Host `will-change: transform`. | Neutral to worse. | +| R15 | ❌ Rejected | Remove scene-root `will-change`. | Neutral to worse. | +| R16 | ❌ Rejected | Inline `background-color` instead of `currentColor`. | Neutral to slightly worse. | +| R17 | ❌ Rejected | `translate3d(0,0,z)` or individual `translate` instead of `translateZ`. | Neutral to worse overall. | +| R18 | 🟡 Flat | Leaf containment. | No meaningful win. | +| R19 | ❌ Rejected | Opacity culling for hidden faces. | Keeps paint/composite work alive; contradicts all-faces result. | +| R20 | ❌ Rejected | CSS paint worklet face generation. | Violates no-JS/render-loop direction and is unlikely to be reliable. | +| R21 | ❌ Rejected | JS-scrubbed compositor animation as an interactive camera fix. | Paused WAAPI `currentTime` and JS-driven scroll-timeline `scrollLeft` probes both hit PAC once per frame, matching the normal JS transform mutation path. | + +## Next Concrete Order + +1. Do not rerun the whole A31/A32 table for every small idea. Run only new + strategy IDs; rerun the full hard split set only when the DOM shape, + culling, or measurement harness changes. +2. Screenshot-check `tile4-scanline-forward` against the current voxel slice + renderer on the hard split set. If visuals pass, keep it as the broad + direct-matrix prototype baseline. +3. If screenshots pass, port `tile4-scanline-forward` as the direct-matrix + prototype order and compare it against the current voxel slice renderer in + the gallery manually. +4. Keep declarative auto-rotate work separate from pointer-driven rotation; it + may still be useful for demos, but it is not the interactive renderer fix. diff --git a/bench/compositor-topology-probe.mjs b/bench/compositor-topology-probe.mjs new file mode 100644 index 00000000..d64e3b52 --- /dev/null +++ b/bench/compositor-topology-probe.mjs @@ -0,0 +1,339 @@ +#!/usr/bin/env node +/** + * Synthetic Chromium compositor probe for polycss trace hypotheses. + * + * This intentionally does not render a correct model. It isolates browser + * behavior under a rotating preserve-3d root: + * - topology: equal leaf count, different leaf transform topology + * - distribution: equal matrix3d leaves, different projected distribution + * - depth-groups: per-leaf translateZ versus depth-plane wrappers + * + * Usage: + * node bench/compositor-topology-probe.mjs + * node bench/compositor-topology-probe.mjs --mode=topology --leaves=5000 + * node bench/compositor-topology-probe.mjs --mode=distribution --headed + * node bench/compositor-topology-probe.mjs --mode=depth-groups --leaves=5000 + * node bench/compositor-topology-probe.mjs --mode=depth-groups --root=js + */ +import { chromium } from "playwright"; + +const args = new Map( + process.argv.slice(2).map((arg) => { + const [key, ...rest] = arg.replace(/^--/, "").split("="); + return [key, rest.length ? rest.join("=") : "true"]; + }), +); + +const MODE = args.get("mode") ?? "all"; +const LEAVES = Number(args.get("leaves") ?? 2500); +const SAMPLE_MS = Number(args.get("sample-ms") ?? 2500); +const WARMUP_MS = Number(args.get("warmup-ms") ?? 700); +const HEADED = args.has("headed"); +const JSON_OUT = args.has("json"); +const EXECUTABLE = args.get("browser"); +const ROOT = args.get("root"); + +const CHROMIUM_ARGS = [ + "--disable-background-timer-throttling", + "--disable-renderer-backgrounding", + "--disable-backgrounding-occluded-windows", +]; + +const TRACE_CATEGORIES = [ + "devtools.timeline", + "disabled-by-default-devtools.timeline", + "blink", + "cc", + "gpu", + "renderer.scheduler", +].join(","); + +function percentile(sorted, p) { + return sorted[ + Math.min(sorted.length - 1, Math.max(0, Math.floor((sorted.length - 1) * p))) + ] ?? 0; +} + +function summarizeFrameTimes(dts) { + const sorted = dts + .filter((value) => Number.isFinite(value) && value > 0 && value < 2000) + .sort((a, b) => a - b); + const p50 = percentile(sorted, 0.5); + const p95 = percentile(sorted, 0.95); + const p99 = percentile(sorted, 0.99); + return { + samples: sorted.length, + fps_p50: +(1000 / p50).toFixed(1), + fps_p95: +(1000 / p95).toFixed(1), + p99_ms: +p99.toFixed(1), + }; +} + +function summarizeTraceEvents(events) { + const byName = new Map(); + for (const event of events) { + if (event?.ph !== "X" || typeof event.dur !== "number") continue; + const entry = byName.get(event.name) ?? { count: 0, us: 0 }; + entry.count += 1; + entry.us += event.dur; + byName.set(event.name, entry); + } + + const get = (name) => byName.get(name) ?? { count: 0, us: 0 }; + const frames = Math.max(1, get("FireAnimationFrame").count || 1); + const pick = (name) => { + const event = get(name); + return { + count: event.count, + total_ms: +(event.us / 1000).toFixed(1), + per_frame_ms: +(event.us / 1000 / frames).toFixed(3), + }; + }; + + return { + pac: pick("PaintArtifactCompositor::Update"), + layerize: pick("Layerize"), + drawProps: pick("LayerTreeImpl::UpdateDrawProperties"), + visible: pick("draw_property_utils::ComputeDrawPropertiesOfVisibleLayers"), + draw: pick("MainFrame.Draw"), + paint: pick("Paint"), + style: pick("UpdateLayoutTree"), + raf: pick("FireAnimationFrame"), + }; +} + +async function startTrace(cdp) { + const events = []; + cdp.on("Tracing.dataCollected", (payload) => { + if (Array.isArray(payload.value)) events.push(...payload.value); + }); + await cdp.send("Tracing.start", { + transferMode: "ReportEvents", + categories: TRACE_CATEGORIES, + }); + return events; +} + +async function stopTrace(cdp, events) { + await new Promise(async (resolve) => { + cdp.once("Tracing.tracingComplete", resolve); + await cdp.send("Tracing.end"); + }); + return summarizeTraceEvents(events); +} + +function topologyStyle(variant, i, leaves) { + const cols = Math.max(1, Math.ceil(Math.sqrt(leaves))); + const x = (i % cols) * 8 - 200; + const y = Math.floor(i / cols) * 8 - 200; + const z = ((i % 17) - 8) * 6; + switch (variant) { + case "left-top": + return `left:${x}px;top:${y}px;width:6px;height:6px;`; + case "translate2d": + return `width:6px;height:6px;transform:translate(${x}px,${y}px);`; + case "translateZ0": + return `left:${x}px;top:${y}px;width:6px;height:6px;transform:translateZ(0);`; + case "translateZ": + return `left:${x}px;top:${y}px;width:6px;height:6px;transform:translateZ(${z}px);`; + case "matrix3d": + return `width:1px;height:1px;transform:matrix3d(6,0,0,0,0,6,0,0,0,0,1,0,${x},${y},${z},1);`; + default: + throw new Error(`Unknown topology variant "${variant}"`); + } +} + +function distributionStyle(variant, i) { + const cols = 40; + let x; + let y; + if (variant === "cluster") { + x = (i % cols) * 8 - 160; + y = Math.floor(i / cols) * 8 - 120; + } else if (variant === "spread") { + x = (i % cols) * 24 - 480; + y = Math.floor(i / cols) * 24 - 360; + } else if (variant === "overlap") { + x = (i % cols) * 2 - 40; + y = Math.floor(i / cols) * 2 - 30; + } else { + throw new Error(`Unknown distribution variant "${variant}"`); + } + const z = ((i % 17) - 8) * 6; + return `width:1px;height:1px;transform:matrix3d(6,0,0,0,0,6,0,0,0,0,1,0,${x},${y},${z},1);`; +} + +function parseDepthVariant(variant) { + const match = /^(leaf|group)-z(\d+)$/.exec(variant); + if (!match) throw new Error(`Unknown depth-groups variant "${variant}"`); + return { + kind: match[1], + depthCount: Math.max(1, Number(match[2])), + }; +} + +function depthPosition(i, leaves) { + const cols = Math.max(1, Math.ceil(Math.sqrt(leaves))); + return { + x: (i % cols) * 8 - 200, + y: Math.floor(i / cols) * 8 - 200, + }; +} + +function depthValue(depthIndex, depthCount) { + return (depthIndex - (depthCount - 1) / 2) * 6; +} + +function makeDepthGroupCells(variant, leaves) { + const { kind, depthCount } = parseDepthVariant(variant); + if (kind === "leaf") { + const cells = []; + for (let i = 0; i < leaves; i++) { + const depthIndex = i % depthCount; + const { x, y } = depthPosition(i, leaves); + const z = depthValue(depthIndex, depthCount); + cells.push( + ``, + ); + } + return cells.join(""); + } + + const buckets = Array.from({ length: depthCount }, () => []); + for (let i = 0; i < leaves; i++) { + const depthIndex = i % depthCount; + const { x, y } = depthPosition(i, leaves); + buckets[depthIndex].push( + ``, + ); + } + + return buckets + .map((children, depthIndex) => { + const z = depthValue(depthIndex, depthCount); + return `
${children.join("")}
`; + }) + .join(""); +} + +function makeHtml({ mode, variant, leaves, root }) { + let cells = ""; + if (mode === "depth-groups") { + cells = makeDepthGroupCells(variant, leaves); + } else { + const leafHtml = []; + for (let i = 0; i < leaves; i++) { + const style = + mode === "topology" + ? topologyStyle(variant, i, leaves) + : distributionStyle(variant, i); + leafHtml.push(``); + } + cells = leafHtml.join(""); + } + + const rootMotion = + root === "css" + ? "animation:spin 10s linear infinite;" + : "transform:scale(.7) rotateX(65deg) rotate(0deg);"; + const script = + root === "css" + ? "const samples=[];let last=performance.now();function tick(now){samples.push(now-last);last=now;requestAnimationFrame(tick)}requestAnimationFrame(tick);window.__probe={samples};" + : "const root=document.querySelector('.scene');const samples=[];let last=performance.now(),frame=0;function tick(now){samples.push(now-last);last=now;frame++;root.style.transform='scale(.7) rotateX(65deg) rotate('+((frame*.5)%360)+'deg)';requestAnimationFrame(tick)}requestAnimationFrame(tick);window.__probe={samples};"; + + return `
${cells}
`; +} + +async function runCase(browser, config) { + const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); + await page.setContent(makeHtml(config), { waitUntil: "load" }); + await page.waitForTimeout(WARMUP_MS); + const cdp = await page.context().newCDPSession(page); + const events = await startTrace(cdp); + const start = await page.evaluate(() => window.__probe.samples.length); + await page.waitForTimeout(SAMPLE_MS); + const dts = await page.evaluate( + (from) => window.__probe.samples.slice(from), + start, + ); + const trace = await stopTrace(cdp, events); + await page.close(); + return { + ...config, + ...summarizeFrameTimes(dts), + trace, + }; +} + +function printRows(rows) { + const header = [ + "mode", + "root", + "variant", + "fps_p50", + "fps_p95", + "p99", + "PAC/frame", + "DrawProps/frame", + "Draw/frame", + ]; + console.log(header.join("\t")); + for (const row of rows) { + console.log([ + row.mode, + row.root, + row.variant, + row.fps_p50.toFixed(1), + row.fps_p95.toFixed(1), + row.p99_ms.toFixed(1), + row.trace.pac.per_frame_ms.toFixed(3), + row.trace.drawProps.per_frame_ms.toFixed(3), + row.trace.draw.per_frame_ms.toFixed(3), + ].join("\t")); + } +} + +const configs = []; +if (MODE === "all" || MODE === "topology") { + for (const variant of ["left-top", "translate2d", "translateZ0", "translateZ", "matrix3d"]) { + configs.push({ mode: "topology", root: ROOT ?? "css", variant, leaves: LEAVES }); + } +} +if (MODE === "all" || MODE === "distribution") { + const leaves = MODE === "all" ? Math.min(LEAVES, 1200) : LEAVES; + for (const variant of ["cluster", "spread", "overlap"]) { + configs.push({ mode: "distribution", root: ROOT ?? "js", variant, leaves }); + } +} +if (MODE === "all" || MODE === "depth-groups") { + for (const variant of ["leaf-z17", "group-z17", "leaf-z50", "group-z50", "leaf-z250", "group-z250"]) { + configs.push({ mode: "depth-groups", root: ROOT ?? "css", variant, leaves: LEAVES }); + } +} +if (configs.length === 0) { + throw new Error(`Unknown --mode=${MODE}; use all, topology, distribution, or depth-groups`); +} + +const launchOptions = { headless: !HEADED, args: CHROMIUM_ARGS }; +if (EXECUTABLE) launchOptions.executablePath = EXECUTABLE; +const browser = await chromium.launch(launchOptions); +try { + const rows = []; + for (const config of configs) { + rows.push(await runCase(browser, config)); + } + if (JSON_OUT) { + console.log(JSON.stringify(rows, null, 2)); + } else { + printRows(rows); + } +} finally { + await browser.close(); +} diff --git a/bench/trace-summary.mjs b/bench/trace-summary.mjs new file mode 100644 index 00000000..072916d6 --- /dev/null +++ b/bench/trace-summary.mjs @@ -0,0 +1,276 @@ +#!/usr/bin/env node +import { readFile } from "node:fs/promises"; +import { basename } from "node:path"; + +const args = process.argv.slice(2); +const files = args.filter((arg) => !arg.startsWith("--")); +const limitArg = args.find((arg) => arg.startsWith("--limit=")); +const LIMIT = Number(limitArg?.slice("--limit=".length) ?? 10); +const jsonMode = args.includes("--json"); + +if (files.length === 0) { + console.error("usage: node bench/trace-summary.mjs [--limit=10] [--json]"); + process.exit(1); +} + +function quantile(values, q) { + const sorted = values.filter(Number.isFinite).sort((a, b) => a - b); + if (sorted.length === 0) return null; + const i = (sorted.length - 1) * q; + const lo = Math.floor(i); + const hi = Math.ceil(i); + if (lo === hi) return sorted[lo]; + return sorted[lo] + (sorted[hi] - sorted[lo]) * (i - lo); +} + +function median(values) { + return quantile(values, 0.5); +} + +function eventMap(trace) { + const out = new Map(); + for (const [name, entry] of Object.entries(trace?.eventTotals ?? {})) { + out.set(name, { + count: Number(entry?.count) || 0, + duration_ms: Number(entry?.duration_ms) || 0, + }); + } + for (const event of trace?.topEvents ?? []) { + if (out.has(event.name)) continue; + out.set(event.name, { + count: Number(event.count) || 0, + duration_ms: Number(event.duration_ms) || 0, + }); + } + return out; +} + +function eventDurationMs(events, name) { + return events.get(name)?.duration_ms ?? 0; +} + +function eventCount(events, name) { + return events.get(name)?.count ?? 0; +} + +function groupDurationMs(trace, name) { + return trace?.groups?.[name]?.duration_ms ?? 0; +} + +function groupCount(trace, name) { + return trace?.groups?.[name]?.count ?? 0; +} + +function inferFrameCount(row) { + const events = eventMap(row.trace); + const candidates = [ + eventCount(events, "ProxyMain::BeginMainFrame"), + eventCount(events, "WebFrameWidgetImpl::UpdateLifecycle"), + eventCount(events, "Layerize"), + groupCount(row.trace, "prePaint"), + groupCount(row.trace, "style"), + row.sample_count, + ].filter((value) => Number.isFinite(value) && value > 0); + return candidates.length ? Math.max(1, Math.round(median(candidates))) : 1; +} + +function leafCount(run) { + if (Number.isFinite(run?.dom?.leaves)) return run.dom.leaves; + if (Number.isFinite(run?.dom?.brushes)) return run.dom.brushes; + if (Number.isFinite(run?.dom?.voxelBrushes)) return run.dom.voxelBrushes; + const tags = run?.dom?.tags ?? run?.renderStats?.dom?.tags; + if (tags) return ["b", "i", "s", "u"].reduce((sum, tag) => sum + (Number(tags[tag]) || 0), 0); + if (Number.isFinite(run?.renderStats?.dom?.leafCount)) return run.renderStats.dom.leafCount; + return null; +} + +function nodeCount(run) { + return run?.performanceMetrics?.Nodes ?? null; +} + +function pushBenchLeaf(rows, file, path, value) { + if (!value?.trace) return; + rows.push({ + id: `${basename(file)}:${path.join(".")}`, + source: file, + path: path.join("."), + fps_p50: value.fps_p50, + fps_p95: value.fps_p95, + frame_time_p99_ms: value.frame_time_p99_ms, + sample_count: value.sample_count, + nodes: nodeCount(value), + leaves: leafCount(value), + trace: value.trace, + cadence: value.cadence, + }); +} + +function walkBenchObject(rows, file, path, value) { + if (!value || typeof value !== "object") return; + if (value.trace) { + pushBenchLeaf(rows, file, path, value); + return; + } + for (const [key, child] of Object.entries(value)) { + if (["trace", "renderStats", "performanceMetrics"].includes(key)) continue; + walkBenchObject(rows, file, [...path, key], child); + } +} + +function extractRows(file, data) { + const rows = []; + if (data?.cases && typeof data.cases === "object") { + for (const [caseId, entry] of Object.entries(data.cases)) { + for (const run of entry?.runs ?? []) { + if (!run?.trace) continue; + rows.push({ + id: `${basename(file)}:${caseId}`, + source: file, + path: caseId, + repeat: run.repeat, + fps_p50: run.fps_p50, + fps_p95: run.fps_p95, + frame_time_p99_ms: run.frame_time_p99_ms, + sample_count: run.sample_count, + nodes: nodeCount(run), + leaves: leafCount(run), + trace: run.trace, + cadence: run.cadence, + }); + } + } + return rows; + } + walkBenchObject(rows, file, [], data); + return rows; +} + +function aggregateRows(rows) { + const byId = new Map(); + for (const row of rows) { + const bucket = byId.get(row.id) ?? []; + bucket.push(row); + byId.set(row.id, bucket); + } + + return [...byId.entries()].map(([id, runs]) => { + const frameCounts = runs.map(inferFrameCount); + const frames = median(frameCounts) ?? 1; + const valuesForEvent = (name) => + runs.map((row) => eventDurationMs(eventMap(row.trace), name) / inferFrameCount(row)); + const valuesForGroup = (name) => + runs.map((row) => groupDurationMs(row.trace, name) / inferFrameCount(row)); + const topEvents = new Map(); + for (const row of runs) { + const framesForRun = inferFrameCount(row); + for (const event of row.trace?.topEvents ?? []) { + const list = topEvents.get(event.name) ?? []; + list.push((Number(event.duration_ms) || 0) / framesForRun); + topEvents.set(event.name, list); + } + } + const top = [...topEvents.entries()] + .map(([name, values]) => ({ name, ms_per_frame: median(values) ?? 0 })) + .sort((a, b) => b.ms_per_frame - a.ms_per_frame) + .slice(0, LIMIT); + + return { + id, + runs: runs.length, + fps_p50: median(runs.map((run) => run.fps_p50)), + fps_p95: median(runs.map((run) => run.fps_p95)), + p99_ms: median(runs.map((run) => run.frame_time_p99_ms)), + frames, + nodes: median(runs.map((run) => run.nodes)), + leaves: median(runs.map((run) => run.leaves)), + ms_per_frame: { + style: median(valuesForGroup("style")), + layout: median(valuesForGroup("layout")), + prePaint: median(valuesForGroup("prePaint")), + paint: median(valuesForGroup("paint")), + raster: median(valuesForGroup("raster")), + script: median(valuesForGroup("script")), + beginMainFrame: median(valuesForEvent("ProxyMain::BeginMainFrame")), + updateLifecycle: median(valuesForEvent("WebFrameWidgetImpl::UpdateLifecycle")), + paintArtifactCompositor: median(valuesForEvent("PaintArtifactCompositor::Update")), + layerize: median(valuesForEvent("Layerize")), + readyToCommit: median(valuesForEvent("ProxyImpl::ReadyToCommit")), + drawProperties: median(valuesForEvent("LayerTreeImpl::UpdateDrawProperties")), + calculateDrawProperties: median(valuesForEvent("LayerTreeImpl::UpdateDrawProperties::CalculateDrawProperties")), + prepareToDraw: median(valuesForEvent("LayerTreeHostImpl::PrepareToDraw")), + mainFrameDraw: median(valuesForEvent("MainFrame.Draw")), + commit: median(valuesForEvent("Commit")), + }, + cadence_pct: { + x1: median(runs.map((run) => run.cadence?.vsync_pct?.x1)), + x2: median(runs.map((run) => run.cadence?.vsync_pct?.x2)), + x3: median(runs.map((run) => run.cadence?.vsync_pct?.x3)), + x4_plus: median(runs.map((run) => run.cadence + ? (run.cadence?.vsync_pct?.x4 ?? 0) + + (run.cadence?.vsync_pct?.x5 ?? 0) + + (run.cadence?.vsync_pct?.x6_plus ?? 0) + : undefined, + )), + }, + topEvents: top, + }; + }); +} + +function fmt(value, digits = 2) { + return Number.isFinite(value) ? Number(value).toFixed(digits) : ""; +} + +function usPerLeaf(row, key) { + const leaves = Number(row.leaves); + const ms = Number(row.ms_per_frame[key]); + if (!Number.isFinite(leaves) || leaves <= 0 || !Number.isFinite(ms)) return null; + return (ms * 1000) / leaves; +} + +const allRows = []; +for (const file of files) { + const data = JSON.parse(await readFile(file, "utf8")); + allRows.push(...extractRows(file, data)); +} + +const summary = aggregateRows(allRows).sort((a, b) => a.id.localeCompare(b.id)); + +if (jsonMode) { + console.log(JSON.stringify(summary, null, 2)); +} else { + console.log("| Case | Runs | FPS p95 | P99 ms | 1x vsync | 2x vsync | 3x vsync | 4x+ vsync | Frames | Nodes | Leaves | PAC ms/f | PAC us/leaf | DrawProps ms/f | DrawProps us/leaf | Draw ms/f | Draw us/leaf | Paint ms/f | Raster ms/f | Script ms/f |"); + console.log("| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |"); + for (const row of summary) { + console.log([ + `| ${row.id}`, + row.runs, + fmt(row.fps_p95, 1), + fmt(row.p99_ms, 2), + fmt(row.cadence_pct.x1, 1), + fmt(row.cadence_pct.x2, 1), + fmt(row.cadence_pct.x3, 1), + fmt(row.cadence_pct.x4_plus, 1), + fmt(row.frames, 0), + fmt(row.nodes, 0), + fmt(row.leaves, 0), + fmt(row.ms_per_frame.paintArtifactCompositor), + fmt(usPerLeaf(row, "paintArtifactCompositor"), 3), + fmt(row.ms_per_frame.drawProperties), + fmt(usPerLeaf(row, "drawProperties"), 3), + fmt(row.ms_per_frame.mainFrameDraw), + fmt(usPerLeaf(row, "mainFrameDraw"), 3), + fmt(row.ms_per_frame.paint), + fmt(row.ms_per_frame.raster), + `${fmt(row.ms_per_frame.script)} |`, + ].join(" | ")); + } + + console.log("\nTop event medians are per inferred frame. Trace events are nested, so columns are diagnostic, not additive."); + for (const row of summary) { + console.log(`\n${row.id}`); + for (const event of row.topEvents) { + console.log(`- ${event.name}: ${fmt(event.ms_per_frame)} ms/f`); + } + } +} diff --git a/bench/voxel-browser-summary.mjs b/bench/voxel-browser-summary.mjs new file mode 100644 index 00000000..5e2c74ac --- /dev/null +++ b/bench/voxel-browser-summary.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; + +const resultDir = process.argv[2] ?? "bench/results"; +const prefixes = process.argv.slice(3); +const selectedPrefixes = prefixes.length + ? prefixes + : ["browser-chrome", "browser-canary", "browser-chrome-headed"]; + +function quantile(values, q) { + const sorted = values.filter(Number.isFinite).sort((a, b) => a - b); + if (sorted.length === 0) return null; + const i = (sorted.length - 1) * q; + const lo = Math.floor(i); + const hi = Math.ceil(i); + if (lo === hi) return sorted[lo]; + return sorted[lo] + (sorted[hi] - sorted[lo]) * (i - lo); +} + +function median(values) { + return quantile(values, 0.5); +} + +function fmt(value, digits = 1) { + return Number.isFinite(value) ? value.toFixed(digits) : ""; +} + +function winnerFor(deltaP95, deltaP99) { + if (Math.abs(deltaP95) >= 8) return deltaP95 > 0 ? "matrix" : "slice"; + if (Math.abs(deltaP99) >= 8) return deltaP99 > 0 ? "matrix-p99" : "slice-p99"; + return "flat"; +} + +const files = await readdir(resultDir); + +function matchesPrefix(file, prefix) { + if (!file.startsWith(`${prefix}-`) || !file.endsWith("-rotation-compare.json")) return false; + return !selectedPrefixes.some((other) => + other !== prefix && + other.startsWith(`${prefix}-`) && + file.startsWith(`${other}-`) + ); +} + +for (const prefix of selectedPrefixes) { + const rows = []; + for (const file of files) { + if (!matchesPrefix(file, prefix)) continue; + const data = JSON.parse(await readFile(join(resultDir, file), "utf8")); + const matrixRuns = data.cases?.["polycss-matrix"]?.runs ?? []; + const sliceRuns = data.cases?.["polycss-baked-voxzoom"]?.runs ?? []; + if (matrixRuns.length === 0 || sliceRuns.length === 0) continue; + const matrixP95 = median(matrixRuns.map((run) => run.fps_p95)); + const sliceP95 = median(sliceRuns.map((run) => run.fps_p95)); + const matrixP99 = median(matrixRuns.map((run) => run.frame_time_p99_ms)); + const sliceP99 = median(sliceRuns.map((run) => run.frame_time_p99_ms)); + rows.push({ + model: String(data.model ?? file).replace(/\.vox$/, ""), + runs: Math.min(matrixRuns.length, sliceRuns.length), + winner: winnerFor(matrixP95 - sliceP95, sliceP99 - matrixP99), + matrixP95, + sliceP95, + matrixP99, + sliceP99, + browser: matrixRuns[0]?.browserVersion ?? "", + }); + } + + rows.sort((a, b) => a.model.localeCompare(b.model)); + console.log(`\n## ${prefix}${rows[0]?.browser ? ` (${rows[0].browser})` : ""}\n`); + console.log("| Model | Winner | Runs | Matrix p95 | Slice p95 | Matrix p99 | Slice p99 |"); + console.log("| --- | --- | ---: | ---: | ---: | ---: | ---: |"); + for (const row of rows) { + console.log([ + `| ${row.model}`, + row.winner, + row.runs, + fmt(row.matrixP95), + fmt(row.sliceP95), + fmt(row.matrixP99), + `${fmt(row.sliceP99)} |`, + ].join(" | ")); + } +} diff --git a/bench/voxel-cadence-summary.mjs b/bench/voxel-cadence-summary.mjs new file mode 100644 index 00000000..ea6955b7 --- /dev/null +++ b/bench/voxel-cadence-summary.mjs @@ -0,0 +1,184 @@ +#!/usr/bin/env node +import { readdir, readFile, stat } from "node:fs/promises"; +import { basename, join } from "node:path"; + +const resultDir = process.argv[2] ?? "bench/results"; + +const FILE_PATTERNS = [ + [/^cadence-clean-(.*)-rotation-compare\.json$/, 1, "clean"], + [/^cadence-wide-(.*)-rotation-compare\.json$/, 2, "wide"], + [/^cadence-extended-(.*)-rotation-compare\.json$/, 3, "extended"], + [/^cadence-validate-(.*)-rotation-compare\.json$/, 4, "validated"], +]; + +function matchFile(file) { + for (const [pattern, priority, label] of FILE_PATTERNS) { + const match = pattern.exec(file); + if (match) return { key: match[1], priority, label }; + } + return null; +} + +function quantile(values, q) { + const sorted = values.filter(Number.isFinite).sort((a, b) => a - b); + if (sorted.length === 0) return null; + const i = (sorted.length - 1) * q; + const lo = Math.floor(i); + const hi = Math.ceil(i); + if (lo === hi) return sorted[lo]; + return sorted[lo] + (sorted[hi] - sorted[lo]) * (i - lo); +} + +function median(values) { + return quantile(values, 0.5); +} + +function fmt(value, digits = 1) { + return Number.isFinite(value) ? value.toFixed(digits) : ""; +} + +function cadence4Plus(run) { + const pct = run.cadence?.vsync_pct; + if (!pct) return null; + return (pct.x4 ?? 0) + (pct.x5 ?? 0) + (pct.x6_plus ?? 0); +} + +function winnerFor(deltaP95, deltaP99) { + if (Math.abs(deltaP95) >= 8) return deltaP95 > 0 ? "matrix" : "slice"; + if (Math.abs(deltaP99) >= 8) return deltaP99 > 0 ? "matrix-p99" : "slice-p99"; + return "flat"; +} + +async function loadRows() { + const selected = new Map(); + for (const file of await readdir(resultDir)) { + const match = matchFile(file); + if (!match) continue; + const path = join(resultDir, file); + const mtime = (await stat(path)).mtimeMs; + const current = selected.get(match.key); + if ( + !current || + match.priority > current.priority || + (match.priority === current.priority && mtime > current.mtime) + ) { + selected.set(match.key, { ...match, path, mtime }); + } + } + + const rows = []; + for (const entry of selected.values()) { + const data = JSON.parse(await readFile(entry.path, "utf8")); + const matrixRuns = data.cases?.["polycss-matrix"]?.runs ?? []; + const sliceRuns = data.cases?.["polycss-baked-voxzoom"]?.runs ?? []; + if (matrixRuns.length === 0 || sliceRuns.length === 0) continue; + + const slice = sliceRuns[0]; + const meta = slice.metadata ?? {}; + const brush = slice.brushMetrics ?? {}; + const matrixP95 = median(matrixRuns.map((run) => run.fps_p95)); + const sliceP95 = median(sliceRuns.map((run) => run.fps_p95)); + const matrixP50 = median(matrixRuns.map((run) => run.fps_p50)); + const sliceP50 = median(sliceRuns.map((run) => run.fps_p50)); + const matrixP99 = median(matrixRuns.map((run) => run.frame_time_p99_ms)); + const sliceP99 = median(sliceRuns.map((run) => run.frame_time_p99_ms)); + const deltaP95 = matrixP95 - sliceP95; + const deltaP99 = sliceP99 - matrixP99; + + rows.push({ + model: String(data.model ?? basename(entry.path)).replace(/\.vox$/, ""), + source: entry.label, + runs: Math.min(matrixRuns.length, sliceRuns.length), + winner: winnerFor(deltaP95, deltaP99), + matrixP50, + sliceP50, + matrixP95, + sliceP95, + matrixP99, + sliceP99, + deltaP95, + deltaP99, + matrix4Plus: median(matrixRuns.map(cadence4Plus)), + slice4Plus: median(sliceRuns.map(cadence4Plus)), + activeLeaves: slice.dom?.leaves ?? null, + matrixNodes: median(matrixRuns.map((run) => run.performanceMetrics?.Nodes)), + sliceNodes: median(sliceRuns.map((run) => run.performanceMetrics?.Nodes)), + planes: brush.depthPlaneCount ?? null, + colors: brush.colorCount ?? null, + areaK: Number.isFinite(brush.localAreaTotal) ? Math.round(brush.localAreaTotal / 1000) : null, + fill: brush.localPlaneFillRatio ?? null, + screenFill: brush.screenFillRatio ?? null, + dimensions: [meta.rows, meta.cols, meta.depth].every(Number.isFinite) + ? `${meta.rows}x${meta.cols}x${meta.depth}` + : "", + }); + } + return rows.sort((a, b) => a.model.localeCompare(b.model)); +} + +const rows = await loadRows(); +const groups = new Map(); +for (const row of rows) { + const list = groups.get(row.winner) ?? []; + list.push(row); + groups.set(row.winner, list); +} + +console.log(`# Voxel Cadence Corpus\n`); +console.log(`Models: ${rows.length}\n`); +for (const key of ["matrix", "slice", "matrix-p99", "slice-p99", "flat"]) { + const list = groups.get(key) ?? []; + console.log(`- ${key}: ${list.length}${list.length ? ` (${list.map((row) => row.model).join(", ")})` : ""}`); +} + +console.log("\n## Strong Winners\n"); +console.log("| Model | Winner | Source | Runs | Matrix p95 | Slice p95 | Matrix p99 | Slice p99 | Matrix 4x+ | Slice 4x+ | Leaves | Nodes M/S | Planes | Colors | AreaK | Fill | Screen | Dims |"); +console.log("| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | ---: | ---: | ---: | ---: | ---: | --- |"); +for (const row of rows + .filter((candidate) => candidate.winner === "matrix" || candidate.winner === "slice") + .sort((a, b) => Math.abs(b.deltaP95) - Math.abs(a.deltaP95))) { + console.log([ + `| ${row.model}`, + row.winner, + row.source, + row.runs, + fmt(row.matrixP95), + fmt(row.sliceP95), + fmt(row.matrixP99), + fmt(row.sliceP99), + fmt(row.matrix4Plus), + fmt(row.slice4Plus), + row.activeLeaves ?? "", + `${fmt(row.matrixNodes, 0)}/${fmt(row.sliceNodes, 0)}`, + row.planes ?? "", + row.colors ?? "", + row.areaK ?? "", + fmt(row.fill, 3), + fmt(row.screenFill, 3), + `${row.dimensions} |`, + ].join(" | ")); +} + +console.log("\n## P50/P99 Splits Within Flat p95\n"); +console.log("| Model | Source | Runs | Matrix p50 | Slice p50 | Matrix p95 | Slice p95 | Matrix p99 | Slice p99 | Leaves | Screen |"); +console.log("| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |"); +for (const row of rows + .filter((candidate) => + candidate.winner === "flat" && + (Math.abs(candidate.matrixP50 - candidate.sliceP50) >= 20 || Math.abs(candidate.deltaP99) >= 5) + ) + .sort((a, b) => Math.abs(b.deltaP99) - Math.abs(a.deltaP99))) { + console.log([ + `| ${row.model}`, + row.source, + row.runs, + fmt(row.matrixP50), + fmt(row.sliceP50), + fmt(row.matrixP95), + fmt(row.sliceP95), + fmt(row.matrixP99), + fmt(row.sliceP99), + row.activeLeaves ?? "", + `${fmt(row.screenFill, 3)} |`, + ].join(" | ")); +} diff --git a/bench/voxel-order-metrics.mjs b/bench/voxel-order-metrics.mjs new file mode 100644 index 00000000..1f240644 --- /dev/null +++ b/bench/voxel-order-metrics.mjs @@ -0,0 +1,711 @@ +#!/usr/bin/env node +import { readFile, readdir, writeFile } from "node:fs/promises"; +import { basename, join, resolve } from "node:path"; +import { + BASE_TILE, + normalFacesCamera, + parseVox, +} from "../packages/core/dist/index.js"; + +const repoRoot = resolve(import.meta.dirname, ".."); +const galleryVoxDir = resolve(repoRoot, "website/public/gallery/vox"); +const resultDir = resolve(repoRoot, "bench/results"); +const benchResultPrefix = process.env.BENCH_RESULT_PREFIX ?? ""; + +const FACE_NORMALS = { + t: [0, 0, 1], + b: [0, 0, -1], + fl: [0, 1, 0], + br: [0, -1, 0], + fr: [1, 0, 0], + bl: [-1, 0, 0], +}; + +const FACE_BY_NORMAL = new Map([ + ["0,0,1", "t"], + ["0,0,-1", "b"], + ["0,1,0", "fl"], + ["0,-1,0", "br"], + ["1,0,0", "fr"], + ["-1,0,0", "bl"], +]); + +const FACE_ORDER = ["t", "b", "bl", "br", "fr", "fl"]; +const FACE_ORDER_INDEX = Object.fromEntries(FACE_ORDER.map((face, index) => [face, index])); +const TOP_REVERSE_ORDER = ["t", "b", "fl", "fr", "br", "bl"]; + +function sourceBlockDepthOrder(items, rotY, blockSize, front) { + const blocks = new Map(); + for (const item of items) { + const blockIndex = Math.floor(item.sourceIndex / blockSize); + const block = blocks.get(blockIndex) ?? { blockIndex, items: [], depthSum: 0 }; + block.items.push(item); + block.depthSum += itemViewDepth(item, rotY); + blocks.set(blockIndex, block); + } + return [...blocks.values()] + .map((block) => ({ ...block, avgDepth: block.depthSum / block.items.length })) + .sort((a, b) => { + const delta = a.avgDepth - b.avgDepth; + return (front ? delta : -delta) || a.blockIndex - b.blockIndex; + }) + .flatMap((block) => block.items); +} + +const STRATEGIES = [ + { + key: "exact", + caseId: "polycss-voxlocal-direct-matrix-exact", + label: "Exact parsed", + order(items) { + return items; + }, + }, + { + key: "face", + caseId: "polycss-voxlocal-direct-matrix-face-order", + label: "Face order", + order(items) { + return [...items].sort((a, b) => + FACE_ORDER_INDEX[a.face] - FACE_ORDER_INDEX[b.face] || a.sourceIndex - b.sourceIndex + ); + }, + }, + { + key: "topRev", + caseId: "polycss-voxlocal-direct-matrix-face-order-top-reverse", + label: "Top reverse", + order(items) { + const index = Object.fromEntries(TOP_REVERSE_ORDER.map((face, i) => [face, i])); + return [...items].sort((a, b) => index[a.face] - index[b.face] || a.sourceIndex - b.sourceIndex); + }, + }, + { + key: "normalF", + caseId: "polycss-voxlocal-direct-matrix-face-normal-front", + label: "Normal front", + order(items, rotY) { + const faces = [...new Set(items.map((item) => item.face))] + .sort((a, b) => faceCameraDepth(a, rotY) - faceCameraDepth(b, rotY)); + const index = Object.fromEntries(faces.map((face, i) => [face, i])); + return [...items].sort((a, b) => index[a.face] - index[b.face] || a.sourceIndex - b.sourceIndex); + }, + }, + { + key: "normalB", + caseId: "polycss-voxlocal-direct-matrix-face-normal-back", + label: "Normal back", + order(items, rotY) { + const faces = [...new Set(items.map((item) => item.face))] + .sort((a, b) => faceCameraDepth(b, rotY) - faceCameraDepth(a, rotY)); + const index = Object.fromEntries(faces.map((face, i) => [face, i])); + return [...items].sort((a, b) => index[a.face] - index[b.face] || a.sourceIndex - b.sourceIndex); + }, + }, + { + key: "depthF", + caseId: "polycss-voxlocal-direct-matrix-depth-front", + label: "Depth front", + order(items, rotY) { + return [...items].sort((a, b) => itemViewDepth(a, rotY) - itemViewDepth(b, rotY)); + }, + }, + { + key: "depthB", + caseId: "polycss-voxlocal-direct-matrix-depth-back", + label: "Depth back", + order(items, rotY) { + return [...items].sort((a, b) => itemViewDepth(b, rotY) - itemViewDepth(a, rotY)); + }, + }, + ...[32, 64, 128, 256].flatMap((blockSize) => [ + { + key: `block${blockSize}F`, + caseId: `polycss-voxlocal-direct-matrix-source-block${blockSize}-depth-front`, + label: `Block ${blockSize} front`, + order(items, rotY) { + return sourceBlockDepthOrder(items, rotY, blockSize, true); + }, + }, + { + key: `block${blockSize}B`, + caseId: `polycss-voxlocal-direct-matrix-source-block${blockSize}-depth-back`, + label: `Block ${blockSize} back`, + order(items, rotY) { + return sourceBlockDepthOrder(items, rotY, blockSize, false); + }, + }, + ]), +]; + +const HARD_SPLIT_MODELS = [ + "obj_house3.vox", + "obj_house5.vox", + "desert2.vox", + "house.vox", + "scene_mechanic2.vox", + "Treasure.vox", + "army.vox", + "AncientCrashSite.vox", + "skyscraper.vox", +]; + +const args = parseArgs(process.argv.slice(2)); +const angleStep = Number(args.angleStep ?? process.env.ANGLE_STEP ?? 15); +const targetSize = Number(args.targetSize ?? process.env.POLY_TARGET_SIZE ?? 70); +const models = args.models.length ? args.models : HARD_SPLIT_MODELS; +const selectedStrategies = args.strategies.length + ? STRATEGIES.filter((strategy) => args.strategies.includes(strategy.key)) + : STRATEGIES; + +function parseArgs(argv) { + const out = { + models: [], + strategies: [], + angleStep: null, + targetSize: null, + }; + for (const arg of argv) { + if (arg.startsWith("--angle-step=")) { + out.angleStep = arg.slice("--angle-step=".length); + } else if (arg.startsWith("--target-size=")) { + out.targetSize = arg.slice("--target-size=".length); + } else if (arg.startsWith("--strategies=")) { + out.strategies = arg.slice("--strategies=".length).split(",").map((value) => value.trim()).filter(Boolean); + } else if (arg.startsWith("--models=")) { + out.models.push(...arg.slice("--models=".length).split(",").map((value) => value.trim()).filter(Boolean)); + } else if (!arg.startsWith("--")) { + out.models.push(arg); + } + } + return out; +} + +function canonicalModelKey(file) { + return basename(file, ".vox").replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").toLowerCase(); +} + +function benchmarkResultSlug(file) { + return basename(file).replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").toLowerCase(); +} + +function quantile(values, q) { + const sorted = values.filter(Number.isFinite).sort((a, b) => a - b); + if (sorted.length === 0) return null; + const i = (sorted.length - 1) * q; + const lo = Math.floor(i); + const hi = Math.ceil(i); + if (lo === hi) return sorted[lo]; + return sorted[lo] + (sorted[hi] - sorted[lo]) * (i - lo); +} + +function median(values) { + return quantile(values, 0.5); +} + +function fmt(value, digits = 1) { + return Number.isFinite(value) ? value.toFixed(digits) : ""; +} + +function cssNormalForPolygon(polygon) { + const vertices = polygon.vertices; + if (vertices.length < 3) return null; + const v0 = vertices[0]; + let nx = 0; + let ny = 0; + let nz = 0; + for (let i = 1; i + 1 < vertices.length; i += 1) { + const v1 = vertices[i]; + const v2 = vertices[i + 1]; + const e1x = v1[1] - v0[1]; + const e1y = v1[0] - v0[0]; + const e1z = v1[2] - v0[2]; + const e2x = v2[1] - v0[1]; + const e2y = v2[0] - v0[0]; + const e2z = v2[2] - v0[2]; + nx -= e1y * e2z - e1z * e2y; + ny -= e1z * e2x - e1x * e2z; + nz -= e1x * e2y - e1y * e2x; + } + const len = Math.hypot(nx, ny, nz); + if (len <= 1e-9) return null; + return [ + Math.round(nx / len), + Math.round(ny / len), + Math.round(nz / len), + ]; +} + +function exactMatrixItemForPolygon(polygon, sourceIndex) { + if (polygon.texture || polygon.material || polygon.uvs || polygon.textureTriangles) return null; + if (polygon.vertices.length !== 4) return null; + const normal = cssNormalForPolygon(polygon); + const face = normal ? FACE_BY_NORMAL.get(normal.join(",")) : undefined; + if (!face) return null; + + let minX = Infinity; + let minY = Infinity; + let minZ = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + let maxZ = -Infinity; + for (const v of polygon.vertices) { + minX = Math.min(minX, v[0]); + minY = Math.min(minY, v[1]); + minZ = Math.min(minZ, v[2]); + maxX = Math.max(maxX, v[0]); + maxY = Math.max(maxY, v[1]); + maxZ = Math.max(maxZ, v[2]); + } + + const eps = 1e-6; + const base = { face, sourceIndex }; + if (Math.abs(maxZ - minZ) <= eps) { + return { + ...base, + axis: "z", + left: minY * BASE_TILE, + top: minX * BASE_TILE, + width: Math.max(0, (maxY - minY) * BASE_TILE), + height: Math.max(0, (maxX - minX) * BASE_TILE), + z: minZ * BASE_TILE, + }; + } + if (Math.abs(maxX - minX) <= eps) { + return { + ...base, + axis: "x", + left: minY * BASE_TILE, + top: minZ * BASE_TILE, + width: Math.max(0, (maxY - minY) * BASE_TILE), + height: Math.max(0, (maxZ - minZ) * BASE_TILE), + z: -minX * BASE_TILE, + }; + } + if (Math.abs(maxY - minY) <= eps) { + return { + ...base, + axis: "y", + left: minZ * BASE_TILE, + top: minX * BASE_TILE, + width: Math.max(0, (maxZ - minZ) * BASE_TILE), + height: Math.max(0, (maxX - minX) * BASE_TILE), + z: -minY * BASE_TILE, + }; + } + return null; +} + +function itemCenter(item) { + if (item.axis === "x") return [item.left + item.width / 2, -item.z, item.top + item.height / 2]; + if (item.axis === "y") return [-item.z, item.top + item.height / 2, item.left + item.width / 2]; + return [item.left + item.width / 2, item.top + item.height / 2, item.z]; +} + +function itemCorners(item) { + if (item.axis === "x") { + return [ + [item.left, -item.z, item.top], + [item.left + item.width, -item.z, item.top], + [item.left + item.width, -item.z, item.top + item.height], + [item.left, -item.z, item.top + item.height], + ]; + } + if (item.axis === "y") { + return [ + [-item.z, item.top, item.left], + [-item.z, item.top, item.left + item.width], + [-item.z, item.top + item.height, item.left + item.width], + [-item.z, item.top + item.height, item.left], + ]; + } + return [ + [item.left, item.top, item.z], + [item.left + item.width, item.top, item.z], + [item.left + item.width, item.top + item.height, item.z], + [item.left, item.top + item.height, item.z], + ]; +} + +function projectPoint(point, rotY) { + const rz = (rotY * Math.PI) / 180; + const rx = (65 * Math.PI) / 180; + const cosZ = Math.cos(rz); + const sinZ = Math.sin(rz); + const cosX = Math.cos(rx); + const sinX = Math.sin(rx); + const [x, y, z] = point; + const x1 = x * cosZ - y * sinZ; + const y1 = x * sinZ + y * cosZ; + return { + x: x1, + y: y1 * cosX - z * sinX, + z: y1 * sinX + z * cosX, + }; +} + +function itemViewDepth(item, rotY) { + return projectPoint(itemCenter(item), rotY).z; +} + +function faceCameraDepth(face, rotY) { + const normal = FACE_NORMALS[face]; + return projectPoint(normal, rotY).z; +} + +function polygonArea2(points) { + let sum = 0; + for (let i = 0; i < points.length; i += 1) { + const a = points[i]; + const b = points[(i + 1) % points.length]; + sum += a.x * b.y - b.x * a.y; + } + return Math.abs(sum) / 2; +} + +function planeFromProjected(points) { + const [a, b, c] = points; + const ux = b.x - a.x; + const uy = b.y - a.y; + const uz = b.z - a.z; + const vx = c.x - a.x; + const vy = c.y - a.y; + const vz = c.z - a.z; + const nx = uy * vz - uz * vy; + const ny = uz * vx - ux * vz; + const nz = ux * vy - uy * vx; + if (Math.abs(nz) <= 1e-9) return null; + return { + a: -nx / nz, + b: -ny / nz, + c: (nx * a.x + ny * a.y + nz * a.z) / nz, + }; +} + +function planeZ(plane, x, y, fallback) { + return plane ? plane.a * x + plane.b * y + plane.c : fallback; +} + +function projectedItem(item, order, rotY) { + const points = itemCorners(item).map((point) => projectPoint(point, rotY)); + const minX = Math.min(...points.map((point) => point.x)); + const maxX = Math.max(...points.map((point) => point.x)); + const minY = Math.min(...points.map((point) => point.y)); + const maxY = Math.max(...points.map((point) => point.y)); + const depth = points.reduce((sum, point) => sum + point.z, 0) / points.length; + return { + item, + order, + points, + minX, + maxX, + minY, + maxY, + depth, + area2d: polygonArea2(points), + plane: planeFromProjected(points), + }; +} + +class DisjointSet { + constructor(size) { + this.parent = Array.from({ length: size }, (_, index) => index); + this.size = Array.from({ length: size }, () => 1); + } + + find(index) { + let root = index; + while (this.parent[root] !== root) root = this.parent[root]; + while (this.parent[index] !== index) { + const parent = this.parent[index]; + this.parent[index] = root; + index = parent; + } + return root; + } + + union(a, b) { + let rootA = this.find(a); + let rootB = this.find(b); + if (rootA === rootB) return; + if (this.size[rootA] < this.size[rootB]) [rootA, rootB] = [rootB, rootA]; + this.parent[rootB] = rootA; + this.size[rootA] += this.size[rootB]; + } +} + +function analyzeProjected(projected) { + const sorted = projected + .map((item, index) => ({ ...item, index })) + .sort((a, b) => a.minX - b.minX); + const dsu = new DisjointSet(projected.length); + const active = []; + let overlapPairs = 0; + let overlapArea = 0; + let invAsc = 0; + let invDesc = 0; + let depthTies = 0; + let crossings = 0; + + for (const current of sorted) { + for (let i = active.length - 1; i >= 0; i -= 1) { + if (active[i].maxX <= current.minX) active.splice(i, 1); + } + for (const other of active) { + if (other.maxY <= current.minY || other.minY >= current.maxY) continue; + const x0 = Math.max(current.minX, other.minX); + const x1 = Math.min(current.maxX, other.maxX); + const y0 = Math.max(current.minY, other.minY); + const y1 = Math.min(current.maxY, other.maxY); + if (x1 <= x0 || y1 <= y0) continue; + overlapPairs += 1; + overlapArea += (x1 - x0) * (y1 - y0); + dsu.union(current.index, other.index); + + const orderDelta = current.order - other.order; + const depthDelta = current.depth - other.depth; + if (Math.abs(depthDelta) <= 0.5) { + depthTies += 1; + } else { + if ((orderDelta < 0) !== (depthDelta < 0)) invAsc += 1; + if ((orderDelta < 0) !== (depthDelta > 0)) invDesc += 1; + } + + const samples = [ + [(x0 + x1) / 2, (y0 + y1) / 2], + [x0, y0], + [x1, y0], + [x1, y1], + [x0, y1], + ]; + let sawPositive = false; + let sawNegative = false; + for (const [x, y] of samples) { + const dz = planeZ(current.plane, x, y, current.depth) - planeZ(other.plane, x, y, other.depth); + if (dz > 0.5) sawPositive = true; + else if (dz < -0.5) sawNegative = true; + } + if (sawPositive && sawNegative) crossings += 1; + } + active.push(current); + } + + let largestComponent = 0; + const componentSizes = new Map(); + for (let i = 0; i < projected.length; i += 1) { + const root = dsu.find(i); + const size = (componentSizes.get(root) ?? 0) + 1; + componentSizes.set(root, size); + largestComponent = Math.max(largestComponent, size); + } + + let faceSwitches = 0; + let depthJump = 0; + let sourceJump = 0; + const orderSorted = [...projected].sort((a, b) => a.order - b.order); + for (let i = 1; i < orderSorted.length; i += 1) { + const prev = orderSorted[i - 1]; + const next = orderSorted[i]; + if (prev.item.face !== next.item.face) faceSwitches += 1; + depthJump += Math.abs(prev.depth - next.depth); + sourceJump += Math.abs(prev.item.sourceIndex - next.item.sourceIndex); + } + + return { + activeLeaves: projected.length, + overlapPairs, + overlapArea, + invAsc, + invDesc, + depthTies, + crossings, + componentCount: componentSizes.size, + largestComponent, + faceSwitches, + depthJumpMean: orderSorted.length > 1 ? depthJump / (orderSorted.length - 1) : 0, + sourceJumpMean: orderSorted.length > 1 ? sourceJump / (orderSorted.length - 1) : 0, + }; +} + +function mergeMetricRows(rows) { + const total = { + samples: rows.length, + activeLeaves: 0, + overlapPairs: 0, + overlapArea: 0, + invAsc: 0, + invDesc: 0, + depthTies: 0, + crossings: 0, + largestComponentMax: 0, + largestComponentMedian: median(rows.map((row) => row.largestComponent)), + componentCountMedian: median(rows.map((row) => row.componentCount)), + faceSwitchesMedian: median(rows.map((row) => row.faceSwitches)), + depthJumpMeanMedian: median(rows.map((row) => row.depthJumpMean)), + sourceJumpMeanMedian: median(rows.map((row) => row.sourceJumpMean)), + }; + for (const row of rows) { + total.activeLeaves += row.activeLeaves; + total.overlapPairs += row.overlapPairs; + total.overlapArea += row.overlapArea; + total.invAsc += row.invAsc; + total.invDesc += row.invDesc; + total.depthTies += row.depthTies; + total.crossings += row.crossings; + total.largestComponentMax = Math.max(total.largestComponentMax, row.largestComponent); + } + total.activeLeavesMedian = median(rows.map((row) => row.activeLeaves)); + total.overlapPairsPerLeaf = total.activeLeaves ? total.overlapPairs / total.activeLeaves : 0; + total.invAscRate = total.overlapPairs ? total.invAsc / total.overlapPairs : 0; + total.invDescRate = total.overlapPairs ? total.invDesc / total.overlapPairs : 0; + total.tieRate = total.overlapPairs ? total.depthTies / total.overlapPairs : 0; + total.crossingRate = total.overlapPairs ? total.crossings / total.overlapPairs : 0; + total.minDepthInvRate = Math.min(total.invAscRate, total.invDescRate); + return total; +} + +function visibleItems(items, rotY) { + const visibleFaces = new Set( + Object.entries(FACE_NORMALS) + .filter(([, normal]) => normalFacesCamera(normal, { rotX: 65, rotY }, 0.001)) + .map(([face]) => face) + ); + return items.filter((item) => visibleFaces.has(item.face)); +} + +async function findModelPath(model) { + if (model.includes("/") || model.includes("\\")) return resolve(repoRoot, model); + const direct = resolve(galleryVoxDir, model); + try { + await readFile(direct); + return direct; + } catch { + const files = await readdir(galleryVoxDir); + const needle = model.toLowerCase().replace(/\.vox$/, ""); + const match = files.find((file) => file.toLowerCase().replace(/\.vox$/, "") === needle); + if (!match) throw new Error(`Could not find model ${model}`); + return join(galleryVoxDir, match); + } +} + +async function loadBenchResult(modelFile) { + const slug = benchmarkResultSlug(modelFile); + const path = join(resultDir, `${benchResultPrefix}${slug}-rotation-compare.json`); + try { + const data = JSON.parse(await readFile(path, "utf8")); + const out = {}; + for (const strategy of STRATEGIES) { + const runs = data.cases?.[strategy.caseId]?.runs ?? []; + out[strategy.key] = runs.length + ? { + runs: runs.length, + fpsP95: median(runs.map((run) => run.fps_p95)), + frameP99: median(runs.map((run) => run.frame_time_p99_ms)), + } + : null; + } + return out; + } catch { + return {}; + } +} + +async function analyzeModel(model) { + const path = await findModelPath(model); + const bytes = await readFile(path); + const parsed = parseVox( + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + { targetSize, gridShift: 0 }, + ); + const items = parsed.polygons + .map((polygon, sourceIndex) => exactMatrixItemForPolygon(polygon, sourceIndex)) + .filter((item) => item && item.width > 0 && item.height > 0); + const angles = []; + for (let offset = 0; offset < 360; offset += angleStep) angles.push(45 + offset); + const bench = await loadBenchResult(path); + const strategyRows = []; + for (const strategy of selectedStrategies) { + const angleRows = []; + for (const rotY of angles) { + const currentVisible = visibleItems(items, rotY); + const ordered = strategy.order(currentVisible, rotY); + const projected = ordered.map((item, index) => projectedItem(item, index, rotY)); + angleRows.push({ rotY, ...analyzeProjected(projected) }); + } + strategyRows.push({ + key: strategy.key, + label: strategy.label, + caseId: strategy.caseId, + bench: bench[strategy.key] ?? null, + metrics: mergeMetricRows(angleRows), + angles: angleRows, + }); + } + return { + model: basename(path), + key: canonicalModelKey(path), + targetSize, + angleStep, + polygons: parsed.polygons.length, + exactItems: items.length, + strategies: strategyRows, + }; +} + +function renderMarkdown(rows) { + const lines = []; + lines.push("# Voxel Order Metrics"); + lines.push(""); + lines.push(`Target size: ${targetSize}`); + lines.push(`Angle step: ${angleStep}`); + lines.push(""); + for (const row of rows) { + lines.push(`## ${row.model}`); + lines.push(""); + lines.push(`Polygons: ${row.polygons}; exact matrix items: ${row.exactItems}`); + lines.push(""); + lines.push("| Strategy | Runs | FPS p95 | Frame p99 | Active | Overlap/leaf | Min inv % | Asc inv % | Desc inv % | Cross % | Tie % | Max comp | Face switches | Depth jump |"); + lines.push("| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |"); + for (const strategy of row.strategies) { + const m = strategy.metrics; + const b = strategy.bench; + lines.push([ + `| ${strategy.label}`, + b?.runs ?? "", + fmt(b?.fpsP95), + fmt(b?.frameP99), + fmt(m.activeLeavesMedian, 0), + fmt(m.overlapPairsPerLeaf, 2), + fmt(m.minDepthInvRate * 100, 2), + fmt(m.invAscRate * 100, 2), + fmt(m.invDescRate * 100, 2), + fmt(m.crossingRate * 100, 2), + fmt(m.tieRate * 100, 2), + fmt(m.largestComponentMax, 0), + fmt(m.faceSwitchesMedian, 0), + `${fmt(m.depthJumpMeanMedian, 1)} |`, + ].join(" | ")); + } + lines.push(""); + } + return `${lines.join("\n")}\n`; +} + +const rows = []; +for (const model of models) { + console.log(`[voxel-order-metrics] analyzing ${model}`); + rows.push(await analyzeModel(model)); +} + +const jsonPath = join(resultDir, "voxel-order-metrics.json"); +const mdPath = join(resultDir, "voxel-order-metrics.md"); +await writeFile(jsonPath, JSON.stringify({ + generatedAt: new Date().toISOString(), + targetSize, + angleStep, + models: rows, +}, null, 2)); +await writeFile(mdPath, renderMarkdown(rows)); + +console.log(`[voxel-order-metrics] wrote ${jsonPath}`); +console.log(`[voxel-order-metrics] wrote ${mdPath}`); diff --git a/bench/voxel-progress-dashboard.html b/bench/voxel-progress-dashboard.html new file mode 100644 index 00000000..74ad8a3e --- /dev/null +++ b/bench/voxel-progress-dashboard.html @@ -0,0 +1,449 @@ + + + + + + Voxel Renderer Progress + + + +
+
+
+

Voxel renderer progress

+
+
+ generated 2026-05-19T17:24:51.414Z
+ source /Users/ekrof/fed/polycss/bench/results +
+
+ +
+
9models with iterated results
+
Baseline / A32 / A33 / A34iterations
+
82.1A34 average best p95 (-3.0 vs A33)
+
3/9models above Baseline
+
+ +
+

Progress

+ +
+ + best p95 FPS by benchmark iteration + +25 + +49 + +73 + +96 + +120 + +Baseline + +A32 + +A33 + +A34 + + +AncientCrashSite.vox Baseline: 39.9 p95 +AncientCrashSite.vox A32: 39.8 p95 +AncientCrashSite.vox A33: 40.0 p95 +AncientCrashSite.vox A34: 39.8 p95 + +army.vox Baseline: 49.3 p95 +army.vox A32: 40.2 p95 +army.vox A33: 58.8 p95 +army.vox A34: 42.1 p95 + +desert2.vox Baseline: 114.3 p95 +desert2.vox A32: 114.3 p95 +desert2.vox A33: 117.6 p95 +desert2.vox A34: 113.6 p95 + +house.vox Baseline: 112.3 p95 +house.vox A32: 108.1 p95 +house.vox A33: 112.4 p95 +house.vox A34: 114.7 p95 + +obj_house3.vox Baseline: 109.9 p95 +obj_house3.vox A32: 59.6 p95 +obj_house3.vox A33: 114.9 p95 +obj_house3.vox A34: 113.6 p95 + +obj_house5.vox Baseline: 113.6 p95 +obj_house5.vox A32: 111.9 p95 +obj_house5.vox A33: 115.6 p95 +obj_house5.vox A34: 113.4 p95 + +scene_mechanic2.vox Baseline: 111.7 p95 +scene_mechanic2.vox A32: 111.1 p95 +scene_mechanic2.vox A33: 117.0 p95 +scene_mechanic2.vox A34: 113.5 p95 + +skyscraper.vox Baseline: 34.5 p95 +skyscraper.vox A32: 30.0 p95 +skyscraper.vox A33: 30.0 p95 +skyscraper.vox A34: 29.9 p95 + +Treasure.vox Baseline: 58.7 p95 +Treasure.vox A32: 58.4 p95 +Treasure.vox A33: 59.7 p95 +Treasure.vox A34: 58.5 p95 + thick line = corpus average + +
AncientCrashSite.voxarmy.voxdesert2.voxhouse.voxobj_house3.voxobj_house5.voxscene_mechanic2.voxskyscraper.voxTreasure.vox
+
+ +
+ +
+

Best p95 heatmap

+ + + + +
ModelBaselineA32A33A34Delta
AncientCrashSite.vox39.939.840.039.8-0.1
army.vox49.340.258.842.1-7.2
desert2.vox114.3114.3117.6113.6-0.7
house.vox112.3108.1112.4114.7+2.5
obj_house3.vox109.959.6114.9113.6+3.7
obj_house5.vox113.6111.9115.6113.4-0.2
scene_mechanic2.vox111.7111.1117.0113.5+1.8
skyscraper.vox34.530.030.029.9-4.5
Treasure.vox58.758.459.758.5-0.3
+ +
+ +
+

A34 strategy heatmap

+ +
+ + + +
Modelscreen:tile4-scanline-forwardvoxlocal
AncientCrashSite.vox39.839.8
army.vox42.139.8
desert2.vox113.659.5
house.vox114.759.9
obj_house3.vox113.659.9
obj_house5.vox113.459.9
scene_mechanic2.vox113.540.0
skyscraper.vox29.923.7
Treasure.vox58.530.5
+
+ +
+ +
+

Model table

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ModelBaselineA33A34Delta baseDelta prevLatest best caseNodesLeavesCases
AncientCrashSite.vox39.940.039.8-0.1-0.2screen:tile4-scanline-forward52672
army.vox49.358.842.1-7.2-16.8screen:tile4-scanline-forward38932
desert2.vox114.3117.6113.6-0.7-4.0screen:tile4-scanline-forward20112
house.vox112.3112.4114.7+2.5+2.4screen:tile4-scanline-forward21322
obj_house3.vox109.9114.9113.6+3.7-1.3screen:tile4-scanline-forward10962
obj_house5.vox113.6115.6113.4-0.2-2.2screen:tile4-scanline-forward25222
scene_mechanic2.vox111.7117.0113.5+1.8-3.5screen:tile4-scanline-forward22432
skyscraper.vox34.530.029.9-4.5-0.0screen:tile4-scanline-forward57932
Treasure.vox58.759.758.5-0.3-1.2screen:tile4-scanline-forward40372
+ +
+
+ + diff --git a/bench/voxel-progress-dashboard.mjs b/bench/voxel-progress-dashboard.mjs new file mode 100644 index 00000000..e1b72a14 --- /dev/null +++ b/bench/voxel-progress-dashboard.mjs @@ -0,0 +1,688 @@ +#!/usr/bin/env node +import { existsSync } from "node:fs"; +import { readdir, readFile, stat, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const resultDir = process.argv[2] ?? join(repoRoot, "bench/results"); +const outputPath = process.argv[3] ?? join(repoRoot, "bench/voxel-progress-dashboard.html"); + +const RESULT_FILE = /^(?:(a\d+)-)?(.+)-rotation-compare\.json$/i; +const BASELINE_ITERATION = "a31"; +const PALETTE = [ + "#2563eb", + "#dc2626", + "#059669", + "#7c3aed", + "#ea580c", + "#0891b2", + "#be123c", + "#4d7c0f", + "#9333ea", + "#0f766e", + "#b45309", + "#1d4ed8", +]; + +function esc(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function fmt(value, digits = 1) { + return Number.isFinite(value) ? value.toFixed(digits) : ""; +} + +function maybeNumber(value) { + return Number.isFinite(value) ? value : null; +} + +function quantile(values, q) { + const sorted = values.filter(Number.isFinite).sort((a, b) => a - b); + if (sorted.length === 0) return null; + const i = (sorted.length - 1) * q; + const lo = Math.floor(i); + const hi = Math.ceil(i); + if (lo === hi) return sorted[lo]; + return sorted[lo] + (sorted[hi] - sorted[lo]) * (i - lo); +} + +function median(values) { + return quantile(values, 0.5); +} + +function iterationRank(id) { + const match = /^a(\d+)$/i.exec(id); + return match ? Number(match[1]) : Number.MAX_SAFE_INTEGER; +} + +function iterationLabel(id) { + if (id === BASELINE_ITERATION) return "Baseline"; + return id.toUpperCase(); +} + +function strategyLabel(id) { + return id + .replace(/^polycss-voxlocal-/, "") + .replace(/^polycss-/, "") + .replace(/^direct-matrix-screen-/, "screen:") + .replace(/^direct-matrix-source-/, "source:") + .replace(/^direct-matrix-/, "") + .replaceAll("screen-tile", "screen:tile") + .replaceAll("source-block", "source:block"); +} + +function fileIteration(prefix) { + return prefix ? prefix.toLowerCase() : BASELINE_ITERATION; +} + +function cellColor(value, domain) { + if (!Number.isFinite(value)) { + return "background: #f3f4f6; color: #9ca3af;"; + } + const [min, max] = domain; + const t = max > min ? Math.max(0, Math.min(1, (value - min) / (max - min))) : 0.5; + const hue = 8 + t * 132; + const light = 94 - t * 12; + return `background: hsl(${hue.toFixed(1)} 68% ${light.toFixed(1)}%); color: #111827;`; +} + +function deltaClass(delta) { + if (!Number.isFinite(delta)) return ""; + if (delta > 0.25) return "good"; + if (delta < -0.25) return "bad"; + return "flat"; +} + +function summarizeCase(id, entry) { + const aggregate = entry.aggregate ?? {}; + const runs = Array.isArray(entry.runs) ? entry.runs : []; + return { + id, + label: strategyLabel(id), + p50: maybeNumber(aggregate.fps_p50_median), + p95: maybeNumber(aggregate.fps_p95_median), + p99FrameMs: maybeNumber(aggregate.frame_time_p99_ms_median), + nodes: maybeNumber(aggregate.nodes_median), + leaves: median(runs.map((run) => run.dom?.leaves ?? run.domSamples?.at?.(0)?.leaves)), + runs: runs.length, + }; +} + +function summarizeResult(data, file, mtime) { + const cases = Object.entries(data.cases ?? {}) + .map(([id, entry]) => summarizeCase(id, entry)) + .filter((entry) => Number.isFinite(entry.p95)); + + cases.sort((a, b) => { + const delta = b.p95 - a.p95; + return delta !== 0 ? delta : a.label.localeCompare(b.label); + }); + + return { + file, + mtime, + model: data.model ?? file.replace(/-rotation-compare\.json$/i, ""), + viewport: data.viewport ?? null, + caseCount: cases.length, + best: cases[0] ?? null, + cases, + }; +} + +async function discoverResults() { + if (!existsSync(resultDir)) { + throw new Error(`Result directory not found: ${resultDir}`); + } + + const candidates = []; + for (const file of await readdir(resultDir)) { + const match = RESULT_FILE.exec(file); + if (!match) continue; + candidates.push({ + prefix: match[1]?.toLowerCase() ?? null, + slug: match[2], + file, + path: join(resultDir, file), + mtime: (await stat(join(resultDir, file))).mtimeMs, + }); + } + + const iteratedSlugs = new Set(candidates.filter((entry) => entry.prefix).map((entry) => entry.slug)); + const selected = new Map(); + for (const entry of candidates) { + if (!entry.prefix && !iteratedSlugs.has(entry.slug)) continue; + const iteration = fileIteration(entry.prefix); + const key = `${entry.slug}:${iteration}`; + const current = selected.get(key); + if (!current || entry.mtime > current.mtime) selected.set(key, { ...entry, iteration }); + } + + const models = new Map(); + const iterations = new Map(); + for (const entry of selected.values()) { + const data = JSON.parse(await readFile(entry.path, "utf8")); + const summary = summarizeResult(data, entry.file, entry.mtime); + if (!summary.best) continue; + + iterations.set(entry.iteration, { + id: entry.iteration, + label: iterationLabel(entry.iteration), + rank: iterationRank(entry.iteration), + }); + + const model = models.get(entry.slug) ?? { + slug: entry.slug, + name: String(summary.model), + results: new Map(), + }; + model.name = String(summary.model); + model.results.set(entry.iteration, summary); + models.set(entry.slug, model); + } + + const sortedIterations = [...iterations.values()].sort((a, b) => a.rank - b.rank || a.id.localeCompare(b.id)); + const sortedModels = [...models.values()] + .filter((model) => sortedIterations.some((iteration) => model.results.has(iteration.id))) + .sort((a, b) => a.name.localeCompare(b.name)); + + return { iterations: sortedIterations, models: sortedModels }; +} + +function valueDomain(models, iterations, pickValue) { + const values = []; + for (const model of models) { + for (const iteration of iterations) { + const value = pickValue(model, iteration); + if (Number.isFinite(value)) values.push(value); + } + } + if (values.length === 0) return [0, 1]; + const min = Math.floor(Math.min(...values) / 5) * 5; + const max = Math.ceil(Math.max(...values) / 5) * 5; + return min === max ? [min - 1, max + 1] : [min, max]; +} + +function renderLineChart(models, iterations) { + const width = 1080; + const height = 360; + const pad = { top: 26, right: 28, bottom: 70, left: 54 }; + const plotWidth = width - pad.left - pad.right; + const plotHeight = height - pad.top - pad.bottom; + const domain = valueDomain(models, iterations, (model, iteration) => model.results.get(iteration.id)?.best?.p95); + const [minY, maxY] = domain; + const xFor = (index) => pad.left + (iterations.length <= 1 ? plotWidth / 2 : (index / (iterations.length - 1)) * plotWidth); + const yFor = (value) => pad.top + ((maxY - value) / (maxY - minY)) * plotHeight; + const lines = []; + + for (let i = 0; i <= 4; i += 1) { + const value = minY + ((maxY - minY) * i) / 4; + const y = yFor(value); + lines.push(``); + lines.push(`${fmt(value, 0)}`); + } + + iterations.forEach((iteration, index) => { + const x = xFor(index); + lines.push(``); + lines.push(`${esc(iteration.label)}`); + }); + + const averages = iterations.map((iteration) => { + const values = models + .map((model) => model.results.get(iteration.id)?.best?.p95) + .filter(Number.isFinite); + return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null; + }); + const avgPoints = averages + .map((value, index) => (Number.isFinite(value) ? `${xFor(index)},${yFor(value)}` : null)) + .filter(Boolean) + .join(" "); + if (avgPoints) lines.push(``); + + models.forEach((model, modelIndex) => { + const color = PALETTE[modelIndex % PALETTE.length]; + const points = iterations + .map((iteration, index) => { + const value = model.results.get(iteration.id)?.best?.p95; + return Number.isFinite(value) ? `${xFor(index)},${yFor(value)}` : null; + }) + .filter(Boolean) + .join(" "); + if (points.includes(" ")) { + lines.push(``); + } + + iterations.forEach((iteration, index) => { + const value = model.results.get(iteration.id)?.best?.p95; + if (!Number.isFinite(value)) return; + lines.push( + `${esc( + `${model.name} ${iteration.label}: ${fmt(value)} p95`, + )}`, + ); + }); + }); + + const legend = models + .map((model, index) => { + const color = PALETTE[index % PALETTE.length]; + return `${esc(model.name)}`; + }) + .join(""); + + return ` +
+ + best p95 FPS by benchmark iteration + ${lines.join("\n")} + thick line = corpus average + +
${legend}
+
+ `; +} + +function renderProgressHeatmap(models, iterations) { + const domain = valueDomain(models, iterations, (model, iteration) => model.results.get(iteration.id)?.best?.p95); + const header = iterations.map((iteration) => `${esc(iteration.label)}`).join(""); + const rows = models + .map((model) => { + const cells = iterations + .map((iteration) => { + const best = model.results.get(iteration.id)?.best; + const title = best ? `${best.label}, ${best.runs} runs` : "missing"; + return `${fmt(best?.p95)}`; + }) + .join(""); + const first = model.results.get(iterations[0]?.id)?.best?.p95; + const latest = model.results.get(iterations.at(-1)?.id)?.best?.p95; + const delta = Number.isFinite(first) && Number.isFinite(latest) ? latest - first : null; + return `${esc(model.name)}${cells}${Number.isFinite(delta) ? `${delta >= 0 ? "+" : ""}${fmt(delta)}` : ""}`; + }) + .join(""); + + return ` + + ${header} + ${rows} +
ModelDelta
+ `; +} + +function latestStrategies(models, latestIteration) { + const byLabel = new Map(); + for (const model of models) { + for (const resultCase of model.results.get(latestIteration.id)?.cases ?? []) { + byLabel.set(resultCase.label, resultCase); + } + } + return [...byLabel.keys()].sort((a, b) => a.localeCompare(b)); +} + +function renderStrategyHeatmap(models, latestIteration) { + if (!latestIteration) return ""; + const strategies = latestStrategies(models, latestIteration); + if (strategies.length === 0) return ""; + + const values = []; + for (const model of models) { + for (const resultCase of model.results.get(latestIteration.id)?.cases ?? []) { + if (Number.isFinite(resultCase.p95)) values.push(resultCase.p95); + } + } + const domain = values.length + ? [Math.floor(Math.min(...values) / 5) * 5, Math.ceil(Math.max(...values) / 5) * 5] + : [0, 1]; + const header = strategies.map((label) => `${esc(label)}`).join(""); + const rows = models + .map((model) => { + const byStrategy = new Map((model.results.get(latestIteration.id)?.cases ?? []).map((entry) => [entry.label, entry])); + const cells = strategies + .map((strategy) => { + const entry = byStrategy.get(strategy); + const title = entry ? `${entry.id}, ${entry.runs} runs` : "missing"; + return `${fmt(entry?.p95)}`; + }) + .join(""); + return `${esc(model.name)}${cells}`; + }) + .join(""); + + return ` +
+ + ${header} + ${rows} +
Model
+
+ `; +} + +function renderModelTable(models, iterations, latestIteration) { + const previousIteration = iterations.at(-2) ?? null; + const baselineIteration = iterations[0] ?? null; + const rows = models + .map((model) => { + const baseline = baselineIteration ? model.results.get(baselineIteration.id)?.best : null; + const previous = previousIteration ? model.results.get(previousIteration.id)?.best : null; + const latest = latestIteration ? model.results.get(latestIteration.id)?.best : null; + const deltaBaseline = + Number.isFinite(baseline?.p95) && Number.isFinite(latest?.p95) ? latest.p95 - baseline.p95 : null; + const deltaPrevious = + Number.isFinite(previous?.p95) && Number.isFinite(latest?.p95) ? latest.p95 - previous.p95 : null; + return ` + + ${esc(model.name)} + ${fmt(baseline?.p95)} + ${fmt(previous?.p95)} + ${fmt(latest?.p95)} + ${Number.isFinite(deltaBaseline) ? `${deltaBaseline >= 0 ? "+" : ""}${fmt(deltaBaseline)}` : ""} + ${Number.isFinite(deltaPrevious) ? `${deltaPrevious >= 0 ? "+" : ""}${fmt(deltaPrevious)}` : ""} + ${esc(latest?.label ?? "")} + ${fmt(latest?.nodes, 0)} + ${fmt(latest?.leaves, 0)} + ${latest?.caseCount ?? model.results.get(latestIteration?.id)?.caseCount ?? ""} + + `; + }) + .join(""); + + return ` + + + + + + + + + + + + + + + + ${rows} +
Model${esc(baselineIteration?.label ?? "Base")}${esc(previousIteration?.label ?? "Prev")}${esc(latestIteration?.label ?? "Latest")}Delta baseDelta prevLatest best caseNodesLeavesCases
+ `; +} + +function averageLatest(models, latestIteration) { + const values = models + .map((model) => model.results.get(latestIteration?.id)?.best?.p95) + .filter(Number.isFinite); + return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null; +} + +function improvedCount(models, baselineIteration, latestIteration) { + let improved = 0; + let comparable = 0; + for (const model of models) { + const baseline = model.results.get(baselineIteration?.id)?.best?.p95; + const latest = model.results.get(latestIteration?.id)?.best?.p95; + if (!Number.isFinite(baseline) || !Number.isFinite(latest)) continue; + comparable += 1; + if (latest > baseline + 0.25) improved += 1; + } + return { improved, comparable }; +} + +function renderDashboard(data) { + const { models, iterations } = data; + const latestIteration = iterations.at(-1) ?? null; + const baselineIteration = iterations[0] ?? null; + const previousIteration = iterations.at(-2) ?? null; + const latestAverage = averageLatest(models, latestIteration); + const previousAverage = averageLatest(models, previousIteration); + const averageDelta = + Number.isFinite(latestAverage) && Number.isFinite(previousAverage) ? latestAverage - previousAverage : null; + const improved = improvedCount(models, baselineIteration, latestIteration); + + return ` + + + + + Voxel Renderer Progress + + + +
+
+
+

Voxel renderer progress

+
+
+ generated ${esc(data.generatedAt)}
+ source ${esc(resultDir)} +
+
+ +
+
${esc(models.length)}models with iterated results
+
${esc(iterations.map((iteration) => iteration.label).join(" / "))}iterations
+
${fmt(latestAverage)}${esc(latestIteration?.label ?? "latest")} average best p95${Number.isFinite(averageDelta) ? ` (${averageDelta >= 0 ? "+" : ""}${fmt(averageDelta)} vs ${esc(previousIteration?.label ?? "previous")})` : ""}
+
${esc(`${improved.improved}/${improved.comparable}`)}models above ${esc(baselineIteration?.label ?? "baseline")}
+
+ +
+

Progress

+ ${renderLineChart(models, iterations)} +
+ +
+

Best p95 heatmap

+ ${renderProgressHeatmap(models, iterations)} +
+ +
+

${esc(latestIteration?.label ?? "Latest")} strategy heatmap

+ ${renderStrategyHeatmap(models, latestIteration)} +
+ +
+

Model table

+ ${renderModelTable(models, iterations, latestIteration)} +
+
+ + +`; +} + +const data = await discoverResults(); +const html = renderDashboard({ + ...data, + generatedAt: new Date().toISOString(), +}); + +await writeFile(outputPath, html, "utf8"); +console.log(`wrote ${outputPath}`); diff --git a/bench/voxel-static-metrics.mjs b/bench/voxel-static-metrics.mjs new file mode 100644 index 00000000..df89f380 --- /dev/null +++ b/bench/voxel-static-metrics.mjs @@ -0,0 +1,607 @@ +#!/usr/bin/env node +import { readdir, readFile, stat } from "node:fs/promises"; +import { basename, join, resolve } from "node:path"; +import { + BASE_TILE, + buildPolyVoxelFaceData, + buildPolyVoxelSlicePlan, + normalFacesCamera, + parsePureColor, + parseVox, + POLY_VOXEL_NEXT_LAYER_STEP, +} from "../packages/core/dist/index.js"; + +const repoRoot = resolve(import.meta.dirname, ".."); +const voxDir = process.argv[2] ?? resolve(repoRoot, "website/public/gallery/vox"); +const resultDir = process.argv[3] ?? resolve(repoRoot, "bench/results"); + +const ROTATION = { rotX: 65, rotY: 45 }; +const DIRECTIONAL_LIGHT = { direction: dirFromAzEl(50, 45), color: "#ffffff", intensity: 1 }; +const AMBIENT_LIGHT = { color: "#ffffff", intensity: 0.4 }; + +const FACE_NORMALS = { + t: [0, 0, 1], + b: [0, 0, -1], + fl: [0, 1, 0], + br: [0, -1, 0], + fr: [1, 0, 0], + bl: [-1, 0, 0], +}; + +const FACE_BY_NORMAL = new Map([ + ["0,0,1", "t"], + ["0,0,-1", "b"], + ["0,1,0", "fl"], + ["0,-1,0", "br"], + ["1,0,0", "fr"], + ["-1,0,0", "bl"], +]); + +const FILE_PATTERNS = [ + [/^cadence-clean-(.*)-rotation-compare\.json$/, 1, "clean"], + [/^cadence-wide-(.*)-rotation-compare\.json$/, 2, "wide"], + [/^cadence-extended-(.*)-rotation-compare\.json$/, 3, "extended"], + [/^cadence-validate-(.*)-rotation-compare\.json$/, 4, "validated"], +]; + +function dirFromAzEl(azDeg, elDeg) { + const az = (azDeg * Math.PI) / 180; + const el = (elDeg * Math.PI) / 180; + const cosEl = Math.cos(el); + return [cosEl * Math.sin(az), cosEl * Math.cos(az), Math.sin(el)]; +} + +function quantile(values, q) { + const sorted = values.filter(Number.isFinite).sort((a, b) => a - b); + if (sorted.length === 0) return null; + const i = (sorted.length - 1) * q; + const lo = Math.floor(i); + const hi = Math.ceil(i); + if (lo === hi) return sorted[lo]; + return sorted[lo] + (sorted[hi] - sorted[lo]) * (i - lo); +} + +function median(values) { + return quantile(values, 0.5); +} + +function fmt(value, digits = 1) { + return Number.isFinite(value) ? value.toFixed(digits) : ""; +} + +function canonicalModelKey(file) { + return basename(file, ".vox").replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").toLowerCase(); +} + +function resultModelKey(model) { + return String(model ?? "").replace(/\.vox$/, "").replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").toLowerCase(); +} + +function winnerFor(deltaP95, deltaP99) { + if (Math.abs(deltaP95) >= 8) return deltaP95 > 0 ? "matrix" : "slice"; + if (Math.abs(deltaP99) >= 8) return deltaP99 > 0 ? "matrix-p99" : "slice-p99"; + return "flat"; +} + +function parseColor(input) { + const parsed = parsePureColor(input); + if (!parsed) return { r: 255, g: 255, b: 255, alpha: 1 }; + return { r: parsed.rgb[0], g: parsed.rgb[1], b: parsed.rgb[2], alpha: parsed.alpha }; +} + +function rgbToHex({ r, g, b }) { + const f = (n) => Math.round(Math.max(0, Math.min(255, n))).toString(16).padStart(2, "0"); + return `#${f(r)}${f(g)}${f(b)}`; +} + +function clampChannel(value) { + return Math.round(Math.max(0, Math.min(255, value))); +} + +function shadeBrushColor(normal, baseColor) { + const base = parseColor(baseColor); + const light = parseColor(DIRECTIONAL_LIGHT.color); + const ambient = parseColor(AMBIENT_LIGHT.color); + const lightDir = DIRECTIONAL_LIGHT.direction; + const lightLen = Math.hypot(lightDir[0], lightDir[1], lightDir[2]) || 1; + const lx = lightDir[0] / lightLen; + const ly = lightDir[1] / lightLen; + const lz = lightDir[2] / lightLen; + const directScale = Math.max(0, DIRECTIONAL_LIGHT.intensity) * + Math.max(0, normal[0] * lx + normal[1] * ly + normal[2] * lz); + const tintR = (ambient.r / 255) * AMBIENT_LIGHT.intensity + (light.r / 255) * directScale; + const tintG = (ambient.g / 255) * AMBIENT_LIGHT.intensity + (light.g / 255) * directScale; + const tintB = (ambient.b / 255) * AMBIENT_LIGHT.intensity + (light.b / 255) * directScale; + const shaded = { + r: base.r * tintR, + g: base.g * tintG, + b: base.b * tintB, + alpha: base.alpha, + }; + return shaded.alpha < 1 + ? `rgba(${clampChannel(shaded.r)}, ${clampChannel(shaded.g)}, ${clampChannel(shaded.b)}, ${shaded.alpha})` + : rgbToHex(shaded); +} + +function cssNormalForPolygon(polygon) { + const vertices = polygon.vertices; + if (vertices.length < 3) return null; + const v0 = vertices[0]; + let nx = 0; + let ny = 0; + let nz = 0; + for (let i = 1; i + 1 < vertices.length; i += 1) { + const v1 = vertices[i]; + const v2 = vertices[i + 1]; + const e1x = v1[1] - v0[1]; + const e1y = v1[0] - v0[0]; + const e1z = v1[2] - v0[2]; + const e2x = v2[1] - v0[1]; + const e2y = v2[0] - v0[0]; + const e2z = v2[2] - v0[2]; + nx -= e1y * e2z - e1z * e2y; + ny -= e1z * e2x - e1x * e2z; + nz -= e1x * e2y - e1y * e2x; + } + const len = Math.hypot(nx, ny, nz); + if (len <= 1e-9) return null; + return [ + Math.round(nx / len), + Math.round(ny / len), + Math.round(nz / len), + ]; +} + +function polygonBrush(polygon) { + if (polygon.texture || polygon.material || polygon.uvs || polygon.textureTriangles) return null; + if (polygon.vertices.length !== 4) return null; + const normal = cssNormalForPolygon(polygon); + const face = normal ? FACE_BY_NORMAL.get(normal.join(",")) : undefined; + if (!face) return null; + + let minX = Infinity; + let minY = Infinity; + let minZ = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + let maxZ = -Infinity; + for (const v of polygon.vertices) { + minX = Math.min(minX, v[0]); + minY = Math.min(minY, v[1]); + minZ = Math.min(minZ, v[2]); + maxX = Math.max(maxX, v[0]); + maxY = Math.max(maxY, v[1]); + maxZ = Math.max(maxZ, v[2]); + } + + const eps = 1e-6; + const baseColor = polygon.color || "#cccccc"; + if (Math.abs(maxZ - minZ) <= eps) { + return { + axis: "z", + face, + left: minY * BASE_TILE, + top: minX * BASE_TILE, + width: Math.max(0, (maxY - minY) * BASE_TILE), + height: Math.max(0, (maxX - minX) * BASE_TILE), + z: minZ * BASE_TILE, + baseColor, + }; + } + if (Math.abs(maxX - minX) <= eps) { + return { + axis: "x", + face, + left: minY * BASE_TILE, + top: minZ * BASE_TILE, + width: Math.max(0, (maxY - minY) * BASE_TILE), + height: Math.max(0, (maxZ - minZ) * BASE_TILE), + z: -minX * BASE_TILE, + baseColor, + }; + } + if (Math.abs(maxY - minY) <= eps) { + return { + axis: "y", + face, + left: minZ * BASE_TILE, + top: minX * BASE_TILE, + width: Math.max(0, (maxZ - minZ) * BASE_TILE), + height: Math.max(0, (maxX - minX) * BASE_TILE), + z: -minY * BASE_TILE, + baseColor, + }; + } + return null; +} + +function buildPolygonPlans(polygons) { + const plans = new Map(); + let accepted = 0; + for (const polygon of polygons) { + const brush = polygonBrush(polygon); + if (!brush || brush.width <= 0 || brush.height <= 0) continue; + accepted += 1; + const key = `${brush.axis}:${brush.face}`; + let plan = plans.get(key); + if (!plan) { + plan = { axis: brush.axis, face: brush.face, brushes: [] }; + plans.set(key, plan); + } + plan.brushes.push(brush); + } + return accepted === polygons.length ? Array.from(plans.values()) : []; +} + +function planBrushZ(plan, cellPx) { + const plane = plan.key.plane * cellPx; + return plan.key.axis === "z" ? plane : -plane; +} + +function buildMergedPlans(source, cellPx) { + const faces = buildPolyVoxelFaceData(source); + const faceIndex = new Map(); + for (const face of faces) { + faceIndex.set(`${face.key.axis}:${face.key.plane}:${face.key.face}`, face); + } + return faces.map((face) => { + const nextPlane = face.key.plane + POLY_VOXEL_NEXT_LAYER_STEP[face.key.face]; + const nextFace = faceIndex.get(`${face.key.axis}:${nextPlane}:${face.key.face}`); + const plan = buildPolyVoxelSlicePlan(face, nextFace?.buffer ?? null); + const z = planBrushZ(plan, cellPx); + return { + axis: plan.key.axis, + face: plan.key.face, + brushes: plan.brushes.map((brush) => ({ + left: (plan.buffer.minCol + brush.c0) * cellPx, + top: (plan.buffer.minRow + brush.r0) * cellPx, + width: (brush.c1 - brush.c0) * cellPx, + height: (brush.r1 - brush.r0) * cellPx, + z, + baseColor: brush.baseColor, + })), + }; + }); +} + +function rectUnionArea(rects) { + if (!rects.length) return 0; + const xs = [...new Set(rects.flatMap((rect) => [rect.left, rect.right]))].sort((a, b) => a - b); + let area = 0; + for (let i = 0; i < xs.length - 1; i += 1) { + const x0 = xs[i]; + const x1 = xs[i + 1]; + if (x1 <= x0) continue; + const intervals = []; + for (const rect of rects) { + if (rect.left < x1 && rect.right > x0) intervals.push([rect.top, rect.bottom]); + } + intervals.sort((a, b) => a[0] - b[0]); + let covered = 0; + let start = -Infinity; + let end = -Infinity; + for (const interval of intervals) { + if (interval[0] > end) { + if (Number.isFinite(end)) covered += end - start; + start = interval[0]; + end = interval[1]; + } else { + end = Math.max(end, interval[1]); + } + } + if (Number.isFinite(end)) covered += end - start; + area += (x1 - x0) * covered; + } + return area; +} + +function brushMetrics(plans) { + const visibleFaces = new Set( + Object.entries(FACE_NORMALS) + .filter(([, normal]) => normalFacesCamera(normal, ROTATION)) + .map(([face]) => face) + ); + const activeRects = []; + const allRects = []; + const visibleBaseColors = new Set(); + const visibleShadedColors = new Set(); + const allBaseColors = new Set(); + const visiblePlanes = new Set(); + const allPlanes = new Set(); + const faceCounts = {}; + for (const plan of plans) { + const normal = FACE_NORMALS[plan.face]; + const visible = visibleFaces.has(plan.face); + for (const brush of plan.brushes) { + const rect = { + axis: plan.axis, + plane: Number(brush.z.toFixed(3)), + left: brush.left, + top: brush.top, + right: brush.left + brush.width, + bottom: brush.top + brush.height, + width: brush.width, + height: brush.height, + area: brush.width * brush.height, + }; + allRects.push(rect); + allBaseColors.add(brush.baseColor); + allPlanes.add(`${plan.axis}:${rect.plane}`); + faceCounts[plan.face] = (faceCounts[plan.face] ?? 0) + 1; + if (!visible) continue; + activeRects.push(rect); + visibleBaseColors.add(brush.baseColor); + visibleShadedColors.add(shadeBrushColor(normal, brush.baseColor)); + visiblePlanes.add(`${plan.axis}:${rect.plane}`); + } + } + + const activeArea = activeRects.reduce((sum, rect) => sum + rect.area, 0); + const byPlane = new Map(); + for (const rect of activeRects) { + const key = `${rect.axis}:${rect.plane}`; + const list = byPlane.get(key) ?? []; + list.push(rect); + byPlane.set(key, list); + } + let unionArea = 0; + let planeBoundsArea = 0; + for (const list of byPlane.values()) { + unionArea += rectUnionArea(list); + const minX = Math.min(...list.map((rect) => rect.left)); + const minY = Math.min(...list.map((rect) => rect.top)); + const maxX = Math.max(...list.map((rect) => rect.right)); + const maxY = Math.max(...list.map((rect) => rect.bottom)); + planeBoundsArea += Math.max(0, maxX - minX) * Math.max(0, maxY - minY); + } + + return { + totalBrushes: allRects.length, + activeBrushes: activeRects.length, + allBaseColors: allBaseColors.size, + visibleBaseColors: visibleBaseColors.size, + visibleShadedColors: visibleShadedColors.size, + allPlanes: allPlanes.size, + visiblePlanes: visiblePlanes.size, + activeArea, + activeAreaK: Math.round(activeArea / 1000), + activeUnionArea: Math.round(unionArea), + activePlaneFillRatio: planeBoundsArea ? +(activeArea / planeBoundsArea).toFixed(3) : null, + faceCounts, + }; +} + +async function loadCadenceRows() { + const selected = new Map(); + for (const file of await readdir(resultDir)) { + let matchInfo = null; + for (const [pattern, priority, label] of FILE_PATTERNS) { + const match = pattern.exec(file); + if (match) { + matchInfo = { key: match[1], priority, label }; + break; + } + } + if (!matchInfo) continue; + const path = join(resultDir, file); + const mtime = (await stat(path)).mtimeMs; + const current = selected.get(matchInfo.key); + if (!current || matchInfo.priority > current.priority || (matchInfo.priority === current.priority && mtime > current.mtime)) { + selected.set(matchInfo.key, { ...matchInfo, path, mtime }); + } + } + + const rows = new Map(); + for (const entry of selected.values()) { + const data = JSON.parse(await readFile(entry.path, "utf8")); + const matrixRuns = data.cases?.["polycss-matrix"]?.runs ?? []; + const sliceRuns = data.cases?.["polycss-baked-voxzoom"]?.runs ?? []; + if (matrixRuns.length === 0 || sliceRuns.length === 0) continue; + const matrixP95 = median(matrixRuns.map((run) => run.fps_p95)); + const sliceP95 = median(sliceRuns.map((run) => run.fps_p95)); + const matrixP99 = median(matrixRuns.map((run) => run.frame_time_p99_ms)); + const sliceP99 = median(sliceRuns.map((run) => run.frame_time_p99_ms)); + const deltaP95 = matrixP95 - sliceP95; + const deltaP99 = sliceP99 - matrixP99; + const model = resultModelKey(data.model ?? entry.key); + rows.set(model, { + source: entry.label, + runs: Math.min(matrixRuns.length, sliceRuns.length), + winner: winnerFor(deltaP95, deltaP99), + matrixP95, + sliceP95, + matrixP99, + sliceP99, + deltaP95, + deltaP99, + }); + } + return rows; +} + +async function analyzeVoxFile(path) { + const bytes = await readFile(path); + const parsed = parseVox( + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + { targetSize: 70, gridShift: 0 }, + ); + const source = parsed.voxelSource; + const sourceColors = new Set((source?.cells ?? []).map((cell) => cell.color)); + const cellPx = source ? Math.max(1, Math.round(source.scale * BASE_TILE)) : BASE_TILE; + const polygonPlans = buildPolygonPlans(parsed.polygons); + const plans = polygonPlans.length > 0 ? polygonPlans : (source ? buildMergedPlans(source, cellPx) : []); + const metrics = brushMetrics(plans); + return { + file: basename(path), + key: canonicalModelKey(path), + rows: source?.rows ?? null, + cols: source?.cols ?? null, + depth: source?.depth ?? null, + maxDim: source ? Math.max(source.rows, source.cols, source.depth) : null, + cellPx, + sourceBytes: source?.sourceBytes ?? bytes.byteLength, + voxels: source?.cells.length ?? parsed.metadata?.voxelCount ?? null, + polygons: parsed.polygons.length, + sourceColors: sourceColors.size, + planner: polygonPlans.length > 0 ? "polygons" : "source", + ...metrics, + }; +} + +const cadenceRows = await loadCadenceRows(); +const files = (await readdir(voxDir)) + .filter((file) => file.toLowerCase().endsWith(".vox")) + .sort((a, b) => a.localeCompare(b)); + +const staticRows = []; +for (const file of files) { + staticRows.push(await analyzeVoxFile(join(voxDir, file))); +} + +const joined = staticRows.map((row) => ({ ...row, cadence: cadenceRows.get(row.key) ?? null })); +const tested = joined.filter((row) => row.cadence); +const strong = tested.filter((row) => ["matrix", "slice"].includes(row.cadence.winner)); +const untested = joined.filter((row) => !row.cadence); + +console.log("# Voxel Static Metrics\n"); +console.log(`Models scanned: ${joined.length}`); +console.log(`Models with cadence: ${tested.length}`); +console.log(`Strong cadence winners: ${strong.length}\n`); + +console.log("## Strong Winners Joined To Static Metrics\n"); +console.log("| Model | Winner | Runs | Matrix p95 | Slice p95 | Active | Total | Source colors | Visible base | Visible shaded | Planes | AreaK | Fill | Dims |"); +console.log("| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |"); +for (const row of strong.sort((a, b) => Math.abs(b.cadence.deltaP95) - Math.abs(a.cadence.deltaP95))) { + console.log([ + `| ${basename(row.file, ".vox")}`, + row.cadence.winner, + row.cadence.runs, + fmt(row.cadence.matrixP95), + fmt(row.cadence.sliceP95), + row.activeBrushes, + row.totalBrushes, + row.sourceColors, + row.visibleBaseColors, + row.visibleShadedColors, + row.visiblePlanes, + row.activeAreaK, + fmt(row.activePlaneFillRatio, 3), + `${row.rows}x${row.cols}x${row.depth} |`, + ].join(" | ")); +} + +const highShadedCutoff = 52; +const highShaded = tested.filter((row) => row.visibleShadedColors >= highShadedCutoff); +console.log(`\n## Diagnostic Rule: visibleShadedColors >= ${highShadedCutoff}\n`); +const highShadedGroups = new Map(); +for (const row of highShaded) { + const key = row.cadence.winner; + highShadedGroups.set(key, (highShadedGroups.get(key) ?? 0) + 1); +} +const strongMatrixRows = tested.filter((row) => row.cadence.winner === "matrix"); +const strongSliceRows = tested.filter((row) => row.cadence.winner === "slice"); +const capturedMatrixRows = strongMatrixRows.filter((row) => row.visibleShadedColors >= highShadedCutoff); +const hitSliceRows = strongSliceRows.filter((row) => row.visibleShadedColors >= highShadedCutoff); +const adaptiveRows = tested.map((row) => { + const useMatrix = row.visibleShadedColors >= highShadedCutoff; + return { + row, + useMatrix, + p95DeltaVsSlice: useMatrix ? row.cadence.matrixP95 - row.cadence.sliceP95 : 0, + p99DeltaVsSlice: useMatrix ? row.cadence.sliceP99 - row.cadence.matrixP99 : 0, + }; +}); +const p95Gains = adaptiveRows.filter((item) => item.p95DeltaVsSlice >= 8); +const p95Losses = adaptiveRows.filter((item) => item.p95DeltaVsSlice <= -8); +const p99Gains = adaptiveRows.filter((item) => item.p99DeltaVsSlice >= 5); +const p99Losses = adaptiveRows.filter((item) => item.p99DeltaVsSlice <= -5); +function scoreRule(predicate) { + const rows = tested.map((row) => { + const useMatrix = predicate(row); + return { + row, + useMatrix, + p95DeltaVsSlice: useMatrix ? row.cadence.matrixP95 - row.cadence.sliceP95 : 0, + p99DeltaVsSlice: useMatrix ? row.cadence.sliceP99 - row.cadence.matrixP99 : 0, + }; + }); + return { + hits: rows.filter((item) => item.useMatrix), + p95Gains: rows.filter((item) => item.p95DeltaVsSlice >= 8), + p95Losses: rows.filter((item) => item.p95DeltaVsSlice <= -8), + p99Gains: rows.filter((item) => item.p99DeltaVsSlice >= 5), + p99Losses: rows.filter((item) => item.p99DeltaVsSlice <= -5), + }; +} +const refinedScore = scoreRule((row) => + row.visibleShadedColors >= highShadedCutoff && row.visiblePlanes < 200 +); +console.log(`- Hits ${highShaded.length} tested models: ${ + [...highShadedGroups.entries()].map(([key, count]) => `${key}=${count}`).join(", ") +}`); +console.log(`- Captures ${capturedMatrixRows.length}/${strongMatrixRows.length} strong matrix wins: ${ + capturedMatrixRows.map((row) => basename(row.file, ".vox")).join(", ") || "none" +}`); +console.log(`- Hits ${hitSliceRows.length}/${strongSliceRows.length} strong slice wins: ${ + hitSliceRows.map((row) => basename(row.file, ".vox")).join(", ") || "none" +}`); +console.log(`- Adaptive p95 gains/losses vs always-slice: +${p95Gains.length}/-${p95Losses.length}`); +console.log(`- Adaptive p99 gains/losses vs always-slice: +${p99Gains.length}/-${p99Losses.length}${ + p99Losses.length ? ` (${p99Losses.map((item) => basename(item.row.file, ".vox")).join(", ")})` : "" +}`); +console.log(`- Refined visibleShadedColors >= ${highShadedCutoff} && visiblePlanes < 200: ` + + `hits=${refinedScore.hits.length}, p95 +${refinedScore.p95Gains.length}/-${refinedScore.p95Losses.length}, ` + + `p99 +${refinedScore.p99Gains.length}/-${refinedScore.p99Losses.length}\n`); +console.log("| Model | Winner | Runs | Visible shaded | Delta p95 | Matrix p95 | Slice p95 |"); +console.log("| --- | --- | ---: | ---: | ---: | ---: | ---: |"); +for (const row of highShaded.sort((a, b) => b.visibleShadedColors - a.visibleShadedColors)) { + console.log([ + `| ${basename(row.file, ".vox")}`, + row.cadence.winner, + row.cadence.runs, + row.visibleShadedColors, + fmt(row.cadence.deltaP95), + fmt(row.cadence.matrixP95), + fmt(row.cadence.sliceP95), + "|", + ].join(" | ")); +} + +const suggested = [ + ...untested + .filter((row) => row.visibleShadedColors >= highShadedCutoff) + .sort((a, b) => b.visibleShadedColors - a.visibleShadedColors || b.activeBrushes - a.activeBrushes) + .slice(0, 8), + ...untested + .filter((row) => row.visibleShadedColors < highShadedCutoff) + .sort((a, b) => b.activeBrushes - a.activeBrushes) + .slice(0, 8), + ...untested + .filter((row) => row.visibleShadedColors < highShadedCutoff) + .sort((a, b) => b.activePlaneFillRatio - a.activePlaneFillRatio) + .slice(0, 8), +]; +const dedupedSuggested = Array.from(new Map(suggested.map((row) => [row.key, row])).values()); + +console.log("\n## Suggested Bench Additions\n"); +console.log("| Model | Reason | Active | Total | Source colors | Visible shaded | Planes | AreaK | Fill | Dims |"); +console.log("| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |"); +for (const row of dedupedSuggested.slice(0, 20)) { + const reason = row.visibleShadedColors >= highShadedCutoff + ? "high shaded colors" + : row.activePlaneFillRatio >= 0.18 + ? "high plane fill" + : "high active brushes"; + console.log([ + `| ${basename(row.file, ".vox")}`, + reason, + row.activeBrushes, + row.totalBrushes, + row.sourceColors, + row.visibleShadedColors, + row.visiblePlanes, + row.activeAreaK, + fmt(row.activePlaneFillRatio, 3), + `${row.rows}x${row.cols}x${row.depth} |`, + ].join(" | ")); +} diff --git a/packages/core/README.md b/packages/core/README.md index 5a97a666..39114150 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -40,6 +40,7 @@ npm install @layoutit/polycss-core | `CameraState` | Camera target, angles, zoom, and dolly distance | | `CameraHandle` | Mutable camera object from `createIsometricCamera` | | `AutoRotateOption` | `boolean | number | { axis, speed, pauseOnInteraction }` | +| `BoxPolygonsOptions` | Options for `boxPolygons`: size/center or min/max bounds, materials, face overrides | ### Functions @@ -50,10 +51,11 @@ npm install @layoutit/polycss-core | `optimizeMeshPolygons(polygons, options?)` | Applies lossless or lossy mesh-resolution optimization and chooses the smallest accepted candidate; defaults to `meshResolution: "lossy"`. | | `computeSceneBbox(polygons)` | Computes min/max bounds across all polygon vertices. | | `createIsometricCamera(initial?)` | Creates a mutable camera handle with `state`, `update(partial)`, and `getStyle()`. | +| `boxPolygons(options?)` | Creates six quad `Polygon`s for an axis-aligned box/cuboid. Supports per-face material/data overrides and omitted faces. | | `parseObj(text, options?)` | Parses OBJ text into `ParseResult`. Supports UV (`vt`), materials, `map_Kd` textures. | | `parseMtl(text)` | Parses MTL text into `{ colors, textures }`. | | `parseGltf(buffer, options?)` | Parses GLB or glTF `ArrayBuffer` into `ParseResult`. Extracts embedded textures as blob URLs. | -| `parseVox(buffer, options?)` | Parses MagicaVoxel `.vox` `ArrayBuffer` into `ParseResult`. Face-culls interior voxel faces and fan-triangulates exposed quads. `targetSize` snaps to integer voxel CSS cells for the slice-brush renderer. | +| `parseVox(buffer, options?)` | Parses MagicaVoxel `.vox` `ArrayBuffer` into `ParseResult`. Face-culls interior voxel faces and emits exposed quads. `targetSize` snaps to integer voxel CSS cells for the fast-path renderer. | | `loadMesh(url, options?)` | Fetches a URL, dispatches to the right parser by extension (`.obj`, `.glb`, `.gltf`, `.vox`). Returns `Promise` and defaults to `meshResolution: "lossy"`. | | `parseColor(input)` | Parse any CSS color string to `{ r, g, b, a }`. | | `shadeColor(input, lambert, ...)` | Apply Lambert shading factor to a color. | @@ -111,3 +113,23 @@ console.log(`${polygons.length} triangles → ${merged.length} merged polygons`) dispose(); // always revoke GLB blob URLs when done ``` + +### Create a box shape + +```ts +import { boxPolygons } from "@layoutit/polycss-core"; + +const polygons = boxPolygons({ + min: [0, 0, 0], + max: [2, 1, 0.5], + color: "#d8d2c7", + data: { tileId: "tile-1" }, + faces: { + top: { + texture: "/tile.png", + data: { face: "top" }, + }, + bottom: false, + }, +}); +``` diff --git a/packages/core/src/helpers/boxPolygons.test.ts b/packages/core/src/helpers/boxPolygons.test.ts new file mode 100644 index 00000000..e179fd7e --- /dev/null +++ b/packages/core/src/helpers/boxPolygons.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; +import type { Polygon, Vec3 } from "../types"; +import { boxPolygons } from "./boxPolygons"; + +function bounds(polygons: Polygon[]): { min: Vec3; max: Vec3 } { + const min: Vec3 = [Infinity, Infinity, Infinity]; + const max: Vec3 = [-Infinity, -Infinity, -Infinity]; + for (const polygon of polygons) { + for (const vertex of polygon.vertices) { + for (let axis = 0; axis < 3; axis += 1) { + min[axis] = Math.min(min[axis], vertex[axis]); + max[axis] = Math.max(max[axis], vertex[axis]); + } + } + } + return { min, max }; +} + +function normal(polygon: Polygon): Vec3 { + const [a, b, c] = polygon.vertices; + const ux = b[0] - a[0]; + const uy = b[1] - a[1]; + const uz = b[2] - a[2]; + const vx = c[0] - a[0]; + const vy = c[1] - a[1]; + const vz = c[2] - a[2]; + const nx = uy * vz - uz * vy; + const ny = uz * vx - ux * vz; + const nz = ux * vy - uy * vx; + const len = Math.hypot(nx, ny, nz) || 1; + return [ + Math.round(nx / len), + Math.round(ny / len), + Math.round(nz / len), + ]; +} + +function hasNormal(polygons: Polygon[], expected: Vec3): boolean { + return polygons.some((polygon) => { + const n = normal(polygon); + return n[0] === expected[0] && n[1] === expected[1] && n[2] === expected[2]; + }); +} + +describe("boxPolygons", () => { + it("returns six white quads for the default unit box", () => { + const polygons = boxPolygons(); + expect(polygons).toHaveLength(6); + for (const polygon of polygons) { + expect(polygon.vertices).toHaveLength(4); + expect(polygon.color).toBe("#ffffff"); + expect(polygon.data).toBeUndefined(); + } + expect(bounds(polygons)).toEqual({ + min: [-0.5, -0.5, -0.5], + max: [0.5, 0.5, 0.5], + }); + }); + + it("supports size and center", () => { + const polygons = boxPolygons({ + size: [2, 4, 6], + center: [10, 20, 30], + }); + expect(bounds(polygons)).toEqual({ + min: [9, 18, 27], + max: [11, 22, 33], + }); + }); + + it("uses explicit min/max bounds and normalizes reversed axes", () => { + const polygons = boxPolygons({ + min: [3, 8, 1], + max: [-1, 2, 5], + }); + expect(bounds(polygons)).toEqual({ + min: [-1, 2, 1], + max: [3, 8, 5], + }); + }); + + it("winds all faces outward", () => { + const polygons = boxPolygons(); + expect(hasNormal(polygons, [1, 0, 0])).toBe(true); + expect(hasNormal(polygons, [-1, 0, 0])).toBe(true); + expect(hasNormal(polygons, [0, 1, 0])).toBe(true); + expect(hasNormal(polygons, [0, -1, 0])).toBe(true); + expect(hasNormal(polygons, [0, 0, 1])).toBe(true); + expect(hasNormal(polygons, [0, 0, -1])).toBe(true); + }); + + it("omits faces set to false", () => { + const polygons = boxPolygons({ + faces: { bottom: false }, + }); + expect(polygons).toHaveLength(5); + expect(hasNormal(polygons, [0, 0, -1])).toBe(false); + }); + + it("merges top-level and per-face material data", () => { + const polygons = boxPolygons({ + color: "#999999", + data: { tileId: "tile-1", kind: "tile" }, + faces: { + top: { + color: "#ff0000", + texture: "/tile.png", + data: { face: "top" }, + }, + }, + }); + const top = polygons.find((polygon) => { + const n = normal(polygon); + return n[0] === 0 && n[1] === 0 && n[2] === 1; + }); + expect(top).toBeDefined(); + expect(top?.color).toBe("#ff0000"); + expect(top?.texture).toBe("/tile.png"); + expect(top?.data).toEqual({ + tileId: "tile-1", + kind: "tile", + face: "top", + }); + }); + + it("returns no polygons for degenerate boxes", () => { + expect(boxPolygons({ size: [1, 0, 1] })).toHaveLength(0); + }); +}); diff --git a/packages/core/src/helpers/boxPolygons.ts b/packages/core/src/helpers/boxPolygons.ts new file mode 100644 index 00000000..58464773 --- /dev/null +++ b/packages/core/src/helpers/boxPolygons.ts @@ -0,0 +1,164 @@ +/** + * Axis-aligned box/cuboid geometry as six quad polygons. + * + * Returned polygons are in standard polycss world space: + * +X = right, +Y = front/forward, +Z = top/up. + */ +import type { Polygon, Vec2, Vec3 } from "../types"; + +export type BoxFace = "right" | "left" | "front" | "back" | "top" | "bottom"; + +export type BoxFaceOptions = Pick< + Polygon, + "color" | "texture" | "material" | "uvs" | "data" +>; + +export interface BoxPolygonsOptions extends BoxFaceOptions { + /** Size along the world X/Y/Z axes. Defaults to a 1×1×1 cube. */ + size?: number | Vec3; + /** Center used with `size`. Defaults to the origin. */ + center?: Vec3; + /** Explicit minimum world-space corner. When set with `max`, bounds win over size/center. */ + min?: Vec3; + /** Explicit maximum world-space corner. When set with `min`, bounds win over size/center. */ + max?: Vec3; + /** Per-face material/data overrides. Set a face to `false` to omit it. */ + faces?: Partial>; +} + +type FaceSpec = { + name: BoxFace; + vertices: Vec3[]; +}; + +const FACE_ORDER: BoxFace[] = ["right", "left", "front", "back", "top", "bottom"]; + +function cloneVec3(v: Vec3): Vec3 { + return [v[0], v[1], v[2]]; +} + +function normalizeBounds(a: Vec3, b: Vec3): { min: Vec3; max: Vec3 } { + return { + min: [ + Math.min(a[0], b[0]), + Math.min(a[1], b[1]), + Math.min(a[2], b[2]), + ], + max: [ + Math.max(a[0], b[0]), + Math.max(a[1], b[1]), + Math.max(a[2], b[2]), + ], + }; +} + +function boundsFromOptions(options: BoxPolygonsOptions): { min: Vec3; max: Vec3 } { + if (options.min && options.max) { + return normalizeBounds(options.min, options.max); + } + + const center: Vec3 = options.center ? cloneVec3(options.center) : [0, 0, 0]; + let size: Vec3; + if (typeof options.size === "number") { + size = [options.size, options.size, options.size]; + } else if (options.size) { + size = cloneVec3(options.size); + } else { + size = [1, 1, 1]; + } + const half: Vec3 = [size[0] / 2, size[1] / 2, size[2] / 2]; + + return normalizeBounds( + [center[0] - half[0], center[1] - half[1], center[2] - half[2]], + [center[0] + half[0], center[1] + half[1], center[2] + half[2]], + ); +} + +function buildFaceSpecs(min: Vec3, max: Vec3): FaceSpec[] { + const [x0, y0, z0] = min; + const [x1, y1, z1] = max; + + return [ + { + name: "right", + vertices: [[x1, y0, z0], [x1, y1, z0], [x1, y1, z1], [x1, y0, z1]], + }, + { + name: "left", + vertices: [[x0, y1, z0], [x0, y0, z0], [x0, y0, z1], [x0, y1, z1]], + }, + { + name: "front", + vertices: [[x1, y1, z0], [x0, y1, z0], [x0, y1, z1], [x1, y1, z1]], + }, + { + name: "back", + vertices: [[x0, y0, z0], [x1, y0, z0], [x1, y0, z1], [x0, y0, z1]], + }, + { + name: "top", + vertices: [[x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1]], + }, + { + name: "bottom", + vertices: [[x0, y1, z0], [x1, y1, z0], [x1, y0, z0], [x0, y0, z0]], + }, + ]; +} + +function mergeFaceOptions( + base: BoxFaceOptions, + face: BoxFaceOptions | undefined, +): BoxFaceOptions { + const merged: BoxFaceOptions = { + ...base, + ...face, + }; + if (base.data !== undefined || face?.data !== undefined) { + merged.data = { + ...(base.data ?? {}), + ...(face?.data ?? {}), + }; + } + return merged; +} + +function polygonFromFace(spec: FaceSpec, options: BoxFaceOptions): Polygon { + const polygon: Polygon = { + vertices: spec.vertices.map(cloneVec3), + }; + if (options.color !== undefined) polygon.color = options.color; + if (options.texture !== undefined) polygon.texture = options.texture; + if (options.material !== undefined) polygon.material = options.material; + if (options.uvs !== undefined) polygon.uvs = options.uvs.map((uv): Vec2 => [uv[0], uv[1]]); + if (options.data !== undefined) polygon.data = { ...options.data }; + return polygon; +} + +/** Build the polygons for one axis-aligned box/cuboid. */ +export function boxPolygons(options: BoxPolygonsOptions = {}): Polygon[] { + const { min, max } = boundsFromOptions(options); + if (min[0] === max[0] || min[1] === max[1] || min[2] === max[2]) return []; + + const base: BoxFaceOptions = { + color: options.color ?? "#ffffff", + texture: options.texture, + material: options.material, + uvs: options.uvs, + data: options.data, + }; + + const specs = buildFaceSpecs(min, max); + const byFace = new Map(specs.map((spec) => [spec.name, spec])); + const polygons: Polygon[] = []; + + for (const face of FACE_ORDER) { + const override = options.faces?.[face]; + if (override === false) continue; + const spec = byFace.get(face); + if (!spec) continue; + polygons.push(polygonFromFace(spec, mergeFaceOptions(base, override))); + } + + return polygons; +} diff --git a/packages/core/src/helpers/index.ts b/packages/core/src/helpers/index.ts index 103390ff..199675f1 100644 --- a/packages/core/src/helpers/index.ts +++ b/packages/core/src/helpers/index.ts @@ -1,5 +1,7 @@ export { axesHelperPolygons } from "./axesPolygons"; export type { AxesHelperOptions } from "./axesPolygons"; +export { boxPolygons } from "./boxPolygons"; +export type { BoxFace, BoxFaceOptions, BoxPolygonsOptions } from "./boxPolygons"; export { arrowPolygons } from "./arrowPolygons"; export type { ArrowPolygonsOptions } from "./arrowPolygons"; export { ringPolygons } from "./ringPolygons"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5863c874..068f4823 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -117,9 +117,9 @@ export type { CameraCullRotation, } from "./cull/cameraBackfaceCulling"; -// ── Helper-gizmo geometry (axes, light marker, transform arrows / rings) ─ -export { axesHelperPolygons, arrowPolygons, ringPolygons, ringQuadPolygons, planePolygons, octahedronPolygons } from "./helpers"; -export type { AxesHelperOptions, ArrowPolygonsOptions, RingPolygonsOptions, RingQuadPolygonsOptions, PlanePolygonsOptions, OctahedronPolygonsOptions } from "./helpers"; +// ── Helper geometry (boxes, axes, light marker, transform arrows / rings) ─ +export { axesHelperPolygons, boxPolygons, arrowPolygons, ringPolygons, ringQuadPolygons, planePolygons, octahedronPolygons } from "./helpers"; +export type { AxesHelperOptions, BoxFace, BoxFaceOptions, BoxPolygonsOptions, ArrowPolygonsOptions, RingPolygonsOptions, RingQuadPolygonsOptions, PlanePolygonsOptions, OctahedronPolygonsOptions } from "./helpers"; // ── Animation ───────────────────────────────────────────────────── export { diff --git a/packages/core/src/merge/coverPlanarPolygons.ts b/packages/core/src/merge/coverPlanarPolygons.ts index 14055749..fddb63c6 100644 --- a/packages/core/src/merge/coverPlanarPolygons.ts +++ b/packages/core/src/merge/coverPlanarPolygons.ts @@ -39,6 +39,13 @@ interface DirectedSegment2 { b: Vec2; } +interface LocalBBox { + minX: number; + minY: number; + maxX: number; + maxY: number; +} + interface BoundaryEdge { a: Vec3; b: Vec3; @@ -360,7 +367,7 @@ function signedArea(points: Vec2[]): number { return area / 2; } -function localBBox(points: Vec2[]): { minX: number; minY: number; maxX: number; maxY: number } { +function localBBox(points: Vec2[]): LocalBBox { let minX = Infinity; let minY = Infinity; let maxX = -Infinity; @@ -374,9 +381,7 @@ function localBBox(points: Vec2[]): { minX: number; minY: number; maxX: number; return { minX, minY, maxX, maxY }; } -function bboxesCanTouch(a: Vec2[], b: Vec2[]): boolean { - const ab = localBBox(a); - const bb = localBBox(b); +function localBBoxesCanTouch(ab: LocalBBox, bb: LocalBBox): boolean { return !( ab.maxX < bb.minX - 1e-7 || bb.maxX < ab.minX - 1e-7 || @@ -427,6 +432,48 @@ function pointParameterOnSegment(point: Vec2, a: Vec2, b: Vec2): number { return ((point[0] - a[0]) * dx + (point[1] - a[1]) * dy) / denom; } +function colinearSegmentsOverlap(a0: Vec2, a1: Vec2, b0: Vec2, b1: Vec2): boolean { + const ax = a1[0] - a0[0]; + const ay = a1[1] - a0[1]; + const bx = b1[0] - b0[0]; + const by = b1[1] - b0[1]; + const aLen = Math.hypot(ax, ay); + const bLen = Math.hypot(bx, by); + if (aLen <= 1e-9 || bLen <= 1e-9) return false; + + const crossB0 = ax * (b0[1] - a0[1]) - ay * (b0[0] - a0[0]); + const crossB1 = ax * (b1[1] - a0[1]) - ay * (b1[0] - a0[0]); + const crossAxes = ax * by - ay * bx; + const tolerance = Math.max(1e-8, Math.max(aLen, bLen) * 1e-8); + if ( + Math.abs(crossB0) > tolerance || + Math.abs(crossB1) > tolerance || + Math.abs(crossAxes) > tolerance + ) { + return false; + } + + const useX = Math.abs(ax) >= Math.abs(ay); + const aMin = Math.min(useX ? a0[0] : a0[1], useX ? a1[0] : a1[1]); + const aMax = Math.max(useX ? a0[0] : a0[1], useX ? a1[0] : a1[1]); + const bMin = Math.min(useX ? b0[0] : b0[1], useX ? b1[0] : b1[1]); + const bMax = Math.max(useX ? b0[0] : b0[1], useX ? b1[0] : b1[1]); + return Math.min(aMax, bMax) - Math.max(aMin, bMin) > 1e-8; +} + +function polygonsMayShareBoundary(a: Vec2[], b: Vec2[]): boolean { + for (let i = 0; i < a.length; i++) { + const a0 = a[i]; + const a1 = a[(i + 1) % a.length]; + for (let j = 0; j < b.length; j++) { + if (colinearSegmentsOverlap(a0, a1, b[j], b[(j + 1) % b.length])) { + return true; + } + } + } + return false; +} + function splitDirectedEdges(polygon: Vec2[], splitPoints: Vec2[]): DirectedSegment2[] { const segments: DirectedSegment2[] = []; for (let i = 0; i < polygon.length; i++) { @@ -465,8 +512,8 @@ function splitDirectedEdges(polygon: Vec2[], splitPoints: Vec2[]): DirectedSegme return segments; } -function unionConvexLocalPair(a: Vec2[], b: Vec2[]): Vec2[] | null { - if (!bboxesCanTouch(a, b)) return null; +function unionConvexLocalPair(a: Vec2[], b: Vec2[], aBBox = localBBox(a), bBBox = localBBox(b)): Vec2[] | null { + if (!localBBoxesCanTouch(aBBox, bBBox) || !polygonsMayShareBoundary(a, b)) return null; const pieces = [ ...splitDirectedEdges(a, b), ...splitDirectedEdges(b, a), @@ -529,16 +576,19 @@ function mergeLocalCells(cells: Vec2[][]): Vec2[][] { const polygons = cells .map(cleanLocalPolygon) .filter((polygon) => polygon.length >= 3 && localAreaAbs(polygon) > 1e-8); + const bboxes = polygons.map(localBBox); let changed = true; while (changed) { changed = false; for (let i = 0; i < polygons.length; i++) { for (let j = i + 1; j < polygons.length; j++) { - const merged = unionConvexLocalPair(polygons[i], polygons[j]); + const merged = unionConvexLocalPair(polygons[i], polygons[j], bboxes[i], bboxes[j]); if (!merged) continue; polygons[i] = merged; + bboxes[i] = localBBox(merged); polygons.splice(j, 1); + bboxes.splice(j, 1); changed = true; break; } diff --git a/packages/core/src/merge/optimizePolygons.test.ts b/packages/core/src/merge/optimizePolygons.test.ts index a0ffb3c9..ce3d6372 100644 --- a/packages/core/src/merge/optimizePolygons.test.ts +++ b/packages/core/src/merge/optimizePolygons.test.ts @@ -29,6 +29,12 @@ function sharedEdgeCount(polygons: Polygon[]): number { return [...counts.values()].filter((count) => count > 1).length; } +function polygonSignature(polygons: Polygon[]): string[] { + return polygons.map((polygon) => + `${polygon.color ?? ""}:${polygon.vertices.map((vertex) => vertex.join(",")).join(";")}` + ).sort(); +} + function textureTrianglePlaneDistance(polygon: Polygon): number { const [a, b, c] = polygon.vertices; const ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; @@ -230,6 +236,27 @@ describe("optimizeMeshPolygons", () => { ])); }); + it("keeps automatic lossy optimization exact for large cardinal quad meshes", () => { + const input: Polygon[] = []; + for (let y = 0; y < 20; y++) { + for (let x = 0; x < 20; x++) { + input.push({ + vertices: [[x, y, 0], [x + 1, y, 0], [x + 1, y + 1, 0], [x, y + 1, 0]], + color: (x + y) % 2 === 0 ? "#111111" : "#eeeeee", + }); + } + } + + const exact = optimizeMeshPolygons(input, { + meshResolution: "lossy", + approximateMerge: false, + }); + const automatic = optimizeMeshPolygons(input, { meshResolution: "lossy" }); + + expect(automatic).toHaveLength(exact.length); + expect(polygonSignature(automatic)).toEqual(polygonSignature(exact)); + }); + it("keeps lossy pair-merge neighbor seams on shared geometry", () => { const input: Polygon[] = [ { vertices: [[0, 0, 0.02], [1, 0, 0], [1, 1, 0.11]], color: "#f00" }, diff --git a/packages/core/src/merge/optimizePolygons.ts b/packages/core/src/merge/optimizePolygons.ts index 62c3a9fc..050ad1ca 100644 --- a/packages/core/src/merge/optimizePolygons.ts +++ b/packages/core/src/merge/optimizePolygons.ts @@ -1,5 +1,5 @@ import { cullInteriorPolygons } from "../cull/cullInteriorPolygons"; -import { dedupeOverlappingPolygons } from "./dedupeOverlappingPolygons"; +import { findOverlappingPolygonDuplicates } from "./dedupeOverlappingPolygons"; import type { MeshResolution, Polygon, TextureTriangle, Vec2, Vec3 } from "../types"; import { coverPlanarPolygons, type CoverPlanarPolygonsOptions } from "./coverPlanarPolygons"; import { mergePolygons } from "./mergePolygons"; @@ -101,11 +101,18 @@ interface PlaneGroupReplacements { } interface PreprocessCache { - baseline: Polygon[]; + baseline?: Polygon[]; + deduped?: Polygon[]; + dedupedIndices?: IndexFilter; + interior?: Polygon[]; + interiorIndices?: IndexFilter; snapped?: Polygon[]; snappedInterior?: Polygon[]; + snappedInteriorIndices?: IndexFilter; } +type IndexFilter = number[] | null; + interface Segment3 { a: Vec3; b: Vec3; @@ -142,6 +149,8 @@ interface CrackMetrics { excessBoundaryLength: number; } +interface CrackMetricLimits extends CrackMetrics {} + interface LossyQualityCandidate { polygons: Polygon[]; cost: number; @@ -181,7 +190,7 @@ const LOSSY_BUDGET_SWEEP: Array { @@ -218,27 +237,57 @@ export function optimizeMeshPolygons( return true; }; - const rectCovered = applyRectCoverCandidate(baseline, options.rectCover); + const initialRectCover = meshResolution === "lossy" && options.rectCover === undefined + ? automaticLossyRectCoverOptions(baseline) + : options.rectCover; + const rectCovered = applyRectCoverCandidate(baseline, initialRectCover); if (rectCovered !== baseline) acceptCandidate(rectCovered); if (meshResolution === "lossy" && options.approximateMerge !== false) { - const crackSource = createCrackSourceContext(polygons); const qualityCandidates: LossyQualityCandidate[] = []; - const referenceCracks = candidateCrackQualityMetrics( - crackSource, - best, - DEFAULT_LOSSY_APPROXIMATE_OPTIONS.maxBoundaryDisplacement, - ).metrics; + const referenceCandidate = best; + let crackSource: CrackSourceContext | null = null; + let referenceCracks: CrackMetrics | null = null; + const getCrackSource = (): CrackSourceContext => { + crackSource ??= createCrackSourceContext(polygons); + return crackSource; + }; + const getReferenceCracks = (): CrackMetrics => { + referenceCracks ??= candidateCrackQualityMetrics( + getCrackSource(), + referenceCandidate, + DEFAULT_LOSSY_APPROXIMATE_OPTIONS.maxBoundaryDisplacement, + ).metrics; + return referenceCracks; + }; const automaticApproximate = options.approximateMerge === undefined || options.approximateMerge === true; const passesLossyCrackBudget = ( sample: CrackMetricSample, allowReferenceCracks = true, ): boolean => !crackMetricsExceed( - crackSource, + getCrackSource(), sample.metrics, sample.tolerance, - allowReferenceCracks ? referenceCracks : null, + allowReferenceCracks ? getReferenceCracks() : null, ); + const sampleCandidateCracks = ( + candidate: Polygon[], + maxBoundaryDisplacement?: number, + allowReferenceCracks = true, + ): CrackMetricSample => { + const source = getCrackSource(); + const tolerance = crackToleranceForSource(source, maxBoundaryDisplacement); + return candidateCrackQualityMetrics( + source, + candidate, + maxBoundaryDisplacement, + crackMetricLimits( + source, + tolerance, + allowReferenceCracks ? getReferenceCracks() : null, + ), + ); + }; const acceptLossyCandidate = ( candidate: Polygon[], cost: number, @@ -249,171 +298,236 @@ export function optimizeMeshPolygons( candidate: Polygon[], cost: number, maxBoundaryDisplacement = DEFAULT_LOSSY_APPROXIMATE_OPTIONS.maxBoundaryDisplacement, + metrics?: CrackMetrics, ): void => { if (!automaticApproximate || cost > bestCost + lossyCrackCostSlack(bestCost)) return; qualityCandidates.push({ polygons: candidate, cost, maxBoundaryDisplacement, + metrics, }); }; - const approximateCandidates = lossyApproximateCandidates( - options.approximateMerge, - automaticApproximate ? baseline : undefined, - ); + const coverLossyCandidates = options.rectCover !== undefined && options.rectCover !== false; + const skipAutomaticGeometryApproximation = + automaticApproximate && shouldSkipAutomaticGeometryApproximation(baseline); + const approximateCandidates = skipAutomaticGeometryApproximation + ? [] + : lossyApproximateCandidates( + options.approximateMerge, + automaticApproximate ? baseline : undefined, + ); + const colorQuantizeCandidates = automaticApproximate + ? lossyColorQuantizeCandidates(polygons) + : []; for (let approximateIndex = 0; approximateIndex < approximateCandidates.length; approximateIndex++) { const approximateOptions = approximateCandidates[approximateIndex]; const approximate = preprocessModelPolygons(polygons, approximateOptions, preprocessCache); const approximateCost = polygonRenderCost(approximate); let approximateCracks: CrackMetricSample | null = null; - const sampleApproximateCracks = (): CrackMetricSample => { - approximateCracks ??= candidateCrackQualityMetrics( - crackSource, + let approximateMetrics: CrackMetrics | undefined; + const sampleApproximateCracks = (allowReferenceCracks: boolean): CrackMetricSample => { + approximateCracks ??= sampleCandidateCracks( approximate, approximateOptions.maxBoundaryDisplacement, + allowReferenceCracks, ); return approximateCracks; }; + const approximateAllowsReferenceCracks = !!approximateOptions.allowReferenceCracks; let approximatePassesCrackBudget = true; if (automaticApproximate || approximateOptions.guard) { - const sample = sampleApproximateCracks(); - approximatePassesCrackBudget = passesLossyCrackBudget(sample, !!approximateOptions.allowReferenceCracks); + const sample = sampleApproximateCracks(approximateAllowsReferenceCracks); + approximateMetrics = sample.metrics; + approximatePassesCrackBudget = passesLossyCrackBudget(sample, approximateAllowsReferenceCracks); } if (!approximatePassesCrackBudget && approximateCost < bestCost) { continue; } if (approximatePassesCrackBudget) { acceptLossyCandidate(approximate, approximateCost); - considerQualityCandidate(approximate, approximateCost, approximateOptions.maxBoundaryDisplacement); - } - const coveredApproximate = applyRectCoverCandidate(approximate, options.rectCover); - const coveredApproximateCost = polygonRenderCost(coveredApproximate); - let coveredApproximateCracks: CrackMetricSample | null = null; - const sampleCoveredApproximateCracks = (): CrackMetricSample => { - coveredApproximateCracks ??= candidateCrackQualityMetrics( - crackSource, - coveredApproximate, + considerQualityCandidate( + approximate, + approximateCost, approximateOptions.maxBoundaryDisplacement, + approximateMetrics, ); - return coveredApproximateCracks; - }; - if (coveredApproximate !== approximate && coveredApproximateCost < bestCost) { - let coveredPassesCrackGuard = true; - if (automaticApproximate || approximateOptions.guard) { - coveredPassesCrackGuard = passesLossyCrackBudget( - sampleCoveredApproximateCracks(), - !!approximateOptions.allowReferenceCracks, + } + if (coverLossyCandidates) { + const coveredApproximate = applyRectCoverCandidate(approximate, options.rectCover); + const coveredApproximateCost = polygonRenderCost(coveredApproximate); + let coveredApproximateCracks: CrackMetricSample | null = null; + let coveredApproximateMetrics: CrackMetrics | undefined; + const sampleCoveredApproximateCracks = (allowReferenceCracks: boolean): CrackMetricSample => { + coveredApproximateCracks ??= sampleCandidateCracks( + coveredApproximate, + approximateOptions.maxBoundaryDisplacement, + allowReferenceCracks, ); - } - if (coveredPassesCrackGuard) { - acceptLossyCandidate(coveredApproximate, coveredApproximateCost); - considerQualityCandidate(coveredApproximate, coveredApproximateCost, approximateOptions.maxBoundaryDisplacement); + return coveredApproximateCracks; + }; + if (coveredApproximate !== approximate && coveredApproximateCost < bestCost) { + let coveredPassesCrackGuard = true; + if (automaticApproximate || approximateOptions.guard) { + const sample = sampleCoveredApproximateCracks(approximateAllowsReferenceCracks); + coveredApproximateMetrics = sample.metrics; + coveredPassesCrackGuard = passesLossyCrackBudget(sample, approximateAllowsReferenceCracks); + } + if (coveredPassesCrackGuard) { + acceptLossyCandidate(coveredApproximate, coveredApproximateCost); + considerQualityCandidate( + coveredApproximate, + coveredApproximateCost, + approximateOptions.maxBoundaryDisplacement, + coveredApproximateMetrics, + ); + } } } } + if ( + automaticApproximate && + colorQuantizeCandidates.length === 0 && + shouldUseRectangulatedFastExit(baseline) + ) { + return best; + } + if (automaticApproximate) { - for (const colorPolygons of lossyColorQuantizeCandidates(polygons)) { - const colorCache: PreprocessCache = { - baseline: mergePolygons(cullInteriorPolygons(colorPolygons)), - }; - const colorCost = polygonRenderCost(colorCache.baseline); + for (const colorPolygons of colorQuantizeCandidates) { + const colorCache = createColorPreprocessCache(colorPolygons, preprocessCache); + const colorBaseline = colorCache.baseline!; + const colorCost = polygonRenderCost(colorBaseline); let colorPassesCrackBudget = true; let colorCracks: CrackMetricSample | null = null; + let colorMetrics: CrackMetrics | undefined; const sampleColorCracks = (): CrackMetricSample => { - colorCracks ??= candidateCrackQualityMetrics( - crackSource, - colorCache.baseline, + colorCracks ??= sampleCandidateCracks( + colorBaseline, DEFAULT_LOSSY_APPROXIMATE_OPTIONS.maxBoundaryDisplacement, ); return colorCracks; }; - if (colorCost < bestCost) colorPassesCrackBudget = passesLossyCrackBudget(sampleColorCracks()); + if (colorCost < bestCost) { + const sample = sampleColorCracks(); + colorMetrics = sample.metrics; + colorPassesCrackBudget = passesLossyCrackBudget(sample); + } if (colorPassesCrackBudget) { - acceptLossyCandidate(colorCache.baseline, colorCost); - considerQualityCandidate(colorCache.baseline, colorCost); + acceptLossyCandidate(colorBaseline, colorCost); + considerQualityCandidate(colorBaseline, colorCost, undefined, colorMetrics); } - const coveredColor = applyRectCoverCandidate(colorCache.baseline, options.rectCover); - if (coveredColor !== colorCache.baseline) { - const coveredColorCost = polygonRenderCost(coveredColor); - if ( - coveredColorCost >= bestCost || - passesLossyCrackBudget(candidateCrackQualityMetrics( - crackSource, - coveredColor, - DEFAULT_LOSSY_APPROXIMATE_OPTIONS.maxBoundaryDisplacement, - )) - ) { - acceptLossyCandidate(coveredColor, coveredColorCost); - considerQualityCandidate(coveredColor, coveredColorCost); + if (coverLossyCandidates) { + const coveredColor = applyRectCoverCandidate(colorBaseline, options.rectCover); + if (coveredColor !== colorBaseline) { + const coveredColorCost = polygonRenderCost(coveredColor); + let coveredColorCracks: CrackMetricSample | null = null; + let coveredColorMetrics: CrackMetrics | undefined; + const sampleCoveredColorCracks = (): CrackMetricSample => { + coveredColorCracks ??= sampleCandidateCracks( + coveredColor, + DEFAULT_LOSSY_APPROXIMATE_OPTIONS.maxBoundaryDisplacement, + ); + return coveredColorCracks; + }; + const coveredColorPassesCrackBudget = (): boolean => { + const sample = sampleCoveredColorCracks(); + coveredColorMetrics = sample.metrics; + return passesLossyCrackBudget(sample); + }; + if ( + coveredColorCost >= bestCost || + coveredColorPassesCrackBudget() + ) { + acceptLossyCandidate(coveredColor, coveredColorCost); + considerQualityCandidate(coveredColor, coveredColorCost, undefined, coveredColorMetrics); + } } } - for (const approximateOptions of lossyApproximateCandidates( - options.approximateMerge, - colorCache.baseline, - )) { + const colorApproximateCandidates = skipAutomaticGeometryApproximation + ? [] + : lossyApproximateCandidates( + options.approximateMerge, + colorCache.baseline, + ); + for (const approximateOptions of colorApproximateCandidates) { const approximate = preprocessModelPolygons(colorPolygons, approximateOptions, colorCache); const approximateCost = polygonRenderCost(approximate); let approximateCracks: CrackMetricSample | null = null; - const sampleApproximateCracks = (): CrackMetricSample => { - approximateCracks ??= candidateCrackQualityMetrics( - crackSource, + let approximateMetrics: CrackMetrics | undefined; + const sampleApproximateCracks = (allowReferenceCracks: boolean): CrackMetricSample => { + approximateCracks ??= sampleCandidateCracks( approximate, approximateOptions.maxBoundaryDisplacement, + allowReferenceCracks, ); return approximateCracks; }; + const approximateAllowsReferenceCracks = !!approximateOptions.allowReferenceCracks; let approximatePassesCrackBudget = true; if (automaticApproximate || approximateOptions.guard) { - const sample = sampleApproximateCracks(); - approximatePassesCrackBudget = passesLossyCrackBudget(sample, !!approximateOptions.allowReferenceCracks); + const sample = sampleApproximateCracks(approximateAllowsReferenceCracks); + approximateMetrics = sample.metrics; + approximatePassesCrackBudget = passesLossyCrackBudget(sample, approximateAllowsReferenceCracks); } if (!approximatePassesCrackBudget && approximateCost < bestCost) { continue; } if (approximatePassesCrackBudget) { acceptLossyCandidate(approximate, approximateCost); - considerQualityCandidate(approximate, approximateCost, approximateOptions.maxBoundaryDisplacement); - } - const coveredApproximate = applyRectCoverCandidate(approximate, options.rectCover); - const coveredApproximateCost = polygonRenderCost(coveredApproximate); - let coveredApproximateCracks: CrackMetricSample | null = null; - const sampleCoveredApproximateCracks = (): CrackMetricSample => { - coveredApproximateCracks ??= candidateCrackQualityMetrics( - crackSource, - coveredApproximate, + considerQualityCandidate( + approximate, + approximateCost, approximateOptions.maxBoundaryDisplacement, + approximateMetrics, ); - return coveredApproximateCracks; - }; - if (coveredApproximate !== approximate && coveredApproximateCost < bestCost) { - let coveredPassesCrackGuard = true; - if (automaticApproximate || approximateOptions.guard) { - coveredPassesCrackGuard = passesLossyCrackBudget( - sampleCoveredApproximateCracks(), - !!approximateOptions.allowReferenceCracks, + } + if (coverLossyCandidates) { + const coveredApproximate = applyRectCoverCandidate(approximate, options.rectCover); + const coveredApproximateCost = polygonRenderCost(coveredApproximate); + let coveredApproximateCracks: CrackMetricSample | null = null; + let coveredApproximateMetrics: CrackMetrics | undefined; + const sampleCoveredApproximateCracks = (allowReferenceCracks: boolean): CrackMetricSample => { + coveredApproximateCracks ??= sampleCandidateCracks( + coveredApproximate, + approximateOptions.maxBoundaryDisplacement, + allowReferenceCracks, ); - } - if (coveredPassesCrackGuard) { - acceptLossyCandidate(coveredApproximate, coveredApproximateCost); - considerQualityCandidate(coveredApproximate, coveredApproximateCost, approximateOptions.maxBoundaryDisplacement); + return coveredApproximateCracks; + }; + if (coveredApproximate !== approximate && coveredApproximateCost < bestCost) { + let coveredPassesCrackGuard = true; + if (automaticApproximate || approximateOptions.guard) { + const sample = sampleCoveredApproximateCracks(approximateAllowsReferenceCracks); + coveredApproximateMetrics = sample.metrics; + coveredPassesCrackGuard = passesLossyCrackBudget(sample, approximateAllowsReferenceCracks); + } + if (coveredPassesCrackGuard) { + acceptLossyCandidate(coveredApproximate, coveredApproximateCost); + considerQualityCandidate( + coveredApproximate, + coveredApproximateCost, + approximateOptions.maxBoundaryDisplacement, + coveredApproximateMetrics, + ); + } } } } } } - if (automaticApproximate) { + if (automaticApproximate && !skipAutomaticGeometryApproximation) { for (const budget of LOSSY_BUDGET_SWEEP) { const polygonPairOptions = resolveNormalizeOptions({ ...budget, isolatedPairs: true }); const polygonPaired = mergeAdjacentApproximatePolygonPairs(best, polygonPairOptions); if (polygonPaired === best) continue; const polygonPairCost = polygonRenderCost(polygonPaired); if (polygonPairCost >= bestCost) continue; - const polygonPairCracks = candidateCrackQualityMetrics( - crackSource, + const polygonPairCracks = sampleCandidateCracks( polygonPaired, polygonPairOptions.maxBoundaryDisplacement, ); @@ -423,6 +537,7 @@ export function optimizeMeshPolygons( polygonPaired, polygonPairCost, polygonPairOptions.maxBoundaryDisplacement, + polygonPairCracks.metrics, ); } @@ -434,14 +549,14 @@ export function optimizeMeshPolygons( bestCost, (candidate) => { candidate.metrics ??= candidateCrackQualityMetrics( - crackSource, + getCrackSource(), candidate.polygons, candidate.maxBoundaryDisplacement, ).metrics; return candidate.metrics; }, () => candidateCrackQualityMetrics( - crackSource, + getCrackSource(), best, DEFAULT_LOSSY_APPROXIMATE_OPTIONS.maxBoundaryDisplacement, ).metrics, @@ -601,6 +716,34 @@ function shouldUseRectangulatedLossyPath(baseline: Polygon[]): boolean { return triangles / baseline.length <= LOSSY_RECTANGULATED_MAX_TRIANGLE_RATIO; } +function shouldUseRectangulatedFastExit(baseline: Polygon[]): boolean { + return baseline.length >= LOSSY_RECTANGULATED_FAST_EXIT_MIN_POLYGONS && + shouldUseRectangulatedLossyPath(baseline); +} + +function shouldSkipAutomaticGeometryApproximation(baseline: Polygon[]): boolean { + if (baseline.length < AUTOMATIC_GEOMETRY_SKIP_MIN_POLYGONS) return false; + + for (const polygon of baseline) { + if (polygon.vertices.length !== 4) return false; + if (polygon.texture || polygon.material?.texture || polygon.textureTriangles?.length) return false; + + const plane = planeOfPolygon(polygon); + if (!plane || !isCardinalNormal(plane.normal)) return false; + } + + return true; +} + +function isCardinalNormal(normal: Vec3): boolean { + const ax = Math.abs(normal[0]); + const ay = Math.abs(normal[1]); + const az = Math.abs(normal[2]); + const dominant = Math.max(ax, ay, az); + return dominant >= 1 - CARDINAL_NORMAL_EPSILON && + ax + ay + az - dominant <= CARDINAL_NORMAL_EPSILON; +} + function polygonRenderCost(polygons: Polygon[]): number { let cost = 0; for (const polygon of polygons) { @@ -678,8 +821,20 @@ function crackMetricsExceed( tolerance: number, reference: CrackMetrics | null = null, ): boolean { + return crackMetricsExceedLimits(metrics, crackMetricLimits(source, tolerance, reference)); +} + +function crackMetricLimits( + source: CrackSourceContext, + tolerance: number, + reference: CrackMetrics | null = null, +): CrackMetricLimits { if (!reference) { - return metrics.internalBoundaryLength > 0 || metrics.excessBoundaryLength > tolerance; + return { + maxGap: Infinity, + internalBoundaryLength: 0, + excessBoundaryLength: tolerance, + }; } const gapSlack = Math.max(tolerance * 0.1, 1e-6); @@ -689,10 +844,18 @@ function crackMetricsExceed( : referenceGapLimit; const lengthSlack = Math.max(tolerance * 2, reference.internalBoundaryLength * 0.15); const excessSlack = Math.max(tolerance * 2, reference.excessBoundaryLength * 0.15); + return { + maxGap: gapLimit, + internalBoundaryLength: reference.internalBoundaryLength + lengthSlack, + excessBoundaryLength: reference.excessBoundaryLength + excessSlack, + }; +} + +function crackMetricsExceedLimits(metrics: CrackMetrics, limits: CrackMetricLimits): boolean { return ( - metrics.maxGap > gapLimit || - metrics.internalBoundaryLength > reference.internalBoundaryLength + lengthSlack || - metrics.excessBoundaryLength > reference.excessBoundaryLength + excessSlack + metrics.maxGap > limits.maxGap || + metrics.internalBoundaryLength > limits.internalBoundaryLength || + metrics.excessBoundaryLength > limits.excessBoundaryLength ); } @@ -701,6 +864,7 @@ function candidateCrackMetrics( candidate: Polygon[], maxBoundaryDisplacement = 0, searchTolerance = crackToleranceForSource(source, maxBoundaryDisplacement), + stopLimits?: CrackMetricLimits, ): CrackMetricSample { const sourceEdges = source.edges; const candidateEdges = collectEdgeStats(candidate); @@ -712,18 +876,23 @@ function candidateCrackMetrics( ...EMPTY_CRACK_METRICS, excessBoundaryLength: Math.max(0, candidateEdges.boundaryLength - sourceEdges.boundaryLength), }; + if (stopLimits && crackMetricsExceedLimits(metrics, stopLimits)) { + return { metrics, tolerance }; + } for (const edge of candidateEdges.boundarySegments) { const key = edgeKey(edge.a, edge.b); if (sourceEdges.boundaryKeys.has(key)) continue; if (sourceEdges.internalKeys.has(key)) { metrics.internalBoundaryLength += distanceVec(edge.a, edge.b); + if (stopLimits && crackMetricsExceedLimits(metrics, stopLimits)) break; continue; } const gap = internalIndex ? indexedInternalEdgeGap(edge, internalIndex, searchTolerance) : null; if (gap !== null) { metrics.maxGap = Math.max(metrics.maxGap, gap); metrics.internalBoundaryLength += distanceVec(edge.a, edge.b); + if (stopLimits && crackMetricsExceedLimits(metrics, stopLimits)) break; } } return { metrics, tolerance }; @@ -733,12 +902,14 @@ function candidateCrackQualityMetrics( source: CrackSourceContext, candidate: Polygon[], maxBoundaryDisplacement = 0, + stopLimits?: CrackMetricLimits, ): CrackMetricSample { return candidateCrackMetrics( source, candidate, maxBoundaryDisplacement, crackQualitySearchToleranceForSource(source, maxBoundaryDisplacement), + stopLimits, ); } @@ -922,6 +1093,15 @@ function resolveRectCoverOptions( return DEFAULT_RECT_COVER_OPTIONS; } +function automaticLossyRectCoverOptions(polygons: Polygon[]): CoverPlanarPolygonsOptions | false { + if (polygons.length > AUTOMATIC_RECT_COVER_MAX_POLYGONS) return false; + if (polygons.length === 0) return false; + if (polygonTriangleCount(polygons) / polygons.length < AUTOMATIC_RECT_COVER_MIN_TRIANGLE_RATIO) { + return false; + } + return AUTOMATIC_LOSSY_RECT_COVER_OPTIONS; +} + function polygonTriangleCount(polygons: Polygon[]): number { let triangles = 0; for (const polygon of polygons) { @@ -930,6 +1110,88 @@ function polygonTriangleCount(polygons: Polygon[]): number { return triangles; } +function applyIndexFilter(polygons: Polygon[], filter: IndexFilter | undefined): Polygon[] { + if (filter === undefined || filter === null) return polygons; + return filter.map((index) => polygons[index]).filter((polygon): polygon is Polygon => !!polygon); +} + +function keptIndexFilter(input: Polygon[], kept: Polygon[]): IndexFilter { + if (kept === input) return null; + if (kept.length === input.length && kept.every((polygon, index) => polygon === input[index])) { + return null; + } + const keptSet = new Set(kept); + const indices: number[] = []; + for (let i = 0; i < input.length; i++) { + if (keptSet.has(input[i])) indices.push(i); + } + return indices.length === input.length ? null : indices; +} + +function dedupedPolygonsForMerge(polygons: Polygon[], cache?: PreprocessCache): Polygon[] { + if (cache?.deduped) return cache.deduped; + let filter = cache?.dedupedIndices; + if (filter === undefined) { + const dropped = findOverlappingPolygonDuplicates(polygons); + if (dropped.size === 0) { + filter = null; + } else { + filter = []; + for (let i = 0; i < polygons.length; i++) { + if (!dropped.has(i)) filter.push(i); + } + } + if (cache) cache.dedupedIndices = filter; + } + const deduped = applyIndexFilter(polygons, filter); + if (cache) cache.deduped = deduped; + return deduped; +} + +function interiorPolygonsForMerge(polygons: Polygon[], cache?: PreprocessCache): Polygon[] { + if (cache?.interior) return cache.interior; + let filter = cache?.interiorIndices; + if (filter === undefined) { + const kept = cullInteriorPolygons(polygons); + filter = keptIndexFilter(polygons, kept); + if (cache) cache.interiorIndices = filter; + } + const interior = applyIndexFilter(polygons, filter); + if (cache) cache.interior = interior; + return interior; +} + +function createColorPreprocessCache( + polygons: Polygon[], + source: PreprocessCache, +): PreprocessCache { + const cache: PreprocessCache = { + dedupedIndices: source.dedupedIndices, + interiorIndices: source.interiorIndices, + snappedInteriorIndices: source.snappedInteriorIndices, + }; + const deduped = dedupedPolygonsForMerge(polygons, cache); + if (source.snapped && source.snapped.length === deduped.length) { + cache.snapped = applyGeometryTemplate(deduped, source.snapped); + } + const interior = interiorPolygonsForMerge(deduped, cache); + cache.baseline = mergePolygons(interior); + return cache; +} + +function applyGeometryTemplate(polygons: Polygon[], template: Polygon[]): Polygon[] { + return polygons.map((polygon, index) => { + const geometry = template[index]; + if (!geometry) return polygon; + return { + ...polygon, + vertices: geometry.vertices, + ...(geometry.uvs ? { uvs: geometry.uvs } : {}), + ...(geometry.textureTriangles ? { textureTriangles: geometry.textureTriangles } : {}), + }; + }); +} + function preprocessModelPolygons( polygons: Polygon[], normalizeGeometry: boolean | ApproximateMergeOptions, @@ -939,8 +1201,10 @@ function preprocessModelPolygons( // doubled-up faces that importers emit as artifacts. Doing it before // cull + merge means everything downstream operates on the leaner set // and gets a free speedup as a bonus. Light-independent, runs once. - const deduped = dedupeOverlappingPolygons(polygons); - const baseline = cache?.baseline ?? mergePolygons(cullInteriorPolygons(deduped)); + const deduped = dedupedPolygonsForMerge(polygons, cache); + const interior = interiorPolygonsForMerge(deduped, cache); + const baseline = cache?.baseline ?? mergePolygons(interior); + if (cache && !cache.baseline) cache.baseline = baseline; if (!normalizeGeometry) return baseline; const options = normalizeGeometry === true @@ -964,7 +1228,14 @@ function snappedPolygonsForMerge(polygons: Polygon[], cache?: PreprocessCache): function snappedInteriorPolygonsForMerge(polygons: Polygon[], cache?: PreprocessCache): Polygon[] { if (!cache) return cullInteriorPolygons(snapGeometryForMerge(polygons)); if (!cache.snappedInterior) { - cache.snappedInterior = cullInteriorPolygons(snappedPolygonsForMerge(polygons, cache)); + const snapped = snappedPolygonsForMerge(polygons, cache); + if (cache.snappedInteriorIndices === undefined) { + const kept = cullInteriorPolygons(snapped); + cache.snappedInteriorIndices = keptIndexFilter(snapped, kept); + cache.snappedInterior = kept; + } else { + cache.snappedInterior = applyIndexFilter(snapped, cache.snappedInteriorIndices); + } } return cache.snappedInterior; } diff --git a/packages/core/src/parser/loadMesh.test.ts b/packages/core/src/parser/loadMesh.test.ts index ddf79d2e..f39e47f3 100644 --- a/packages/core/src/parser/loadMesh.test.ts +++ b/packages/core/src/parser/loadMesh.test.ts @@ -85,6 +85,46 @@ function stubTexturePixels(width: number, height: number, pixels: Uint8Array): v }); } +function stubEarlyDecodeTexturePixels( + width: number, + height: number, + unloadedPixels: Uint8Array, + loadedPixels: Uint8Array, +): void { + let loaded = false; + vi.stubGlobal("Image", class MockImage { + decoding = ""; + naturalWidth = width; + naturalHeight = height; + width = width; + height = height; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + set src(_value: string) { + setTimeout(() => { + loaded = true; + this.onload?.(); + }, 0); + } + decode() { + return Promise.resolve(); + } + }); + vi.stubGlobal("document", { + createElement(tagName: string) { + if (tagName !== "canvas") return {}; + return { + width: 0, + height: 0, + getContext: () => ({ + drawImage: vi.fn(), + getImageData: () => ({ data: loaded ? loadedPixels : unloadedPixels }), + }), + }; + }, + }); +} + // Build a minimal valid GLB (no mesh — just magic + empty JSON chunk + // empty BIN chunk). parseGltf requires a BIN chunk to be present, even if // the document has no meshes. @@ -175,6 +215,30 @@ describe("loadMesh", () => { expect(result.polygons[0].vertices).toHaveLength(4); }); + it("waits for image load before sampling solid texture colors", async () => { + vi.stubGlobal("fetch", makeMockFetch({ text: TEXTURED_QUAD_OBJ })); + stubEarlyDecodeTexturePixels( + 2, + 2, + new Uint8Array([ + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + ]), + new Uint8Array([ + 255, 0, 0, 255, 255, 0, 0, 255, + 255, 0, 0, 255, 255, 0, 0, 255, + ]), + ); + + const result = await loadMesh("model.obj", { + objOptions: { materialTextures: { Swatch: "swatch.png" } }, + }); + + expect(result.polygons).toHaveLength(1); + expect(result.polygons[0].texture).toBeUndefined(); + expect(result.polygons[0].color).toBe("#ff0000"); + }); + it("bakes noisy color-swatch texture samples into solid polygons by default", async () => { vi.stubGlobal("fetch", makeMockFetch({ text: TEXTURED_QUAD_OBJ })); stubTexturePixels(2, 2, new Uint8Array([ diff --git a/packages/core/src/parser/parseVox.test.ts b/packages/core/src/parser/parseVox.test.ts index 987fccbe..f8f4a46a 100644 --- a/packages/core/src/parser/parseVox.test.ts +++ b/packages/core/src/parser/parseVox.test.ts @@ -310,7 +310,7 @@ describe("parseVox — minimal synthetic buffer", () => { expect(result.polygons.length).toBe(6); }); - it("preserves normalized raw voxel source for slice-brush rendering", () => { + it("preserves normalized raw voxel source for fast-path rendering", () => { const buf = buildVoxBuffer([3, 2, 1], [{ x: 2, y: 1, z: 0, colorIndex: 1 }]); const result = parseVox(buf, { targetSize: 30, gridShift: 2 }); expect(result.voxelSource).toEqual({ diff --git a/packages/core/src/parser/parseVox.ts b/packages/core/src/parser/parseVox.ts index 6bdcb14b..afdf9a7a 100644 --- a/packages/core/src/parser/parseVox.ts +++ b/packages/core/src/parser/parseVox.ts @@ -23,8 +23,8 @@ * shift is required by default. * * Output mesh is uniformly scaled near `targetSize` units along the longest - * bbox axis, snapped to the nearest integer CSS cell so voxel slice renderers - * can avoid fractional brush coordinates without adding scale transforms. + * bbox axis, snapped to the nearest integer CSS cell so voxel renderers can + * avoid fractional brush coordinates without adding scale transforms. */ import type { Polygon, Vec3 } from "../types"; import { BASE_TILE } from "../camera/camera"; @@ -34,7 +34,7 @@ export interface VoxParseOptions { /** * Largest mesh extent (in scene-space units). For `.vox`, the requested * extent is snapped to the nearest integer CSS cell size to keep voxel - * slice brushes on integer pixel coordinates. Default: 60. + * fast-path coordinates integral. Default: 60. */ targetSize?: number; /** diff --git a/packages/core/src/parser/solidTextureSamples.ts b/packages/core/src/parser/solidTextureSamples.ts index e0da90e9..af126c9c 100644 --- a/packages/core/src/parser/solidTextureSamples.ts +++ b/packages/core/src/parser/solidTextureSamples.ts @@ -22,7 +22,6 @@ interface ImageLike { height?: number; onload: (() => void) | null; onerror: (() => void) | null; - decode?: () => Promise; } interface CanvasLike { @@ -104,14 +103,6 @@ function loadImage(url: string, ImageCtor: new () => ImageLike): Promise done(() => resolve(img)); img.onerror = () => done(() => reject(new Error(`texture load failed: ${url}`))); img.src = url; - if (typeof img.decode === "function") { - img.decode().then( - () => done(() => resolve(img)), - () => { - // Keep the onload/onerror path authoritative for older/fake images. - }, - ); - } }); } diff --git a/packages/core/src/voxel/voxelSlicePlanner.ts b/packages/core/src/voxel/voxelSlicePlanner.ts index 8c4bb85b..20565b39 100644 --- a/packages/core/src/voxel/voxelSlicePlanner.ts +++ b/packages/core/src/voxel/voxelSlicePlanner.ts @@ -1,7 +1,7 @@ /* Pure voxel slice planning - zero DOM dependencies. * The rectangle optimizer is ported from voxcss mergeVoxels="3d"; polycss - * feeds it the raw MagicaVoxel cell source and renders the plans in - * packages/polycss. + * keeps it available as a low-level planning utility, though the current + * vanilla `.vox` fast path renders exact direct-matrix quads instead. */ import type { PolyVoxelSource } from "../parser/types"; diff --git a/packages/polycss/README.md b/packages/polycss/README.md index 6c5374e5..2988e112 100644 --- a/packages/polycss/README.md +++ b/packages/polycss/README.md @@ -95,7 +95,7 @@ poly-polygon.hover { filter: brightness(1.5); } | `ambient-intensity` | Ambient light intensity | | `ambient-color` | Ambient light color hex | | `texture-lighting` | `"baked"` or `"dynamic"` | -| `atlas-scale` | Raster scale for generated atlas pages; lower values reduce memory/detail | +| `atlas-scale` | Atlas bitmap budget and compositor sprite size; lower numeric values reduce memory/detail | For pointer drag, wheel zoom, and autorotate, drop a `` child inside the scene (or wire `createPolyOrbitControls(scene, ...)` against the imperative API). For pan-first map-style input use `` / `createPolyMapControls` instead. Mirrors Three.js's split between camera state (``) and camera input. @@ -161,7 +161,7 @@ mesh.dispose(); | `directionalLight` | `PolyDirectionalLight` | Directional light config | | `ambientLight` | `PolyAmbientLight` | Ambient light config | | `textureLighting` | `"baked" \| "dynamic"` | Texture lighting mode | -| `textureQuality` | `number \| "auto"` | Raster scale for generated atlas pages | +| `textureQuality` | `number \| "auto"` | Atlas bitmap budget and compositor sprite size | | `autoCenter` | `boolean` | Rotate around the union bbox center of added meshes | Returns a `PolySceneHandle`: diff --git a/packages/polycss/src/api/createPolyScene.test.ts b/packages/polycss/src/api/createPolyScene.test.ts index cf486b1c..717b0f04 100644 --- a/packages/polycss/src/api/createPolyScene.test.ts +++ b/packages/polycss/src/api/createPolyScene.test.ts @@ -276,6 +276,11 @@ describe("createPolyScene", () => { expect(styleEl?.textContent).toContain("transform-origin: 0 0"); expect(styleEl?.textContent).toContain("backface-visibility: hidden"); expect(styleEl?.textContent).toContain("background-repeat: no-repeat"); + expect(styleEl?.textContent).toContain("width: 64px;"); + expect(styleEl?.textContent).toContain("height: 64px;"); + expect(styleEl?.textContent).toContain("width: var(--polycss-atlas-size, 64px);"); + expect(styleEl?.textContent).toContain("height: var(--polycss-atlas-size, 64px);"); + expect(styleEl?.textContent).toContain("border-width: 0 32px 64px 32px;"); expect(styleEl?.textContent).toContain("width: 0;"); expect(styleEl?.textContent).toContain("height: 0;"); }); @@ -296,40 +301,41 @@ describe("createPolyScene", () => { expect(handle.polygons.length).toBe(2); }); - it("routes raw vox sources through the voxel slice-brush renderer", () => { + it("routes exact raw vox sources through the direct voxel renderer", () => { scene = createPolyScene(host); - scene.add(makeVoxelParseResult(), { merge: false }); - const voxelRoot = host.querySelector(".polycss-voxel-host-z"); - const voxelBrushes = Array.from(host.querySelectorAll(".polycss-voxel-host b")); - expect(voxelRoot).not.toBeNull(); - expect(host.querySelector(".polycss-voxel-slice")).toBeNull(); + scene.add(makeVoxelExactParseResult(), { merge: false }); + const voxelBrushes = Array.from(host.querySelectorAll(".polycss-mesh > b")); + expect(host.querySelector(".polycss-voxel-host-z")).toBeNull(); expect(voxelBrushes.length).toBeGreaterThan(0); expect(voxelBrushes.every((el) => el.tagName === "B")).toBe(true); + expect(host.querySelector(".polycss-mesh")?.classList.contains("polycss-voxel-mesh")).toBe(true); const firstBrush = voxelBrushes[0] as HTMLElement; - expect(firstBrush.style.left).toMatch(/px$/); - expect(firstBrush.style.top).toMatch(/px$/); - expect(firstBrush.style.width).toMatch(/px$/); - expect(firstBrush.style.height).toMatch(/px$/); + expect(firstBrush.style.left).toBe(""); + expect(firstBrush.style.top).toBe(""); + expect(firstBrush.style.width).toBe(""); + expect(firstBrush.style.height).toBe(""); expect(firstBrush.style.gridArea).toBe(""); - expect(firstBrush.style.transform).toContain("translateZ("); + expect(firstBrush.style.transform).toContain("matrix3d("); + expect(firstBrush.style.backfaceVisibility).toBe(""); + expect(firstBrush.style.pointerEvents).toBe(""); expect(firstBrush.style.getPropertyValue("--polycss-voxel-z")).toBe(""); expect(voxelBrushes.every((el) => el.className === "")).toBe(true); }); - it("applies baked lighting to voxel slice-brush quads", () => { + it("applies baked lighting to direct voxel quads", () => { scene = createPolyScene(host, { rotX: 0, rotY: 0, directionalLight: { direction: [0, 0, -1], color: "#ffffff", intensity: 1 }, ambientLight: { color: "#ffffff", intensity: 0 }, }); - scene.add(makeVoxelParseResult(), { merge: false }); - const brush = host.querySelector(".polycss-voxel-host b") as HTMLElement | null; + scene.add(makeVoxelExactParseResult(), { merge: false }); + const brush = host.querySelector(".polycss-mesh > b") as HTMLElement | null; expect(brush).not.toBeNull(); expect(brush!.style.color).toMatch(/^(#000000|rgb\\(0, 0, 0\\))$/); }); - it("uses exact parsed voxel polygons before falling back to merged source brushes", () => { + it("uses exact parsed voxel polygons for direct matrix placement", () => { scene = createPolyScene(host, { rotX: 65, rotY: 45, @@ -337,20 +343,30 @@ describe("createPolyScene", () => { ambientLight: { color: "#ffffff", intensity: 1 }, }); scene.add(makeVoxelExactParseResult(), { merge: false }); - const brush = host.querySelector(".polycss-voxel-host b") as HTMLElement | null; + const brush = host.querySelector(".polycss-mesh > b") as HTMLElement | null; expect(brush).not.toBeNull(); expect(brush!.style.color).toMatch(/^(#123456|rgb\\(18, 52, 86\\))$/); - expect(brush!.style.width).toBe("50px"); - expect(brush!.style.height).toBe("50px"); + expect(brush!.style.width).toBe(""); + expect(brush!.style.height).toBe(""); + expect(brush!.style.transform).toContain("matrix3d(50,0,0,0,0,50"); + }); + + it("falls back to polygon rendering when raw vox polygons are not exact direct quads", () => { + scene = createPolyScene(host); + scene.add(makeVoxelParseResult(), { merge: false }); + expect(host.querySelector(".polycss-voxel-host-z")).toBeNull(); + expect(host.querySelector(".polycss-mesh > b")).toBeNull(); + expect(host.querySelector("i,b,s,u")).not.toBeNull(); }); it("falls back to polygon rendering after setPolygons replaces vox source geometry", () => { scene = createPolyScene(host); - const handle = scene.add(makeVoxelParseResult(), { merge: false }); - expect(host.querySelector(".polycss-voxel-host-z")).not.toBeNull(); + const handle = scene.add(makeVoxelExactParseResult(), { merge: false }); + expect(host.querySelector(".polycss-mesh > b")).not.toBeNull(); handle.setPolygons([triangle()], { merge: false }); expect(host.querySelector(".polycss-voxel-host-z")).toBeNull(); - expect(host.querySelector(".polycss-voxel-host b")).toBeNull(); + expect(host.querySelector(".polycss-mesh > b")).toBeNull(); + expect(host.querySelector(".polycss-mesh")?.classList.contains("polycss-voxel-mesh")).toBe(false); expect(host.querySelector("i,b,s,u")).not.toBeNull(); }); diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index f105bb83..1a00f931 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -56,9 +56,9 @@ import { type SolidPaintDefaults, } from "../render/textureAtlas"; import { - createPolyVoxelSliceRenderer, - type PolyVoxelSliceRenderer, -} from "../render/voxelSliceRenderer"; + createPolyVoxelRenderer, + type PolyVoxelRenderer, +} from "../render/voxelRenderer"; import { injectPolyBaseStyles } from "../styles/styles"; // Used only by the internal async mesh update path. Batching DOM insertion @@ -93,9 +93,10 @@ export interface PolySceneOptions { ambientLight?: PolyAmbientLight; /** Textured polygon lighting mode. Defaults to "baked". */ textureLighting?: PolyTextureLightingMode; - /** Raster scale for generated atlas pages. `"auto"` reduces large atlases - * to fit a device-appropriate memory budget (~4 MB mobile / ~16 MB desktop). - * Numeric values 0.1..1 force an explicit scale. */ + /** Atlas bitmap budget and CSS sprite size. `"auto"` uses a + * device-appropriate memory budget (~4 MB mobile / ~16 MB desktop) and + * desktop/mobile sprite sizing. Numeric values 0.1..1 force an explicit + * raster scale and the 64px sprite. */ textureQuality?: TextureQuality; /** * Skip specific render-strategy tags. Polygons that would normally use a @@ -488,7 +489,7 @@ export function createPolyScene( * separate from `rendered` so they can be removed independently when * castShadow is toggled or lighting mode changes. */ shadowRendered: HTMLElement[]; - voxelRenderer?: PolyVoxelSliceRenderer; + voxelRenderer?: PolyVoxelRenderer; disposeAtlas?: () => void; polygons: Polygon[]; voxelSource: ParseResult["voxelSource"]; @@ -795,7 +796,7 @@ export function createPolyScene( function syncMountedRenderedForCameraChange(entry: MeshEntry, force = false): void { if (entry.voxelRenderer) { entry.voxelRenderer.syncCamera(cameraCullRotation(entry)); - entry.cameraCullSignature = "voxel-slice"; + entry.cameraCullSignature = "voxel-direct"; return; } @@ -1091,7 +1092,7 @@ export function createPolyScene( function remountEntry(entry: MeshEntry): void { if (entry.voxelRenderer) { entry.voxelRenderer.render(cameraCullRotation(entry)); - entry.cameraCullSignature = "voxel-slice"; + entry.cameraCullSignature = "voxel-direct"; return; } clearShadowLeaves(entry); @@ -1099,7 +1100,7 @@ export function createPolyScene( emitShadowLeaves(entry); } - function canRenderVoxelSlice(entry: MeshEntry): boolean { + function canRenderVoxelDirect(entry: MeshEntry): boolean { return !!entry.voxelSource && currentOptions.textureLighting !== "dynamic" && !entry.stableDom && @@ -1112,19 +1113,20 @@ export function createPolyScene( const directionalLight: typeof baseDirLight = lightDirectionOverride ? { ...baseDirLight, direction: lightDirectionOverride } : baseDirLight; - if (canRenderVoxelSlice(entry) && entry.voxelSource) { - const renderer = createPolyVoxelSliceRenderer({ + if (canRenderVoxelDirect(entry)) { + const renderer = createPolyVoxelRenderer({ doc, wrapper: entry.wrapper, - source: entry.voxelSource, polygons: entry.parseResult.polygons, directionalLight, ambientLight: currentOptions.ambientLight, }); - entry.voxelRenderer = renderer; - renderer.render(cameraCullRotation(entry)); - entry.cameraCullSignature = "voxel-slice"; - return; + if (renderer) { + entry.voxelRenderer = renderer; + renderer.render(cameraCullRotation(entry)); + entry.cameraCullSignature = "voxel-direct"; + return; + } } const renderOptions = { diff --git a/packages/polycss/src/render/polyDOM.test.ts b/packages/polycss/src/render/polyDOM.test.ts index 3af9691e..2c810a73 100644 --- a/packages/polycss/src/render/polyDOM.test.ts +++ b/packages/polycss/src/render/polyDOM.test.ts @@ -3,6 +3,11 @@ import { renderPoly } from "./polyDOM"; import { renderPolygonsWithTextureAtlas, renderPolygonsWithTextureAtlasAsync } from "./textureAtlas"; import type { Polygon } from "@layoutit/polycss-core"; +const ATLAS_CANONICAL_SIZE_EXPLICIT = 64; +const ATLAS_CANONICAL_SIZE_AUTO_DESKTOP = 128; +const SOLID_QUAD_CANONICAL_SIZE = 64; +const SOLID_TRIANGLE_CANONICAL_SIZE = 64; + const FLAT_TRIANGLE: Polygon = { vertices: [ [0, 0, 0], @@ -152,23 +157,42 @@ function computeExpectedPlan( }; } -function computeExpectedMatrix( +function computeExpectedAtlasMatrix( vertices: [number, number, number][], tileSize = 50, elev = tileSize, + atlasCanonicalSize = ATLAS_CANONICAL_SIZE_AUTO_DESKTOP, ): number[] { - return computeExpectedPlan(vertices, tileSize, elev).matrix; + const { matrix, canvasW, canvasH } = computeExpectedPlan(vertices, tileSize, elev); + return [ + matrix[0] * canvasW / atlasCanonicalSize, + matrix[1] * canvasW / atlasCanonicalSize, + matrix[2] * canvasW / atlasCanonicalSize, + 0, + matrix[4] * canvasH / atlasCanonicalSize, + matrix[5] * canvasH / atlasCanonicalSize, + matrix[6] * canvasH / atlasCanonicalSize, + 0, + matrix[8], matrix[9], matrix[10], 0, + matrix[12], matrix[13], matrix[14], 1, + ]; } -function computeExpectedCanonicalMatrix( +function computeExpectedSolidQuadMatrix( vertices: [number, number, number][], tileSize = 50, elev = tileSize, ): number[] { const { matrix, canvasW, canvasH } = computeExpectedPlan(vertices, tileSize, elev); return [ - matrix[0] * canvasW, matrix[1] * canvasW, matrix[2] * canvasW, 0, - matrix[4] * canvasH, matrix[5] * canvasH, matrix[6] * canvasH, 0, + matrix[0] * canvasW / SOLID_QUAD_CANONICAL_SIZE, + matrix[1] * canvasW / SOLID_QUAD_CANONICAL_SIZE, + matrix[2] * canvasW / SOLID_QUAD_CANONICAL_SIZE, + 0, + matrix[4] * canvasH / SOLID_QUAD_CANONICAL_SIZE, + matrix[5] * canvasH / SOLID_QUAD_CANONICAL_SIZE, + matrix[6] * canvasH / SOLID_QUAD_CANONICAL_SIZE, + 0, matrix[8], matrix[9], matrix[10], 0, matrix[12], matrix[13], matrix[14], 1, ]; @@ -283,7 +307,7 @@ describe("renderPoly — matrix math parity", () => { it("vertical quad matrix3d values match expected", () => { const result = renderPoly(VERTICAL_QUAD)!; const actual = extractMatrix(result.element); - const expected = roundedMatrix(computeExpectedCanonicalMatrix(VERTICAL_QUAD.vertices as [number, number, number][])); + const expected = roundedMatrix(computeExpectedSolidQuadMatrix(VERTICAL_QUAD.vertices as [number, number, number][])); expect(actual.length).toBe(16); for (let i = 0; i < 16; i++) expect(actual[i]).toBeCloseTo(expected[i], 6); result.dispose(); @@ -309,7 +333,7 @@ describe("renderPoly — matrix math parity", () => { }; const result = renderPoly(poly, { tileSize: 50, layerElevation: 25 })!; const actual = extractMatrix(result.element); - const expected = roundedMatrix(computeExpectedCanonicalMatrix(poly.vertices as [number, number, number][], 50, 25)); + const expected = roundedMatrix(computeExpectedSolidQuadMatrix(poly.vertices as [number, number, number][], 50, 25)); for (let i = 0; i < 16; i++) expect(actual[i]).toBeCloseTo(expected[i], 6); result.dispose(); }); @@ -331,6 +355,34 @@ describe("renderPolygonsWithTextureAtlas", () => { expect(styleText).not.toContain("background:linear-gradient"); expect(styleText).not.toMatch(/(^|;)width:/); expect(styleText).not.toMatch(/(^|;)height:/); + const matrix = extractMatrix(element); + expect(Math.hypot(matrix[0], matrix[1], matrix[2])).toBeLessThan(2); + expect(Math.hypot(matrix[4], matrix[5], matrix[6])).toBeLessThan(2); + result.dispose(); + }); + + it("falls back to atlas for solid triangles on WebKit", () => { + const canvases: Array<{ width: number; height: number; getContext: () => null }> = []; + const doc = { + defaultView: { + navigator: { + userAgent: "Mozilla/5.0 AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", + }, + CSS: { supports: () => false }, + }, + createElement(tagName: string) { + if (tagName === "canvas") { + const canvas = { width: 0, height: 0, getContext: () => null }; + canvases.push(canvas); + return canvas; + } + return document.createElement(tagName); + }, + } as unknown as Document; + + const result = renderPolygonsWithTextureAtlas([FLAT_TRIANGLE], { doc }); + expect(result.rendered[0].element.tagName.toLowerCase()).toBe("s"); + expect(canvases.length).toBeGreaterThan(0); result.dispose(); }); @@ -399,6 +451,9 @@ describe("renderPolygonsWithTextureAtlas", () => { expect(styleText).not.toMatch(/color:rgb/i); expect(element.style.color).not.toBe(""); expect(element.style.backgroundColor).toBe(""); + const matrix = extractMatrix(element); + expect(Math.hypot(matrix[0], matrix[1], matrix[2])).toBeCloseTo(50 / SOLID_QUAD_CANONICAL_SIZE, 3); + expect(Math.hypot(matrix[4], matrix[5], matrix[6])).toBeCloseTo(50 / SOLID_QUAD_CANONICAL_SIZE, 3); result.dispose(); }); @@ -580,6 +635,46 @@ describe("renderPolygonsWithTextureAtlas", () => { result.dispose(); }); + it("uses the desktop atlas primitive for auto textureQuality", () => { + const texturedPoly: Polygon = { + vertices: FLAT_TRIANGLE.vertices, + texture: "https://example.com/tex.png", + }; + const result = renderPolygonsWithTextureAtlas([texturedPoly], { textureQuality: "auto" }); + const element = result.rendered[0].element; + + expect(element.style.getPropertyValue("--polycss-atlas-size")).toBe("128px"); + expectMatrixClose( + extractMatrix(element), + roundedMatrix(computeExpectedAtlasMatrix(texturedPoly.vertices as [number, number, number][], 50, 50, ATLAS_CANONICAL_SIZE_AUTO_DESKTOP)), + ); + result.dispose(); + }); + + it("keeps the atlas primitive smaller for mobile auto and numeric textureQuality", () => { + const texturedPoly: Polygon = { + vertices: FLAT_TRIANGLE.vertices, + texture: "https://example.com/tex.png", + }; + const doc = { + defaultView: { + matchMedia: (query: string) => ({ + matches: query.includes("pointer: coarse") || query.includes("hover: none"), + }), + }, + createElement(tagName: string) { + return document.createElement(tagName); + }, + } as unknown as Document; + const mobileAuto = renderPolygonsWithTextureAtlas([texturedPoly], { doc, textureQuality: "auto" }); + const numeric = renderPolygonsWithTextureAtlas([texturedPoly], { doc, textureQuality: 1 }); + + expect(mobileAuto.rendered[0].element.style.getPropertyValue("--polycss-atlas-size")).toBe("64px"); + expect(numeric.rendered[0].element.style.getPropertyValue("--polycss-atlas-size")).toBe("64px"); + mobileAuto.dispose(); + numeric.dispose(); + }); + it("uses matrix scale for the fixed border-shape paint box", () => { const obliqueQuad: Polygon = { vertices: [ @@ -624,7 +719,7 @@ describe("renderPolygonsWithTextureAtlas", () => { const result = renderPolygonsWithTextureAtlas([obliqueTriangle], { tileSize: 1 }); const element = result.rendered[0].element; const matrix = extractMatrix(element); - const expected = roundedMatrix(computeExpectedMatrix(obliqueTriangle.vertices as [number, number, number][], 1, 1)); + const expected = roundedMatrix(computeExpectedAtlasMatrix(obliqueTriangle.vertices as [number, number, number][], 1, 1)); expect(element.style.width).toBe(""); expect(element.style.height).toBe(""); @@ -691,7 +786,7 @@ describe("renderPolygonsWithTextureAtlas", () => { const isolated = renderPolygonsWithTextureAtlas([bladeFace], { tileSize: 1 }); const shared = renderPolygonsWithTextureAtlas([bladeFace, bevelFace], { tileSize: 1 }); const sharedMatrix = extractMatrix(shared.rendered[0].element); - const sharedEdgeMatrix = roundedMatrix(computeExpectedMatrix(bladeFace.vertices as [number, number, number][], 1, 1)); + const sharedEdgeMatrix = roundedMatrix(computeExpectedAtlasMatrix(bladeFace.vertices as [number, number, number][], 1, 1)); const isolatedMatrix = extractMatrix(isolated.rendered[0].element); expectColumnDirection(isolatedMatrix, sharedEdgeMatrix, 0); @@ -731,7 +826,7 @@ describe("renderPolygonsWithTextureAtlas", () => { expect(repaired.rendered[0].element.style.height).toBe(""); expectMatrixClose( extractMatrix(repaired.rendered[0].element), - roundedMatrix(computeExpectedMatrix(left.vertices as [number, number, number][], 1, 1)), + roundedMatrix(computeExpectedAtlasMatrix(left.vertices as [number, number, number][], 1, 1, ATLAS_CANONICAL_SIZE_EXPLICIT)), ); expect(repaired.rendered[0].plan?.textureEdgeRepair).toBe(true); @@ -769,11 +864,11 @@ describe("renderPolygonsWithTextureAtlas", () => { expect(repaired.rendered[1].element.style.height).toBe(""); expectMatrixClose( extractMatrix(repaired.rendered[0].element), - roundedMatrix(computeExpectedMatrix(floor.vertices as [number, number, number][], 1, 1)), + roundedMatrix(computeExpectedAtlasMatrix(floor.vertices as [number, number, number][], 1, 1, ATLAS_CANONICAL_SIZE_EXPLICIT)), ); expectMatrixClose( extractMatrix(repaired.rendered[1].element), - roundedMatrix(computeExpectedMatrix(wall.vertices as [number, number, number][], 1, 1)), + roundedMatrix(computeExpectedAtlasMatrix(wall.vertices as [number, number, number][], 1, 1, ATLAS_CANONICAL_SIZE_EXPLICIT)), ); expect(repaired.rendered[0].plan?.textureEdgeRepair).toBe(true); @@ -1330,9 +1425,9 @@ describe("renderPolygonsWithTextureAtlas — strategies.disable", () => { ]); expectPointClose(transformMatrixPoint(matrix, 0, 0), expected[0]); - expectPointClose(transformMatrixPoint(matrix, 1, 0), expected[1]); - expectPointClose(transformMatrixPoint(matrix, 1, 1), expected[2]); - expectPointClose(transformMatrixPoint(matrix, 0, 1), expected[3]); + expectPointClose(transformMatrixPoint(matrix, SOLID_QUAD_CANONICAL_SIZE, 0), expected[1]); + expectPointClose(transformMatrixPoint(matrix, SOLID_QUAD_CANONICAL_SIZE, SOLID_QUAD_CANONICAL_SIZE), expected[2]); + expectPointClose(transformMatrixPoint(matrix, 0, SOLID_QUAD_CANONICAL_SIZE), expected[3]); result.dispose(); }); diff --git a/packages/polycss/src/render/textureAtlas.ts b/packages/polycss/src/render/textureAtlas.ts index c25555a0..1be407d6 100644 --- a/packages/polycss/src/render/textureAtlas.ts +++ b/packages/polycss/src/render/textureAtlas.ts @@ -72,6 +72,8 @@ interface TextureAtlasPlan { layerElevation: number; matrix: string; canonicalMatrix: string; + atlasMatrix: string; + atlasCanonicalSize?: number; projectiveMatrix: string | null; canvasW: number; canvasH: number; @@ -192,11 +194,11 @@ export interface RenderTextureAtlasOptions { ambientLight?: PolyAmbientLight; textureLighting?: PolyTextureLightingMode; /** - * Raster scale for generated atlas pages. `1` keeps one bitmap pixel per CSS - * pixel; lower values reduce atlas memory and encode cost at lower texture - * detail. Numeric values are clamped to 0.1..1. Omitted / `"auto"` picks - * 1 / 0.75 / 0.5 from packed atlas area, then caps oversized runtime - * bitmaps by side length and decoded-memory budget. + * Atlas bitmap budget and CSS sprite size. Numeric values are clamped to + * 0.1..1 and keep the 64px sprite. Omitted / `"auto"` picks a raster scale + * from packed atlas area, caps oversized runtime bitmaps by side length and + * decoded-memory budget, and uses a 128px sprite on desktop-class documents + * or a 64px sprite on mobile-class documents. */ textureQuality?: TextureQuality; solidPaintDefaults?: SolidPaintDefaults; @@ -235,6 +237,10 @@ const SOLID_TRIANGLE_BLEED = 0.75; const DEFAULT_MATRIX_DECIMALS = 3; const DEFAULT_BORDER_SHAPE_DECIMALS = 2; const DEFAULT_ATLAS_CSS_DECIMALS = 4; +const SOLID_QUAD_CANONICAL_SIZE = 64; +const SOLID_TRIANGLE_CANONICAL_SIZE = 64; +const ATLAS_CANONICAL_SIZE_EXPLICIT = 64; +const ATLAS_CANONICAL_SIZE_AUTO_DESKTOP = 128; const BORDER_SHAPE_CENTER_PERCENT = 50; const BORDER_SHAPE_POINT_EPS = 1e-7; const BORDER_SHAPE_CANONICAL_SIZE = 16; @@ -530,12 +536,14 @@ function computeProjectiveQuadMatrix( (weight - 1) * tz + (weight * x - q0[0]) * xAxis[2] + (weight * y - q0[1]) * yAxis[2], ]; - return formatMatrix3dValues([ + const values = [ ...projectiveColumn(q1, w1), g, ...projectiveColumn(q3, w3), h, normal[0], normal[1], normal[2], 0, p0[0], p0[1], p0[2], 1, - ]); + ]; + for (let i = 0; i < 8; i += 1) values[i] /= SOLID_QUAD_CANONICAL_SIZE; + return formatMatrix3dValues(values, 6); } function formatPercent(value: number, decimals = DEFAULT_BORDER_SHAPE_DECIMALS): string { @@ -659,31 +667,89 @@ function packTextureAtlasPlansAuto( } function autoAtlasMaxDecodedBytes(doc: Document | null | undefined): number { - if (!doc) return AUTO_ATLAS_MAX_DECODED_BYTES_DESKTOP; + return isMobileDocument(doc) + ? AUTO_ATLAS_MAX_DECODED_BYTES_MOBILE + : AUTO_ATLAS_MAX_DECODED_BYTES_DESKTOP; +} + +function isMobileDocument(doc: Document | null | undefined): boolean { + if (!doc) return false; const win = doc.defaultView ?? (typeof window !== "undefined" ? window : undefined); const media = win?.matchMedia; - if (!media) return AUTO_ATLAS_MAX_DECODED_BYTES_DESKTOP; + if (!media) return false; // Same device-class heuristic as borderShapeSupported: coarse pointer or // no hover capability = phone/tablet, which has a tight GPU-memory budget // for composited 3D layers. - const isMobile = media("(pointer: coarse)").matches || media("(hover: none)").matches; - return isMobile - ? AUTO_ATLAS_MAX_DECODED_BYTES_MOBILE - : AUTO_ATLAS_MAX_DECODED_BYTES_DESKTOP; + return media("(pointer: coarse)").matches || media("(hover: none)").matches; +} + +function atlasCanonicalSizeForTextureQuality( + textureQualityInput: TextureQuality | undefined, + doc: Document | null | undefined, +): number { + if (textureQualityInput !== undefined && textureQualityInput !== "auto") { + return ATLAS_CANONICAL_SIZE_EXPLICIT; + } + return isMobileDocument(doc) + ? ATLAS_CANONICAL_SIZE_EXPLICIT + : ATLAS_CANONICAL_SIZE_AUTO_DESKTOP; +} + +function formatAtlasMatrix( + entry: TextureAtlasPlan, + atlasCanonicalSize: number, +): string { + const values = entry.matrix.split(",").map((value) => Number(value)); + if (values.length !== 16 || values.some((value) => !Number.isFinite(value))) { + return entry.canonicalMatrix; + } + values[0] *= entry.canvasW / atlasCanonicalSize; + values[1] *= entry.canvasW / atlasCanonicalSize; + values[2] *= entry.canvasW / atlasCanonicalSize; + values[4] *= entry.canvasH / atlasCanonicalSize; + values[5] *= entry.canvasH / atlasCanonicalSize; + values[6] *= entry.canvasH / atlasCanonicalSize; + return formatMatrix3dValues(values); +} + +function applyPackedAtlasCanonicalSize( + packed: PackedAtlas, + atlasCanonicalSize: number, +): PackedAtlas { + for (const entry of packed.entries) { + if (!entry) continue; + entry.atlasCanonicalSize = atlasCanonicalSize; + entry.atlasMatrix = formatAtlasMatrix(entry, atlasCanonicalSize); + } + return packed; +} + +function atlasCanonicalSizeForEntry(entry: TextureAtlasPlan): number { + return entry.atlasCanonicalSize ?? ATLAS_CANONICAL_SIZE_EXPLICIT; } function packTextureAtlasPlansWithScale( plans: Array, textureQualityInput: TextureQuality | undefined, doc: Document | null | undefined, -): { packed: PackedAtlas; atlasScale: number } { +): { packed: PackedAtlas; atlasScale: number; atlasCanonicalSize: number } { + const atlasCanonicalSize = atlasCanonicalSizeForTextureQuality(textureQualityInput, doc); if (textureQualityInput !== undefined && textureQualityInput !== "auto") { const atlasScale = normalizeAtlasScale(textureQualityInput); - return { packed: packTextureAtlasPlans(plans, atlasScale), atlasScale }; + return { + packed: applyPackedAtlasCanonicalSize(packTextureAtlasPlans(plans, atlasScale), atlasCanonicalSize), + atlasScale, + atlasCanonicalSize, + }; } const fullScalePacked = packTextureAtlasPlans(plans, 1); - return packTextureAtlasPlansAuto(plans, fullScalePacked, autoAtlasMaxDecodedBytes(doc)); + const autoPacked = packTextureAtlasPlansAuto(plans, fullScalePacked, autoAtlasMaxDecodedBytes(doc)); + return { + packed: applyPackedAtlasCanonicalSize(autoPacked.packed, atlasCanonicalSize), + atlasScale: autoPacked.atlasScale, + atlasCanonicalSize, + }; } function atlasPadding(atlasScale: number): number { @@ -1515,6 +1581,18 @@ function computeTextureAtlasPlan( normal[0], normal[1], normal[2], 0, tx, ty, tz, 1, ]); + const atlasMatrix = formatMatrix3dValues([ + xAxis[0] * canvasW / ATLAS_CANONICAL_SIZE_EXPLICIT, + xAxis[1] * canvasW / ATLAS_CANONICAL_SIZE_EXPLICIT, + xAxis[2] * canvasW / ATLAS_CANONICAL_SIZE_EXPLICIT, + 0, + yAxis[0] * canvasH / ATLAS_CANONICAL_SIZE_EXPLICIT, + yAxis[1] * canvasH / ATLAS_CANONICAL_SIZE_EXPLICIT, + yAxis[2] * canvasH / ATLAS_CANONICAL_SIZE_EXPLICIT, + 0, + normal[0], normal[1], normal[2], 0, + tx, ty, tz, 1, + ]); const projectiveMatrix = !texture && vertices.length === 4 ? computeProjectiveQuadMatrix( screenPts, @@ -1568,6 +1646,7 @@ function computeTextureAtlasPlan( layerElevation: elev, matrix, canonicalMatrix, + atlasMatrix, projectiveMatrix, canvasW, canvasH, @@ -1732,20 +1811,21 @@ function computeSolidTrianglePlan( const apex = worldPoint(apex2); const baseLeft = worldPoint([baseLeft2[0], baseY]); const baseRight = worldPoint([baseRight2[0], baseY]); + const halfBase = SOLID_TRIANGLE_CANONICAL_SIZE / 2; const xCol: Vec3 = [ - (baseRight[0] - baseLeft[0]) / 2, - (baseRight[1] - baseLeft[1]) / 2, - (baseRight[2] - baseLeft[2]) / 2, + (baseRight[0] - baseLeft[0]) / SOLID_TRIANGLE_CANONICAL_SIZE, + (baseRight[1] - baseLeft[1]) / SOLID_TRIANGLE_CANONICAL_SIZE, + (baseRight[2] - baseLeft[2]) / SOLID_TRIANGLE_CANONICAL_SIZE, ]; const txCol: Vec3 = [ - apex[0] - xCol[0], - apex[1] - xCol[1], - apex[2] - xCol[2], + apex[0] - xCol[0] * halfBase, + apex[1] - xCol[1] * halfBase, + apex[2] - xCol[2] * halfBase, ]; const yCol: Vec3 = [ - baseLeft[0] - txCol[0], - baseLeft[1] - txCol[1], - baseLeft[2] - txCol[2], + (baseLeft[0] - txCol[0]) / SOLID_TRIANGLE_CANONICAL_SIZE, + (baseLeft[1] - txCol[1]) / SOLID_TRIANGLE_CANONICAL_SIZE, + (baseLeft[2] - txCol[2]) / SOLID_TRIANGLE_CANONICAL_SIZE, ]; const canonicalMatrix = formatMatrix3dValues([ xCol[0], xCol[1], xCol[2], 0, @@ -2227,8 +2307,9 @@ function applyAtlasBackground( const url = `url(${page.url})`; const width = entry.canvasW || 1; const height = entry.canvasH || 1; - const pos = `${formatCssLength(-entry.x / width)} ${formatCssLength(-entry.y / height)}`; - const size = `${formatCssLength(page.width / width)} ${formatCssLength(page.height / height)}`; + const atlasCanonicalSize = atlasCanonicalSizeForEntry(entry); + const pos = `${formatCssLength((-entry.x / width) * atlasCanonicalSize)} ${formatCssLength((-entry.y / height) * atlasCanonicalSize)}`; + const size = `${formatCssLength((page.width / width) * atlasCanonicalSize)} ${formatCssLength((page.height / height) * atlasCanonicalSize)}`; if (textureLighting === "dynamic") { setInlineStyleProperty(el, "background-image", url); setInlineStyleProperty(el, "background-position", pos); @@ -2279,14 +2360,6 @@ function applyPolygonDataAttrs(el: HTMLElement, polygon: Polygon): void { ELEMENT_DATA_KEYS.set(el, nextDataKeys); } -function formatPlanElementStyle( - entry: TextureAtlasPlan, - shapeDeclaration?: string, -): string { - const shape = shapeDeclaration ? `;${shapeDeclaration}` : ""; - return `transform:matrix3d(${entry.canonicalMatrix})${shape}`; -} - function formatScaledMatrixFromPlan( entry: TextureAtlasPlan, scaleX: number, @@ -2329,6 +2402,14 @@ function formatBorderShapeMatrix( ); } +function formatSolidQuadMatrix(entry: TextureAtlasPlan): string { + return formatScaledMatrixFromPlan( + entry, + (entry.canvasW || 1) / SOLID_QUAD_CANONICAL_SIZE, + (entry.canvasH || 1) / SOLID_QUAD_CANONICAL_SIZE, + ); +} + function formatBorderShapeElementStyle(entry: TextureAtlasPlan): string { const geometry = borderShapeGeometryForPlan(entry); return [ @@ -2337,18 +2418,6 @@ function formatBorderShapeElementStyle(entry: TextureAtlasPlan): string { ].join(";"); } -function applyPlanElementBase( - el: HTMLElement, - entry: TextureAtlasPlan, - shapeDeclaration?: string, -): void { - el.setAttribute( - "style", - formatPlanElementStyle(entry, shapeDeclaration), - ); - applyPolygonDataAttrs(el, entry.polygon); -} - // Stable topology can reuse the original atlas raster: keep the element's // local 2D texture space fixed, and solve the new matrix from that space to // the updated 3D triangle. @@ -2400,8 +2469,14 @@ function stableMatrixFromPlan( return { normal, matrix: formatMatrix3dValues([ - xAxis[0], xAxis[1], xAxis[2], 0, - yAxis[0], yAxis[1], yAxis[2], 0, + xAxis[0] * source.canvasW / atlasCanonicalSizeForEntry(source), + xAxis[1] * source.canvasW / atlasCanonicalSizeForEntry(source), + xAxis[2] * source.canvasW / atlasCanonicalSizeForEntry(source), + 0, + yAxis[0] * source.canvasH / atlasCanonicalSizeForEntry(source), + yAxis[1] * source.canvasH / atlasCanonicalSizeForEntry(source), + yAxis[2] * source.canvasH / atlasCanonicalSizeForEntry(source), + 0, normal[0], normal[1], normal[2], 0, tx, ty, tz, 1, ]), @@ -2510,6 +2585,16 @@ function borderShapeSupported(doc: Document): boolean { return media("(pointer: fine)").matches && media("(hover: hover)").matches; } +function solidTriangleSupported(doc: Document): boolean { + const win = doc.defaultView ?? (typeof window !== "undefined" ? window : undefined); + const userAgent = win?.navigator?.userAgent ?? ""; + if (!userAgent) return true; + + const isChromiumFamily = /\b(?:Chrome|HeadlessChrome|Chromium|Edg|OPR)\//.test(userAgent); + const isSafariFamily = /\bVersion\/[\d.]+.*\bSafari\//.test(userAgent); + return !isSafariFamily || isChromiumFamily; +} + function incrementCount(map: Map, key: string): void { map.set(key, (map.get(key) ?? 0) + 1); } @@ -2537,7 +2622,7 @@ function getSolidPaintDefaultsForPlans( const dynamicColors = new Map(); const useFullRectSolid = !disabled.has("b"); const useProjectiveQuad = useFullRectSolid; - const useStableTriangle = !disabled.has("u"); + const useStableTriangle = !disabled.has("u") && solidTriangleSupported(doc); const useBorderShape = !disabled.has("i") && borderShapeSupported(doc); for (const plan of plans) { @@ -2723,7 +2808,8 @@ function createSolidElement( solidPaintDefaults?: SolidPaintDefaults, ): HTMLElement { const el = doc.createElement("b"); - applyPlanElementBase(el, entry); + el.setAttribute("style", `transform:matrix3d(${formatSolidQuadMatrix(entry)})`); + applyPolygonDataAttrs(el, entry.polygon); applySolidPaint(el, entry, textureLighting, solidPaintDefaults); return el; @@ -2763,10 +2849,13 @@ function createAtlasElement( doc: Document, ): HTMLElement { const el = doc.createElement("s"); - applyPlanElementBase(el, entry); + el.setAttribute("style", `transform:matrix3d(${entry.atlasMatrix})`); + applyPolygonDataAttrs(el, entry.polygon); const width = entry.canvasW || 1; const height = entry.canvasH || 1; - setInlineStyleProperty(el, "background-position", `${formatCssLength(-entry.x / width)} ${formatCssLength(-entry.y / height)}`); + const atlasCanonicalSize = atlasCanonicalSizeForEntry(entry); + setInlineStyleProperty(el, "--polycss-atlas-size", `${atlasCanonicalSize}px`); + setInlineStyleProperty(el, "background-position", `${formatCssLength((-entry.x / width) * atlasCanonicalSize)} ${formatCssLength((-entry.y / height) * atlasCanonicalSize)}`); setInlineStyleProperty(el, "opacity", "0"); if (textureLighting === "dynamic") applyDynamicNormalVars(el, entry); @@ -2784,7 +2873,7 @@ export function renderPolygonsWithTextureAtlas( const disabled = new Set(options.strategies?.disable ?? []); const useFullRectSolid = !disabled.has("b"); const useProjectiveQuad = useFullRectSolid; - const useStableTriangle = !disabled.has("u"); + const useStableTriangle = !disabled.has("u") && solidTriangleSupported(doc); const useBorderShape = !disabled.has("i") && borderShapeSupported(doc); const basisHints = buildBasisHints(polygons, options); const projectiveQuadGuards = resolveProjectiveQuadGuards(doc); @@ -2817,7 +2906,7 @@ export function renderPolygonsWithTextureAtlas( if (entry) { const element = createAtlasElement(entry, textureLighting, doc); atlasElements.set(i, element); - rendered.push({ polygonIndex: i, element, kind: "atlas", plan, dispose: () => {} }); + rendered.push({ polygonIndex: i, element, kind: "atlas", plan: entry, dispose: () => {} }); } else if (!plan.texture && useFullRectSolid && isFullRectSolid(plan)) { const element = createSolidElement(plan, textureLighting, doc, options.solidPaintDefaults); rendered.push({ polygonIndex: i, element, kind: "solid", plan, dispose: () => {} }); @@ -2888,7 +2977,7 @@ export async function renderPolygonsWithTextureAtlasAsync( const disabled = new Set(options.strategies?.disable ?? []); const useFullRectSolid = !disabled.has("b"); const useProjectiveQuad = useFullRectSolid; - const useStableTriangle = !disabled.has("u"); + const useStableTriangle = !disabled.has("u") && solidTriangleSupported(doc); const useBorderShape = !disabled.has("i") && borderShapeSupported(doc); await yieldToMainThread(); if (shouldCancel()) return { rendered: [], solidPaintDefaults: {}, dispose: () => {} }; @@ -2936,7 +3025,7 @@ export async function renderPolygonsWithTextureAtlasAsync( if (entry) { const element = createAtlasElement(entry, textureLighting, doc); atlasElements.set(i, element); - rendered.push({ polygonIndex: i, element, kind: "atlas", plan, dispose: () => {} }); + rendered.push({ polygonIndex: i, element, kind: "atlas", plan: entry, dispose: () => {} }); } else if (!plan.texture && useFullRectSolid && isFullRectSolid(plan)) { const element = createSolidElement(plan, textureLighting, doc, solidPaintDefaults); rendered.push({ polygonIndex: i, element, kind: "solid", plan, dispose: () => {} }); @@ -3052,6 +3141,7 @@ export function renderPolygonsWithStableTriangles( ): RenderTextureAtlasResult | null { const doc = options.doc ?? (typeof document !== "undefined" ? document : null); if (!doc) return { rendered: [], dispose: () => {} }; + if (!solidTriangleSupported(doc)) return null; const plans = computeStableSolidTriangles(polygons, options); if (!plans) return null; @@ -3077,6 +3167,7 @@ export function updatePolygonsWithStableTriangles( ): RenderTextureAtlasResult | null { const doc = options.doc ?? (typeof document !== "undefined" ? document : null); if (!doc) return { rendered, dispose: () => {} }; + if (!solidTriangleSupported(doc)) return null; if (rendered.some((item) => item.kind !== "triangle")) return null; const plans = computeStableSolidTriangles(polygons, options); diff --git a/packages/polycss/src/render/voxelSliceRenderer.ts b/packages/polycss/src/render/voxelRenderer.ts similarity index 55% rename from packages/polycss/src/render/voxelSliceRenderer.ts rename to packages/polycss/src/render/voxelRenderer.ts index a15a7a9c..ba8846dc 100644 --- a/packages/polycss/src/render/voxelSliceRenderer.ts +++ b/packages/polycss/src/render/voxelRenderer.ts @@ -3,36 +3,28 @@ import type { PolyAmbientLight, PolyDirectionalLight, PolyVoxelFace, - PolyVoxelSource, - PolyVoxelSlicePlan, Polygon, Vec3, } from "@layoutit/polycss-core"; import { BASE_TILE, - buildPolyVoxelFaceData, - buildPolyVoxelSlicePlan, - POLY_VOXEL_NEXT_LAYER_STEP, normalFacesCamera, parsePureColor, + rotateVec3, } from "@layoutit/polycss-core"; type Axis = "x" | "y" | "z"; interface BrushState { - left?: string; - top?: string; - width?: string; - height?: string; color?: string; - zOffset?: string; + transform?: string; } type BrushElement = HTMLElement & { __polycssVoxelBrushState?: BrushState; }; -export interface PolyVoxelSliceRenderer { +export interface PolyVoxelRenderer { readonly element: HTMLElement; readonly brushCount: number; render(rotation: CameraCullRotation): void; @@ -40,10 +32,9 @@ export interface PolyVoxelSliceRenderer { dispose(): void; } -export interface PolyVoxelSliceRendererOptions { +export interface PolyVoxelRendererOptions { doc: Document; wrapper: HTMLElement; - source: PolyVoxelSource; polygons?: readonly Polygon[]; directionalLight?: PolyDirectionalLight; ambientLight?: PolyAmbientLight; @@ -51,17 +42,16 @@ export interface PolyVoxelSliceRendererOptions { interface RGB { r: number; g: number; b: number; alpha: number; } -interface BrushPlan { +interface DirectMatrixItem { axis: Axis; face: PolyVoxelFace; - brushes: Array<{ - left: number; - top: number; - width: number; - height: number; - z: number; - baseColor: string; - }>; + left: number; + top: number; + width: number; + height: number; + z: number; + baseColor: string; + sourceIndex: number; } const DEFAULT_LIGHT_DIR: Vec3 = [0.4, -0.7, 0.59]; @@ -100,45 +90,20 @@ function visibleFaceSignature(rotation: CameraCullRotation): string { function applyBrush( el: BrushElement, - left: string, - top: string, - width: string, - height: string, color: string, - zOffset: string, + transform: string, ): void { const state = (el.__polycssVoxelBrushState ??= {}); - if (state.left !== left) { - el.style.left = left; - state.left = left; - } - if (state.top !== top) { - el.style.top = top; - state.top = top; - } - if (state.width !== width) { - el.style.width = width; - state.width = width; - } - if (state.height !== height) { - el.style.height = height; - state.height = height; - } if (state.color !== color) { el.style.color = color; state.color = color; } - if (state.zOffset !== zOffset) { - el.style.transform = `translateZ(${zOffset})`; - state.zOffset = zOffset; + if (state.transform !== transform) { + el.style.transform = transform; + state.transform = transform; } } -function planBrushZ(plan: PolyVoxelSlicePlan, cellPx: number): string { - const plane = plan.key.plane * cellPx; - return plan.key.axis === "z" ? `${plane}px` : `${-plane}px`; -} - function cssNormalForPolygon(polygon: Polygon): Vec3 | null { const vertices = polygon.vertices; if (vertices.length < 3) return null; @@ -168,10 +133,7 @@ function cssNormalForPolygon(polygon: Polygon): Vec3 | null { ]; } -function polygonBrush(polygon: Polygon): (BrushPlan["brushes"][number] & { - axis: Axis; - face: PolyVoxelFace; -}) | null { +function polygonBrush(polygon: Polygon): Omit | null { if (polygon.texture || polygon.material || polygon.uvs || polygon.textureTriangles) return null; if (polygon.vertices.length !== 4) return null; const normal = cssNormalForPolygon(polygon); @@ -286,125 +248,135 @@ function shadeBrushColor( : rgbToHex(shaded); } -function buildMergedPlans(source: PolyVoxelSource, cellPx: number): BrushPlan[] { - const faces = buildPolyVoxelFaceData(source); - const faceIndex = new Map(); - for (const face of faces) { - faceIndex.set(`${face.key.axis}:${face.key.plane}:${face.key.face}`, face); - } - return faces.map((face): BrushPlan => { - const nextPlane = face.key.plane + POLY_VOXEL_NEXT_LAYER_STEP[face.key.face]; - const nextFace = faceIndex.get(`${face.key.axis}:${nextPlane}:${face.key.face}`); - const plan = buildPolyVoxelSlicePlan(face, nextFace?.buffer ?? null); - const z = Number.parseFloat(planBrushZ(plan, cellPx)); - return { - axis: plan.key.axis, - face: plan.key.face, - brushes: plan.brushes.map((brush) => ({ - left: (plan.buffer.minCol + brush.c0) * cellPx, - top: (plan.buffer.minRow + brush.r0) * cellPx, - width: (brush.c1 - brush.c0) * cellPx, - height: (brush.r1 - brush.r0) * cellPx, - z, - baseColor: brush.baseColor, - })), - }; - }); -} - -function buildPolygonPlans(polygons: readonly Polygon[] | undefined): BrushPlan[] { +function buildDirectMatrixItems(polygons: readonly Polygon[] | undefined): DirectMatrixItem[] { if (!polygons?.length) return []; - const plans = new Map(); - let accepted = 0; - for (const polygon of polygons) { + const items: DirectMatrixItem[] = []; + for (let sourceIndex = 0; sourceIndex < polygons.length; sourceIndex += 1) { + const polygon = polygons[sourceIndex]; const brush = polygonBrush(polygon); - if (!brush || brush.width <= 0 || brush.height <= 0) continue; - accepted += 1; - const key = `${brush.axis}:${brush.face}`; - let plan = plans.get(key); - if (!plan) { - plan = { axis: brush.axis, face: brush.face, brushes: [] }; - plans.set(key, plan); - } - plan.brushes.push({ - left: brush.left, - top: brush.top, - width: brush.width, - height: brush.height, - z: brush.z, - baseColor: brush.baseColor, + if (!brush || brush.width <= 0 || brush.height <= 0) return []; + items.push({ + ...brush, + sourceIndex, }); } - return accepted === polygons.length ? Array.from(plans.values()) : []; + return items; } -function configureHost( - host: HTMLElement, +function directMatrix( + axis: Axis, + left: number, + top: number, width: number, height: number, -): void { - host.style.width = `${width}px`; - host.style.height = `${height}px`; + zOffset: number, +): string { + const values = axis === "x" + ? [ + width, 0, 0, 0, + 0, 0, height, 0, + 0, -1, 0, 0, + left, -zOffset, top, 1, + ] + : axis === "y" + ? [ + 0, 0, width, 0, + 0, height, 0, 0, + -1, 0, 0, 0, + -zOffset, top, left, 1, + ] + : [ + width, 0, 0, 0, + 0, height, 0, 0, + 0, 0, 1, 0, + left, top, zOffset, 1, + ]; + return `matrix3d(${values.map((value) => Number(value.toFixed(6))).join(",")})`; } -export function createPolyVoxelSliceRenderer( - options: PolyVoxelSliceRendererOptions, -): PolyVoxelSliceRenderer { - const { doc, wrapper, source, polygons, directionalLight, ambientLight } = options; - const cellPx = Math.max(1, Math.round(source.scale * BASE_TILE)); - const polygonPlans = buildPolygonPlans(polygons); - const plans = polygonPlans.length > 0 - ? polygonPlans - : buildMergedPlans(source, cellPx); - const shiftPx = polygonPlans.length > 0 ? 0 : source.gridShift * BASE_TILE; - const colorCache = new Map(); +function itemCenter(item: DirectMatrixItem): Vec3 { + if (item.axis === "x") { + return [item.left + item.width / 2, -item.z, item.top + item.height / 2]; + } + if (item.axis === "y") { + return [-item.z, item.top + item.height / 2, item.left + item.width / 2]; + } + return [item.left + item.width / 2, item.top + item.height / 2, item.z]; +} - const hosts: Record = { - z: doc.createElement("div"), - x: doc.createElement("div"), - y: doc.createElement("div"), - }; - hosts.z.className = "polycss-voxel-host polycss-voxel-host-z"; - hosts.x.className = "polycss-voxel-host polycss-voxel-host-x"; - hosts.y.className = "polycss-voxel-host polycss-voxel-host-y"; - const shiftTransform = shiftPx !== 0 - ? `translate3d(${shiftPx}px, ${shiftPx}px, ${shiftPx}px) ` - : ""; - hosts.z.style.transform = shiftTransform.trim(); - hosts.x.style.transform = `${shiftTransform}rotateX(90deg)`; - hosts.y.style.transform = `${shiftTransform}rotateY(-90deg)`; - wrapper.append(hosts.z, hosts.x, hosts.y); - - configureHost( - hosts.z, - source.cols * cellPx, - source.rows * cellPx, - ); - configureHost( - hosts.x, - source.cols * cellPx, - source.depth * cellPx, - ); - configureHost( - hosts.y, - source.depth * cellPx, - source.rows * cellPx, - ); - - const pools: Record = { z: [], x: [], y: [] }; - let lastSignature = ""; - let mountedBrushCount = 0; +function projectedPoint(item: DirectMatrixItem, rotation: CameraCullRotation): { x: number; y: number } { + let center = itemCenter(item); + const meshRotation = rotation.meshRotation; + if (meshRotation) { + center = rotateVec3(center, meshRotation[0] ?? 0, meshRotation[1] ?? 0, meshRotation[2] ?? 0); + } + const [x, y] = rotateVec3(center, rotation.rotX, 0, rotation.rotY); + return { x, y }; +} - const nextBrush = (axis: Axis, index: number): BrushElement => { - let el = pools[axis][index]; - if (!el) { - el = doc.createElement("b") as BrushElement; - pools[axis][index] = el; +function orderDirectMatrixItems( + items: readonly DirectMatrixItem[], + visibleFaces: Set, + rotation: CameraCullRotation, +): DirectMatrixItem[] { + const entries = items + .filter((item) => visibleFaces.has(item.face)) + .map((item) => ({ item, ...projectedPoint(item, rotation) })); + if (entries.length === 0) return []; + + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (const entry of entries) { + minX = Math.min(minX, entry.x); + maxX = Math.max(maxX, entry.x); + minY = Math.min(minY, entry.y); + maxY = Math.max(maxY, entry.y); + } + + const tileCount = 4; + const spanX = Math.max(1e-6, maxX - minX); + const spanY = Math.max(1e-6, maxY - minY); + const tiles = new Map(); + for (const entry of entries) { + const tx = Math.min( + tileCount - 1, + Math.max(0, Math.floor(((entry.x - minX) / spanX) * tileCount)), + ); + const ty = Math.min( + tileCount - 1, + Math.max(0, Math.floor(((entry.y - minY) / spanY) * tileCount)), + ); + const key = `${tx}:${ty}`; + let tile = tiles.get(key); + if (!tile) { + tile = { tx, ty, sourceIndex: entry.item.sourceIndex, items: [] }; + tiles.set(key, tile); } - if (el.parentElement !== hosts[axis]) hosts[axis].appendChild(el); - return el; - }; + tile.items.push(entry.item); + tile.sourceIndex = Math.min(tile.sourceIndex, entry.item.sourceIndex); + } + + return Array.from(tiles.values()) + .sort((a, b) => (a.ty - b.ty) || (a.tx - b.tx) || a.sourceIndex - b.sourceIndex) + .flatMap((tile) => tile.items); +} + +export function createPolyVoxelRenderer( + options: PolyVoxelRendererOptions, +): PolyVoxelRenderer | null { + const { doc, wrapper, polygons, directionalLight, ambientLight } = options; + const directMatrixItems = buildDirectMatrixItems(polygons); + if (directMatrixItems.length === 0) return null; + wrapper.classList.add("polycss-voxel-mesh"); + const colorCache = new Map(); const shadedColor = (face: PolyVoxelFace, baseColor: string): string => { const key = `${face}|${baseColor}`; const cached = colorCache.get(key); @@ -414,65 +386,55 @@ export function createPolyVoxelSliceRenderer( return shaded; }; - const draw = (signature: string): void => { - const visibleFaces = new Set(signature.split("|").filter(Boolean) as PolyVoxelFace[]); - const used: Record = { z: 0, x: 0, y: 0 }; - mountedBrushCount = 0; + const pool: BrushElement[] = []; + let lastSignature = ""; + let mountedBrushCount = 0; - for (const plan of plans) { - const axis = plan.axis; - if (!visibleFaces.has(plan.face)) continue; - for (const brush of plan.brushes) { - const left = `${brush.left}px`; - const top = `${brush.top}px`; - const width = `${brush.width}px`; - const height = `${brush.height}px`; - const zOffset = `${brush.z}px`; - const el = nextBrush(axis, used[axis]); - used[axis] += 1; - applyBrush( - el, - left, - top, - width, - height, - shadedColor(plan.face, brush.baseColor), - zOffset, - ); - mountedBrushCount += 1; - } + const nextBrush = (index: number): BrushElement => { + let el = pool[index]; + if (!el) { + el = doc.createElement("b") as BrushElement; + pool[index] = el; } + if (el.parentElement !== wrapper) wrapper.appendChild(el); + return el; + }; - for (const axis of Object.keys(pools) as Axis[]) { - const pool = pools[axis]; - for (let i = used[axis]; i < pool.length; i += 1) pool[i]?.remove(); + const draw = (signature: string, rotation: CameraCullRotation): void => { + const visibleFaces = new Set(signature.split("|").filter(Boolean) as PolyVoxelFace[]); + const orderedItems = orderDirectMatrixItems(directMatrixItems, visibleFaces, rotation); + mountedBrushCount = 0; + for (const item of orderedItems) { + const el = nextBrush(mountedBrushCount); + applyBrush( + el, + shadedColor(item.face, item.baseColor), + directMatrix(item.axis, item.left, item.top, item.width, item.height, item.z), + ); + mountedBrushCount += 1; } + for (let i = mountedBrushCount; i < pool.length; i += 1) pool[i]?.remove(); }; - const renderer: PolyVoxelSliceRenderer = { - element: hosts.z, + return { + element: wrapper, get brushCount() { return mountedBrushCount; }, render(rotation: CameraCullRotation) { lastSignature = visibleFaceSignature(rotation); - draw(lastSignature); + draw(lastSignature, rotation); }, syncCamera(rotation: CameraCullRotation) { const nextSignature = visibleFaceSignature(rotation); if (nextSignature === lastSignature) return; lastSignature = nextSignature; - draw(nextSignature); + draw(nextSignature, rotation); }, dispose() { - hosts.z.remove(); - hosts.x.remove(); - hosts.y.remove(); - pools.x.length = 0; - pools.y.length = 0; - pools.z.length = 0; + for (const el of pool) el.remove(); + wrapper.classList.remove("polycss-voxel-mesh"); + pool.length = 0; mountedBrushCount = 0; lastSignature = ""; }, }; - - return renderer; } diff --git a/packages/polycss/src/styles/styles.ts b/packages/polycss/src/styles/styles.ts index 10bee340..24a00d2f 100644 --- a/packages/polycss/src/styles/styles.ts +++ b/packages/polycss/src/styles/styles.ts @@ -94,8 +94,17 @@ const CORE_BASE_STYLES = ` .polycss-scene b { background: currentColor; + width: 64px; + height: 64px; +} + +.polycss-mesh.polycss-voxel-mesh > b { + top: 0; + left: 0; width: 1px; height: 1px; + backface-visibility: visible; + pointer-events: none; } .polycss-scene i { @@ -105,8 +114,8 @@ const CORE_BASE_STYLES = ` } .polycss-scene s { - width: 1px; - height: 1px; + width: var(--polycss-atlas-size, 64px); + height: var(--polycss-atlas-size, 64px); } .polycss-scene u { @@ -116,7 +125,7 @@ const CORE_BASE_STYLES = ` box-sizing: content-box; border: 0 solid transparent; border-color: transparent transparent currentColor transparent; - border-width: 0 1px 1px 1px; + border-width: 0 32px 64px 32px; } /* — dedicated shadow leaf. Same border-shape rendering trick as @@ -150,36 +159,6 @@ const CORE_BASE_STYLES = ` content: none; } -/* ── Voxel slice-brush fast path ────────────────────────────────────────── */ - -.polycss-voxel-host { - position: absolute; - top: 0; - left: 0; - transform-origin: 0 0; - transform-style: preserve-3d; - pointer-events: none; -} - -.polycss-voxel-host-x { - transform: rotateX(90deg); -} - -.polycss-voxel-host-y { - transform: rotateY(-90deg); -} - -.polycss-voxel-host b { - position: absolute; - display: block; - overflow: visible; - transform-origin: 0 0; - transform-style: preserve-3d; - backface-visibility: visible; - pointer-events: none; - background-repeat: no-repeat; -} - /* ── Gizmo override (createTransformControls) ───────────────────────────── */ /* diff --git a/packages/react/README.md b/packages/react/README.md index 66f4a40e..c59d3229 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -41,7 +41,7 @@ Root of every React polycss render tree. Renders polygons and meshes inside a `< | `directionalLight` | `PolyDirectionalLight` | None | Directional light config | | `ambientLight` | `PolyAmbientLight` | None | Ambient light config | | `textureLighting` | `"baked" \| "dynamic"` | `"baked"` | Texture lighting mode | -| `textureQuality` | `number \| "auto"` | `"auto"` | Raster scale for generated atlas pages | +| `textureQuality` | `number \| "auto"` | `"auto"` | Atlas bitmap budget and compositor sprite size | | `polygons` | `Polygon[]` | None | Static polygon array (composes with `children`) | | `children` | `ReactNode` | None | ``, ``, and/or `` | @@ -58,7 +58,7 @@ Loads a mesh from a URL and renders its polygons. Manages blob-URL lifecycle aut | `position` | `Vec3` | `[x, y, z]` offset in scene space | | `scale` | `number \| Vec3` | Uniform or per-axis scale | | `rotation` | `Vec3` | Euler angles in degrees `[x, y, z]` | -| `textureQuality` | `number \| "auto"` | Raster scale for generated atlas pages | +| `textureQuality` | `number \| "auto"` | Atlas bitmap budget and compositor sprite size | | `autoCenter` | `boolean` | Shift mesh so its bbox center is at origin | | `mtl` | `string` | Companion `.mtl` URL for OBJ models | | `parseOptions` | `UseMeshOptions` | Forwarded to `loadMesh`; `meshResolution` defaults to `"lossy"` | @@ -80,7 +80,7 @@ Single polygon. The atomic primitive: renders one atlas-backed `` for UV-text | `position` | `Vec3` | Local offset | | `scale` | `number \| Vec3` | Scale | | `rotation` | `Vec3` | Euler rotation in degrees | -| `textureQuality` | `number \| "auto"` | Raster scale for generated atlas pages | +| `textureQuality` | `number \| "auto"` | Atlas bitmap budget and compositor sprite size | | `onClick` | `MouseEventHandler` | Standard DOM event handler | | `onMouseEnter` | `MouseEventHandler` | | | `className` | `string` | CSS class | diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index ec9990aa..e59c8595 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -110,6 +110,9 @@ export type { AutoRotateOption, AutoRotateConfig, AxesHelperOptions, + BoxFace, + BoxFaceOptions, + BoxPolygonsOptions, ArrowPolygonsOptions, RingPolygonsOptions, OctahedronPolygonsOptions, @@ -165,6 +168,7 @@ export { rotateVec3, inverseRotateVec3, axesHelperPolygons, + boxPolygons, arrowPolygons, ringPolygons, octahedronPolygons, diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx index 8b669e01..5af0230e 100644 --- a/packages/react/src/scene/PolyMesh.tsx +++ b/packages/react/src/scene/PolyMesh.tsx @@ -88,9 +88,10 @@ export interface PolyMeshProps extends TransformProps, InteractionProps { autoCenter?: boolean; /** Textured polygon lighting mode. Defaults to "baked". */ textureLighting?: PolyTextureLightingMode; - /** Raster scale for generated atlas pages. `"auto"` (default) downscales to - * a device-appropriate memory budget (~4 MB mobile / ~16 MB desktop). - * Numeric values 0.1..1 force an explicit scale. */ + /** Atlas bitmap budget and CSS sprite size. `"auto"` (default) uses a + * device-appropriate memory budget (~4 MB mobile / ~16 MB desktop) and + * desktop/mobile sprite sizing. Numeric values 0.1..1 force an explicit + * raster scale and the 64px sprite. */ textureQuality?: TextureQuality; /** Per-polygon override render, or static children mounted inside the mesh wrapper. */ children?: ((polygon: Polygon, index: number) => ReactNode) | ReactNode; diff --git a/packages/react/src/scene/PolyScene.tsx b/packages/react/src/scene/PolyScene.tsx index 91f11ac7..9c1c730f 100644 --- a/packages/react/src/scene/PolyScene.tsx +++ b/packages/react/src/scene/PolyScene.tsx @@ -52,9 +52,10 @@ export interface PolySceneProps extends TransformProps { ambientLight?: PolyAmbientLight; /** Textured polygon lighting mode. Defaults to "baked". */ textureLighting?: PolyTextureLightingMode; - /** Raster scale for generated atlas pages. `"auto"` (default) downscales to - * a device-appropriate memory budget (~4 MB mobile / ~16 MB desktop). - * Numeric values 0.1..1 force an explicit scale. */ + /** Atlas bitmap budget and CSS sprite size. `"auto"` (default) uses a + * device-appropriate memory budget (~4 MB mobile / ~16 MB desktop) and + * desktop/mobile sprite sizing. Numeric values 0.1..1 force an explicit + * raster scale and the 64px sprite. */ textureQuality?: TextureQuality; /** * Render strategy overrides. Use `{ disable: ["u"] }` to force solid diff --git a/packages/react/src/scene/textureAtlas.test.tsx b/packages/react/src/scene/textureAtlas.test.tsx index 4c3c6b03..4fc24bd3 100644 --- a/packages/react/src/scene/textureAtlas.test.tsx +++ b/packages/react/src/scene/textureAtlas.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { afterEach, describe, it, expect, vi } from "vitest"; import React, { act } from "react"; import { createRoot } from "react-dom/client"; import { @@ -6,11 +6,15 @@ import { computeTextureAtlasPlan, isSolidTrianglePlan, useTextureAtlas, + type TextureQuality, type TextureAtlasPlan, type TextureAtlasResult, } from "./textureAtlas"; import type { Polygon } from "@layoutit/polycss-core"; +const originalMatchMedia = window.matchMedia; +const originalUserAgent = window.navigator.userAgent; + const TEXTURED_QUAD_60: Polygon = { vertices: [ [0, 0, 0], @@ -28,17 +32,19 @@ function planFor(polygon: Polygon, index = 0): TextureAtlasPlan | null { function Harness({ plans, + textureQuality, onResult, }: { plans: Array; + textureQuality?: TextureQuality; onResult: (result: TextureAtlasResult) => void; }) { - const atlas = useTextureAtlas(plans, "baked"); + const atlas = useTextureAtlas(plans, "baked", textureQuality); onResult(atlas); return null; } -function renderAtlas(plans: Array): TextureAtlasResult { +function renderAtlas(plans: Array, textureQuality?: TextureQuality): TextureAtlasResult { let captured: TextureAtlasResult | null = null; const container = document.createElement("div"); const root = createRoot(container); @@ -46,6 +52,7 @@ function renderAtlas(plans: Array): TextureAtlasResult root.render( React.createElement(Harness, { plans, + textureQuality, onResult: (r) => { captured = r; }, @@ -56,6 +63,48 @@ function renderAtlas(plans: Array): TextureAtlasResult return captured!; } +function stubMatchMedia(mobile: boolean): void { + Object.defineProperty(window, "matchMedia", { + configurable: true, + writable: true, + value: vi.fn((query: string) => ({ + matches: mobile && (query.includes("pointer: coarse") || query.includes("hover: none")), + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +function stubUserAgent(userAgent: string): void { + Object.defineProperty(window.navigator, "userAgent", { + configurable: true, + value: userAgent, + }); +} + +function stubBorderShapeUnsupported(): void { + vi.stubGlobal("CSS", { supports: () => false }); +} + +afterEach(() => { + Object.defineProperty(window, "matchMedia", { + configurable: true, + writable: true, + value: originalMatchMedia, + }); + Object.defineProperty(window.navigator, "userAgent", { + configurable: true, + value: originalUserAgent, + }); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + describe("computeTextureAtlasPlan", () => { it("returns a plan for a textured quad", () => { const plan = planFor(TEXTURED_QUAD_60); @@ -163,6 +212,18 @@ describe("useTextureAtlas", () => { expect(atlas.entries.length).toBe(0); }); + it("packs solid triangles into the atlas on WebKit", () => { + stubUserAgent("Mozilla/5.0 AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"); + stubBorderShapeUnsupported(); + const tri: Polygon = { + vertices: [[0, 0, 0], [1, 0, 0], [0, 1, 0]], + color: "#ff0000", + }; + + const atlas = renderAtlas([planFor(tri)]); + expect(atlas.entries[0]).not.toBeNull(); + }); + it("filters out null plan entries (degenerate polygons)", () => { const plans: Array = [...buildSixFaceCrateScene(), null]; const atlas = renderAtlas(plans); @@ -170,4 +231,19 @@ describe("useTextureAtlas", () => { expect(atlas.entries.length).toBe(plans.length); expect(atlas.entries[atlas.entries.length - 1]).toBeNull(); }); + + it("sets the atlas primitive from auto and numeric textureQuality", () => { + const plans = [planFor(TEXTURED_QUAD_60)]; + + stubMatchMedia(false); + const desktop = renderAtlas(plans, "auto"); + expect(desktop.entries[0]?.atlasCanonicalSize).toBe(128); + + stubMatchMedia(true); + const mobile = renderAtlas(plans, "auto"); + expect(mobile.entries[0]?.atlasCanonicalSize).toBe(64); + + const explicit = renderAtlas(plans, 1); + expect(explicit.entries[0]?.atlasCanonicalSize).toBe(64); + }); }); diff --git a/packages/react/src/scene/textureAtlas.tsx b/packages/react/src/scene/textureAtlas.tsx index d6a2b3b4..07093566 100644 --- a/packages/react/src/scene/textureAtlas.tsx +++ b/packages/react/src/scene/textureAtlas.tsx @@ -35,11 +35,16 @@ const MAX_ATLAS_SCALE = 1; const AUTO_ATLAS_LOW_AREA = ATLAS_MAX_SIZE * ATLAS_MAX_SIZE; const AUTO_ATLAS_MEDIUM_AREA = AUTO_ATLAS_LOW_AREA * 3; const AUTO_ATLAS_MAX_BITMAP_SIDE = 2048; -const AUTO_ATLAS_MAX_DECODED_BYTES = 16 * 1024 * 1024; +const AUTO_ATLAS_MAX_DECODED_BYTES_MOBILE = 4 * 1024 * 1024; +const AUTO_ATLAS_MAX_DECODED_BYTES_DESKTOP = 16 * 1024 * 1024; const AUTO_ATLAS_SCALE_GUARD = 0.995; const DEFAULT_MATRIX_DECIMALS = 3; const DEFAULT_BORDER_SHAPE_DECIMALS = 2; const DEFAULT_ATLAS_CSS_DECIMALS = 4; +const SOLID_QUAD_CANONICAL_SIZE = 64; +const SOLID_TRIANGLE_CANONICAL_SIZE = 64; +const ATLAS_CANONICAL_SIZE_EXPLICIT = 64; +const ATLAS_CANONICAL_SIZE_AUTO_DESKTOP = 128; const BORDER_SHAPE_CENTER_PERCENT = 50; const BORDER_SHAPE_POINT_EPS = 1e-7; const BORDER_SHAPE_CANONICAL_SIZE = 16; @@ -91,6 +96,8 @@ export interface TextureAtlasPlan { layerElevation: number; matrix: string; canonicalMatrix: string; + atlasMatrix: string; + atlasCanonicalSize?: number; projectiveMatrix: string | null; canvasW: number; canvasH: number; @@ -245,7 +252,7 @@ function atlasArea(pages: PackedPage[]): number { return pages.reduce((sum, page) => sum + page.width * page.height, 0); } -function autoAtlasScaleCap(pages: PackedPage[]): number { +function autoAtlasScaleCap(pages: PackedPage[], maxDecodedBytes: number): number { const area = atlasArea(pages); if (area <= 0) return 1; @@ -254,18 +261,73 @@ function autoAtlasScaleCap(pages: PackedPage[]): number { ...pages.map((page) => Math.max(page.width, page.height)), ); const sideScale = AUTO_ATLAS_MAX_BITMAP_SIDE / maxSide; - const memoryScale = Math.sqrt(AUTO_ATLAS_MAX_DECODED_BYTES / (area * 4)); + const memoryScale = Math.sqrt(maxDecodedBytes / (area * 4)); return normalizeAtlasScale(Math.min(sideScale, memoryScale)); } -function autoAtlasScale(pages: PackedPage[]): number { +function isMobileDocument(doc: Document | null | undefined): boolean { + if (!doc) return false; + const win = doc.defaultView ?? (typeof window !== "undefined" ? window : undefined); + const media = win?.matchMedia; + if (!media) return false; + return media("(pointer: coarse)").matches || media("(hover: none)").matches; +} + +function autoAtlasMaxDecodedBytes(doc: Document | null | undefined): number { + return isMobileDocument(doc) + ? AUTO_ATLAS_MAX_DECODED_BYTES_MOBILE + : AUTO_ATLAS_MAX_DECODED_BYTES_DESKTOP; +} + +function atlasCanonicalSizeForTextureQuality( + textureQualityInput: TextureQuality | undefined, + doc: Document | null | undefined, +): number { + if (textureQualityInput !== undefined && textureQualityInput !== "auto") { + return ATLAS_CANONICAL_SIZE_EXPLICIT; + } + return isMobileDocument(doc) + ? ATLAS_CANONICAL_SIZE_EXPLICIT + : ATLAS_CANONICAL_SIZE_AUTO_DESKTOP; +} + +function formatAtlasMatrix( + entry: TextureAtlasPlan, + atlasCanonicalSize: number, +): string { + const values = entry.matrix.split(",").map((value) => Number(value)); + if (values.length !== 16 || values.some((value) => !Number.isFinite(value))) { + return entry.canonicalMatrix; + } + values[0] *= entry.canvasW / atlasCanonicalSize; + values[1] *= entry.canvasW / atlasCanonicalSize; + values[2] *= entry.canvasW / atlasCanonicalSize; + values[4] *= entry.canvasH / atlasCanonicalSize; + values[5] *= entry.canvasH / atlasCanonicalSize; + values[6] *= entry.canvasH / atlasCanonicalSize; + return values.join(","); +} + +function applyPackedAtlasCanonicalSize( + packed: PackedAtlas, + atlasCanonicalSize: number, +): PackedAtlas { + for (const entry of packed.entries) { + if (!entry) continue; + entry.atlasCanonicalSize = atlasCanonicalSize; + entry.atlasMatrix = formatAtlasMatrix(entry, atlasCanonicalSize); + } + return packed; +} + +function autoAtlasScale(pages: PackedPage[], maxDecodedBytes: number): number { const area = atlasArea(pages); let atlasScale = 0.5; if (area <= AUTO_ATLAS_LOW_AREA) atlasScale = 1; else if (area <= AUTO_ATLAS_MEDIUM_AREA) atlasScale = 0.75; - return normalizeAtlasScale(Math.min(atlasScale, autoAtlasScaleCap(pages))); + return normalizeAtlasScale(Math.min(atlasScale, autoAtlasScaleCap(pages, maxDecodedBytes))); } function atlasBitmapMaxSide(pages: PackedPage[], atlasScale: number): number { @@ -285,14 +347,14 @@ function atlasDecodedBytes(pages: PackedPage[], atlasScale: number): number { , 0); } -function autoAtlasBudgetFactor(pages: PackedPage[], atlasScale: number): number { +function autoAtlasBudgetFactor(pages: PackedPage[], atlasScale: number, maxDecodedBytes: number): number { const maxSide = atlasBitmapMaxSide(pages, atlasScale); const decodedBytes = atlasDecodedBytes(pages, atlasScale); const sideFactor = maxSide > AUTO_ATLAS_MAX_BITMAP_SIDE ? AUTO_ATLAS_MAX_BITMAP_SIDE / maxSide : 1; - const memoryFactor = decodedBytes > AUTO_ATLAS_MAX_DECODED_BYTES - ? Math.sqrt(AUTO_ATLAS_MAX_DECODED_BYTES / decodedBytes) + const memoryFactor = decodedBytes > maxDecodedBytes + ? Math.sqrt(maxDecodedBytes / decodedBytes) : 1; return Math.min(sideFactor, memoryFactor); } @@ -300,15 +362,16 @@ function autoAtlasBudgetFactor(pages: PackedPage[], atlasScale: number): number function packTextureAtlasPlansAuto( plans: Array, fullScalePacked: PackedAtlas, + maxDecodedBytes: number, ): { packed: PackedAtlas; atlasScale: number } { - let atlasScale = autoAtlasScale(fullScalePacked.pages); + let atlasScale = autoAtlasScale(fullScalePacked.pages, maxDecodedBytes); let packed = atlasScale === 1 ? fullScalePacked : packTextureAtlasPlans(plans, atlasScale); // Lower scales increase padding, so verify the final packed bitmap budget. for (let i = 0; i < 4; i++) { - const factor = autoAtlasBudgetFactor(packed.pages, atlasScale); + const factor = autoAtlasBudgetFactor(packed.pages, atlasScale, maxDecodedBytes); if (factor >= 1) break; const nextAtlasScale = normalizeAtlasScale(atlasScale * factor * AUTO_ATLAS_SCALE_GUARD); @@ -323,14 +386,25 @@ function packTextureAtlasPlansAuto( function packTextureAtlasPlansWithScale( plans: Array, textureQualityInput: TextureQuality | undefined, -): { packed: PackedAtlas; atlasScale: number } { + doc: Document | null | undefined, +): { packed: PackedAtlas; atlasScale: number; atlasCanonicalSize: number } { + const atlasCanonicalSize = atlasCanonicalSizeForTextureQuality(textureQualityInput, doc); if (textureQualityInput !== undefined && textureQualityInput !== "auto") { const atlasScale = normalizeAtlasScale(textureQualityInput); - return { packed: packTextureAtlasPlans(plans, atlasScale), atlasScale }; + return { + packed: applyPackedAtlasCanonicalSize(packTextureAtlasPlans(plans, atlasScale), atlasCanonicalSize), + atlasScale, + atlasCanonicalSize, + }; } const fullScalePacked = packTextureAtlasPlans(plans, 1); - return packTextureAtlasPlansAuto(plans, fullScalePacked); + const autoPacked = packTextureAtlasPlansAuto(plans, fullScalePacked, autoAtlasMaxDecodedBytes(doc)); + return { + packed: applyPackedAtlasCanonicalSize(autoPacked.packed, atlasCanonicalSize), + atlasScale: autoPacked.atlasScale, + atlasCanonicalSize, + }; } function atlasPadding(atlasScale: number): number { @@ -437,6 +511,15 @@ function borderShapeSupported(): boolean { return media("(pointer: fine)").matches && media("(hover: hover)").matches; } +function solidTriangleSupported(): boolean { + const userAgent = (typeof window !== "undefined" ? window.navigator : globalThis.navigator)?.userAgent ?? ""; + if (!userAgent) return true; + + const isChromiumFamily = /\b(?:Chrome|HeadlessChrome|Chromium|Edg|OPR)\//.test(userAgent); + const isSafariFamily = /\bVersion\/[\d.]+.*\bSafari\//.test(userAgent); + return !isSafariFamily || isChromiumFamily; +} + function incrementCount(map: Map, key: string): void { map.set(key, (map.get(key) ?? 0) + 1); } @@ -488,13 +571,14 @@ export function getSolidPaintDefaults( const paintCounts = new Map(); const dynamicCounts = new Map(); const dynamicColors = new Map(); + const useStableTriangle = solidTriangleSupported(); const useBorderShape = textureLighting !== "dynamic" && borderShapeSupported(); for (const plan of plans) { if (!plan || plan.texture) continue; if (textureLighting === "dynamic") { - if (!isSolidTrianglePlan(plan) && !isFullRectSolid(plan)) continue; + if (!(useStableTriangle && isSolidTrianglePlan(plan)) && !isFullRectSolid(plan)) continue; const color = parseHex(plan.polygon.color ?? "#cccccc"); const key = rgbKey(color); incrementCount(dynamicCounts, key); @@ -502,7 +586,7 @@ export function getSolidPaintDefaults( continue; } - if (!isSolidTrianglePlan(plan) && !isFullRectSolid(plan) && !useBorderShape) continue; + if (!(useStableTriangle && isSolidTrianglePlan(plan)) && !isFullRectSolid(plan) && !useBorderShape) continue; incrementCount(paintCounts, plan.shadedColor); } @@ -585,6 +669,14 @@ function formatBorderShapeMatrix(entry: TextureAtlasPlan): string { ); } +function formatSolidQuadMatrix(entry: TextureAtlasPlan): string { + return formatScaledMatrixFromPlan( + entry, + (entry.canvasW || 1) / SOLID_QUAD_CANONICAL_SIZE, + (entry.canvasH || 1) / SOLID_QUAD_CANONICAL_SIZE, + ); +} + function isConvexPolygonPoints(points: Array<[number, number]>): boolean { if (points.length < 3) return false; let sign = 0; @@ -765,12 +857,14 @@ function computeProjectiveQuadMatrix( p3[2] * w3 - p0[2], ]; - return [ + const values = [ xCol[0], xCol[1], xCol[2], g, yCol[0], yCol[1], yCol[2], h, normal[0], normal[1], normal[2], 0, p0[0], p0[1], p0[2], 1, - ].join(","); + ]; + for (let i = 0; i < 8; i += 1) values[i] /= SOLID_QUAD_CANONICAL_SIZE; + return values.join(","); } function cssPoints(vertices: Vec3[], tile: number, elev: number): Vec3[] { @@ -993,20 +1087,21 @@ function solidTriangleStyle( const apex = worldPoint(apex2); const baseLeft = worldPoint([baseLeft2[0], baseY]); const baseRight = worldPoint([baseRight2[0], baseY]); + const halfBase = SOLID_TRIANGLE_CANONICAL_SIZE / 2; const xCol: Vec3 = [ - (baseRight[0] - baseLeft[0]) / 2, - (baseRight[1] - baseLeft[1]) / 2, - (baseRight[2] - baseLeft[2]) / 2, + (baseRight[0] - baseLeft[0]) / SOLID_TRIANGLE_CANONICAL_SIZE, + (baseRight[1] - baseLeft[1]) / SOLID_TRIANGLE_CANONICAL_SIZE, + (baseRight[2] - baseLeft[2]) / SOLID_TRIANGLE_CANONICAL_SIZE, ]; const txCol: Vec3 = [ - apex[0] - xCol[0], - apex[1] - xCol[1], - apex[2] - xCol[2], + apex[0] - xCol[0] * halfBase, + apex[1] - xCol[1] * halfBase, + apex[2] - xCol[2] * halfBase, ]; const yCol: Vec3 = [ - baseLeft[0] - txCol[0], - baseLeft[1] - txCol[1], - baseLeft[2] - txCol[2], + (baseLeft[0] - txCol[0]) / SOLID_TRIANGLE_CANONICAL_SIZE, + (baseLeft[1] - txCol[1]) / SOLID_TRIANGLE_CANONICAL_SIZE, + (baseLeft[2] - txCol[2]) / SOLID_TRIANGLE_CANONICAL_SIZE, ]; const canonicalMatrix = formatMatrix3dValues([ xCol[0], xCol[1], xCol[2], 0, @@ -1596,6 +1691,18 @@ export function computeTextureAtlasPlan( nx, ny, nz, 0, tx, ty, tz, 1, ].join(","); + const atlasMatrix = [ + xAxis[0] * canvasW / ATLAS_CANONICAL_SIZE_EXPLICIT, + xAxis[1] * canvasW / ATLAS_CANONICAL_SIZE_EXPLICIT, + xAxis[2] * canvasW / ATLAS_CANONICAL_SIZE_EXPLICIT, + 0, + yAxis[0] * canvasH / ATLAS_CANONICAL_SIZE_EXPLICIT, + yAxis[1] * canvasH / ATLAS_CANONICAL_SIZE_EXPLICIT, + yAxis[2] * canvasH / ATLAS_CANONICAL_SIZE_EXPLICIT, + 0, + nx, ny, nz, 0, + tx, ty, tz, 1, + ].join(","); const normal: Vec3 = [nx, ny, nz]; const projectiveMatrix = !texture && vertices.length === 4 ? computeProjectiveQuadMatrix(screenPts, xAxis, yAxis, normal, tx, ty, tz) @@ -1640,6 +1747,7 @@ export function computeTextureAtlasPlan( layerElevation: elev, matrix, canonicalMatrix, + atlasMatrix, projectiveMatrix, canvasW, canvasH, @@ -1858,7 +1966,7 @@ export function useTextureAtlas( const disableU = strategies?.disable?.includes("u") ?? false; const useFullRectSolid = !disableB; const useProjectiveQuad = useFullRectSolid; - const useStableTriangle = !disableU; + const useStableTriangle = !disableU && solidTriangleSupported(); const useBorderShape = !disableI && textureLighting !== "dynamic" && borderShapeSupported(); const atlasPlans = useMemo( () => plans.map((plan) => { @@ -1879,7 +1987,11 @@ export function useTextureAtlas( [plans, textureLighting, useFullRectSolid, useProjectiveQuad, useStableTriangle, useBorderShape], ); const { packed, atlasScale } = useMemo( - () => packTextureAtlasPlansWithScale(atlasPlans, textureQualityInput), + () => packTextureAtlasPlansWithScale( + atlasPlans, + textureQualityInput, + typeof document !== "undefined" ? document : null, + ), [atlasPlans, textureQualityInput], ); const [pages, setPages] = useState( @@ -1958,7 +2070,7 @@ export function TextureBorderShapePoly({ else el.style.removeProperty("border-shape"); orderBrushInlineStyle(el); }, [borderShape]); - const transform = formatMatrix3d(borderShape ? formatBorderShapeMatrix(entry) : entry.canonicalMatrix); + const transform = formatMatrix3d(borderShape ? formatBorderShapeMatrix(entry) : formatSolidQuadMatrix(entry)); const style: CSSProperties = fullRect ? { transform, @@ -2129,13 +2241,14 @@ export function TextureAtlasPoly({ pointerEvents?: "auto" | "none"; }) { const dynamic = textureLighting === "dynamic"; + const atlasCanonicalSize = entry.atlasCanonicalSize ?? ATLAS_CANONICAL_SIZE_EXPLICIT; const atlasWidth = entry.canvasW || 1; const atlasHeight = entry.canvasH || 1; const atlasPosition = page - ? `${formatCssLength(-entry.x / atlasWidth)} ${formatCssLength(-entry.y / atlasHeight)}` + ? `${formatCssLength((-entry.x / atlasWidth) * atlasCanonicalSize)} ${formatCssLength((-entry.y / atlasHeight) * atlasCanonicalSize)}` : undefined; const atlasSize = page - ? `${formatCssLength(page.width / atlasWidth)} ${formatCssLength(page.height / atlasHeight)}` + ? `${formatCssLength((page.width / atlasWidth) * atlasCanonicalSize)} ${formatCssLength((page.height / atlasHeight) * atlasCanonicalSize)}` : undefined; // Dynamic mode: emit ONLY the per-polygon surface normal vars + the @@ -2151,7 +2264,8 @@ export function TextureAtlasPoly({ : undefined; const style: CSSProperties = { - transform: formatMatrix3d(entry.canonicalMatrix), + transform: formatMatrix3d(entry.atlasMatrix), + ["--polycss-atlas-size" as string]: `${atlasCanonicalSize}px`, background, backgroundImage: dynamic && page?.url ? `url(${page.url})` : undefined, backgroundPosition: dynamic ? atlasPosition : undefined, diff --git a/packages/react/src/shapes/types.ts b/packages/react/src/shapes/types.ts index 2e1f41df..d44a4a5e 100644 --- a/packages/react/src/shapes/types.ts +++ b/packages/react/src/shapes/types.ts @@ -102,8 +102,8 @@ export interface PolyProps extends TransformProps, DOMPassthroughProps { }; /** Textured polygon lighting mode. Defaults to scene context, then "baked". */ textureLighting?: PolyTextureLightingMode; - /** Raster scale for generated atlas pages. `"auto"` (default) downscales to - * a device-appropriate memory budget (~4 MB mobile / ~16 MB desktop). */ + /** Atlas bitmap budget and CSS sprite size. `"auto"` (default) uses a + * device-appropriate memory budget and desktop/mobile sprite sizing. */ textureQuality?: TextureQuality; /** Pre-computed shaded base color from the parent (optional override). */ baseColor?: string; diff --git a/packages/react/src/styles/styles.test.ts b/packages/react/src/styles/styles.test.ts index 598c4bf7..aae7533e 100644 --- a/packages/react/src/styles/styles.test.ts +++ b/packages/react/src/styles/styles.test.ts @@ -48,6 +48,11 @@ describe("injectPolyBaseStyles", () => { expect(el.textContent).toContain("transform-origin: 0 0"); expect(el.textContent).toContain("backface-visibility: hidden"); expect(el.textContent).toContain("background-repeat: no-repeat"); + expect(el.textContent).toContain("width: 64px;"); + expect(el.textContent).toContain("height: 64px;"); + expect(el.textContent).toContain("width: var(--polycss-atlas-size, 64px);"); + expect(el.textContent).toContain("height: var(--polycss-atlas-size, 64px);"); + expect(el.textContent).toContain("border-width: 0 32px 64px 32px;"); expect(el.textContent).toContain("width: 0;"); expect(el.textContent).toContain("height: 0;"); }); diff --git a/packages/react/src/styles/styles.ts b/packages/react/src/styles/styles.ts index ab9385c5..c62cdc0b 100644 --- a/packages/react/src/styles/styles.ts +++ b/packages/react/src/styles/styles.ts @@ -99,8 +99,8 @@ const CORE_BASE_STYLES = ` .polycss-scene b { background: currentColor; - width: 1px; - height: 1px; + width: 64px; + height: 64px; } .polycss-scene i { @@ -110,8 +110,8 @@ const CORE_BASE_STYLES = ` } .polycss-scene s { - width: 1px; - height: 1px; + width: var(--polycss-atlas-size, 64px); + height: var(--polycss-atlas-size, 64px); } .polycss-scene u { @@ -121,7 +121,7 @@ const CORE_BASE_STYLES = ` box-sizing: content-box; border: 0 solid transparent; border-color: transparent transparent currentColor transparent; - border-width: 0 1px 1px 1px; + border-width: 0 32px 64px 32px; } /* ── Gizmo override ─────────────────────────────────────────────────────── */ diff --git a/packages/vue/README.md b/packages/vue/README.md index 0515f8cd..608fb7d8 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -39,7 +39,7 @@ Root of every Vue polycss render tree. Renders polygons and meshes inside a `` (or `` for pan-first map-style input) inside ``: it receives the camera context. Mirrors Three.js's split between camera state and input. @@ -55,7 +55,7 @@ Loads a mesh from a URL and renders its polygons. Manages blob-URL lifecycle aut | `position` | `Vec3` | `[x, y, z]` offset in scene space | | `scale` | `number \| Vec3` | Uniform or per-axis scale | | `rotation` | `Vec3` | Euler angles in degrees `[x, y, z]` | -| `atlas-scale` | `number \| "auto"` | Raster scale for generated atlas pages | +| `atlas-scale` | `number \| "auto"` | Atlas bitmap budget and compositor sprite size | | `auto-center` | `boolean` | Shift mesh so its bbox center is at origin | | `mtl` | `string` | Companion `.mtl` URL for OBJ models | @@ -75,7 +75,7 @@ Single polygon. Renders one atlas-backed `` for UV-textured and flat-color fa | `position` | `Vec3` | Local offset | | `scale` | `number \| Vec3` | Scale | | `rotation` | `Vec3` | Euler rotation in degrees | -| `atlas-scale` | `number \| "auto"` | Raster scale for generated atlas pages | +| `atlas-scale` | `number \| "auto"` | Atlas bitmap budget and compositor sprite size | ### `` diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index b8db10fd..df2d103b 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -100,6 +100,9 @@ export type { AutoRotateOption, AutoRotateConfig, AxesHelperOptions, + BoxFace, + BoxFaceOptions, + BoxPolygonsOptions, ArrowPolygonsOptions, RingPolygonsOptions, OctahedronPolygonsOptions, @@ -155,6 +158,7 @@ export { rotateVec3, inverseRotateVec3, axesHelperPolygons, + boxPolygons, arrowPolygons, ringPolygons, octahedronPolygons, diff --git a/packages/vue/src/scene/PolyMesh.ts b/packages/vue/src/scene/PolyMesh.ts index ef5c8478..b9c5264b 100644 --- a/packages/vue/src/scene/PolyMesh.ts +++ b/packages/vue/src/scene/PolyMesh.ts @@ -71,9 +71,10 @@ export interface PolyMeshProps extends InteractionProps { polygons?: Polygon[]; autoCenter?: boolean; textureLighting?: PolyTextureLightingMode; - /** Raster scale for generated atlas pages. `"auto"` (default) downscales to - * a device-appropriate memory budget (~4 MB mobile / ~16 MB desktop). - * Numeric values 0.1..1 force an explicit scale. */ + /** Atlas bitmap budget and CSS sprite size. `"auto"` (default) uses a + * device-appropriate memory budget (~4 MB mobile / ~16 MB desktop) and + * desktop/mobile sprite sizing. Numeric values 0.1..1 force an explicit + * raster scale and the 64px sprite. */ textureQuality?: TextureQuality; /** * When `true` and the scene is in dynamic lighting mode, the renderer emits diff --git a/packages/vue/src/scene/PolyScene.ts b/packages/vue/src/scene/PolyScene.ts index beec2547..56103918 100644 --- a/packages/vue/src/scene/PolyScene.ts +++ b/packages/vue/src/scene/PolyScene.ts @@ -54,9 +54,10 @@ export interface PolySceneProps { directionalLight?: PolyDirectionalLight; ambientLight?: PolyAmbientLight; textureLighting?: PolyTextureLightingMode; - /** Raster scale for generated atlas pages. `"auto"` (default) downscales to - * a device-appropriate memory budget (~4 MB mobile / ~16 MB desktop). - * Numeric values 0.1..1 force an explicit scale. */ + /** Atlas bitmap budget and CSS sprite size. `"auto"` (default) uses a + * device-appropriate memory budget (~4 MB mobile / ~16 MB desktop) and + * desktop/mobile sprite sizing. Numeric values 0.1..1 force an explicit + * raster scale and the 64px sprite. */ textureQuality?: TextureQuality; /** Opt out of specific render strategies. Disabled strategies fall through the chain (b→i→s, u→i→s, i→s). `` cannot be disabled. */ strategies?: PolyRenderStrategiesOption; diff --git a/packages/vue/src/scene/textureAtlas.test.ts b/packages/vue/src/scene/textureAtlas.test.ts index df1eadf3..7f76c1b8 100644 --- a/packages/vue/src/scene/textureAtlas.test.ts +++ b/packages/vue/src/scene/textureAtlas.test.ts @@ -9,6 +9,8 @@ import { } from "./textureAtlas"; import type { Polygon } from "@layoutit/polycss-core"; +const originalUserAgent = window.navigator.userAgent; + const TEXTURED_QUAD_60: Polygon = { vertices: [ [0, 0, 0], @@ -24,7 +26,23 @@ function planFor(polygon: Polygon, index = 0): TextureAtlasPlan | null { return computeTextureAtlasPlan(polygon, index, {}); } +function stubUserAgent(userAgent: string): void { + Object.defineProperty(window.navigator, "userAgent", { + configurable: true, + value: userAgent, + }); +} + +function stubBorderShapeUnsupported(): void { + vi.stubGlobal("CSS", { supports: () => false }); +} + afterEach(() => { + Object.defineProperty(window.navigator, "userAgent", { + configurable: true, + value: originalUserAgent, + }); + vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -122,7 +140,7 @@ describe("useTextureAtlas (auto textureQuality)", () => { return Array.from({ length: 6 }, () => ({ ...TEXTURED_QUAD_60 })); } - async function measureAtlas(mobile: boolean): Promise<{ pageBytes: number; pageCount: number }> { + async function measureAtlas(mobile: boolean): Promise<{ pageBytes: number; pageCount: number; atlasCanonicalSize: number | null }> { vi.stubGlobal("matchMedia", (query: string) => ({ matches: mobile && (query.includes("pointer: coarse") || query.includes("hover: none")), addEventListener: () => {}, @@ -142,7 +160,12 @@ describe("useTextureAtlas (auto textureQuality)", () => { // After the synchronous pack the pages ref already exposes packed sizes. const pages = atlas.pages.value; const pageBytes = pages.reduce((sum, p) => sum + p.width * p.height * 4, 0); - result = { pageBytes, pageCount: pages.length }; + const entry = atlas.entries.value.find((packed) => packed !== null); + result = { + pageBytes, + pageCount: pages.length, + atlasCanonicalSize: entry?.atlasCanonicalSize ?? null, + }; }); await nextTick(); scope.stop(); @@ -159,10 +182,37 @@ describe("useTextureAtlas (auto textureQuality)", () => { expect(mobile.pageCount).toBeGreaterThan(0); expect(desktop.pageCount).toBeGreaterThan(0); expect(mobile.pageBytes).toBeGreaterThan(0); + expect(mobile.atlasCanonicalSize).toBe(64); + expect(desktop.atlasCanonicalSize).toBe(128); + }); + + it("packs solid triangles into the atlas on WebKit", async () => { + stubUserAgent("Mozilla/5.0 AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"); + stubBorderShapeUnsupported(); + const tri: Polygon = { + vertices: [[0, 0, 0], [1, 0, 0], [0, 1, 0]], + color: "#ff0000", + }; + + let packed = false; + const scope = effectScope(); + scope.run(() => { + const plans = computed>(() => [ + computeTextureAtlasPlan(tri, 0, {}), + ]); + const textureLighting = computed(() => "baked" as const); + const atlas = useTextureAtlas(plans, textureLighting); + packed = atlas.entries.value[0] !== null; + }); + await nextTick(); + scope.stop(); + + expect(packed).toBe(true); }); it("explicit numeric textureQuality applies without auto branches", async () => { let pageDims: { width: number; height: number }[] = []; + let atlasCanonicalSize: number | null = null; const scope = effectScope(); scope.run(() => { const polygons = ref(buildSixFaceCrateScene()); @@ -173,10 +223,12 @@ describe("useTextureAtlas (auto textureQuality)", () => { const textureQuality = computed(() => 0.5); const atlas = useTextureAtlas(plans, textureLighting, textureQuality); pageDims = atlas.pages.value.map((p) => ({ width: p.width, height: p.height })); + atlasCanonicalSize = atlas.entries.value.find((packed) => packed !== null)?.atlasCanonicalSize ?? null; }); await nextTick(); scope.stop(); expect(pageDims.length).toBeGreaterThan(0); + expect(atlasCanonicalSize).toBe(64); }); it("rasterizes packed pages to canvas blobs when a 2D context is available", async () => { diff --git a/packages/vue/src/scene/textureAtlas.ts b/packages/vue/src/scene/textureAtlas.ts index 0014c462..9d1ab282 100644 --- a/packages/vue/src/scene/textureAtlas.ts +++ b/packages/vue/src/scene/textureAtlas.ts @@ -30,7 +30,8 @@ const MAX_ATLAS_SCALE = 1; const AUTO_ATLAS_LOW_AREA = ATLAS_MAX_SIZE * ATLAS_MAX_SIZE; const AUTO_ATLAS_MEDIUM_AREA = AUTO_ATLAS_LOW_AREA * 3; const AUTO_ATLAS_MAX_BITMAP_SIDE = 2048; -const AUTO_ATLAS_MAX_DECODED_BYTES = 16 * 1024 * 1024; +const AUTO_ATLAS_MAX_DECODED_BYTES_MOBILE = 4 * 1024 * 1024; +const AUTO_ATLAS_MAX_DECODED_BYTES_DESKTOP = 16 * 1024 * 1024; const AUTO_ATLAS_SCALE_GUARD = 0.995; const RECT_EPS = 1e-3; const TEXTURE_TRIANGLE_BLEED = 0.75; @@ -40,6 +41,10 @@ const TEXTURE_EDGE_REPAIR_RADIUS = 1.5; const DEFAULT_MATRIX_DECIMALS = 3; const DEFAULT_BORDER_SHAPE_DECIMALS = 2; const DEFAULT_ATLAS_CSS_DECIMALS = 4; +const SOLID_QUAD_CANONICAL_SIZE = 64; +const SOLID_TRIANGLE_CANONICAL_SIZE = 64; +const ATLAS_CANONICAL_SIZE_EXPLICIT = 64; +const ATLAS_CANONICAL_SIZE_AUTO_DESKTOP = 128; const BORDER_SHAPE_CENTER_PERCENT = 50; const BORDER_SHAPE_POINT_EPS = 1e-7; const BORDER_SHAPE_CANONICAL_SIZE = 16; @@ -100,6 +105,8 @@ export interface TextureAtlasPlan { layerElevation: number; matrix: string; canonicalMatrix: string; + atlasMatrix: string; + atlasCanonicalSize?: number; projectiveMatrix: string | null; canvasW: number; canvasH: number; @@ -253,7 +260,7 @@ function atlasArea(pages: PackedPage[]): number { return pages.reduce((sum, page) => sum + page.width * page.height, 0); } -function autoAtlasScaleCap(pages: PackedPage[]): number { +function autoAtlasScaleCap(pages: PackedPage[], maxDecodedBytes: number): number { const area = atlasArea(pages); if (area <= 0) return 1; @@ -262,18 +269,73 @@ function autoAtlasScaleCap(pages: PackedPage[]): number { ...pages.map((page) => Math.max(page.width, page.height)), ); const sideScale = AUTO_ATLAS_MAX_BITMAP_SIDE / maxSide; - const memoryScale = Math.sqrt(AUTO_ATLAS_MAX_DECODED_BYTES / (area * 4)); + const memoryScale = Math.sqrt(maxDecodedBytes / (area * 4)); return normalizeAtlasScale(Math.min(sideScale, memoryScale)); } -function autoAtlasScale(pages: PackedPage[]): number { +function isMobileDocument(doc: Document | null | undefined): boolean { + if (!doc) return false; + const win = doc.defaultView ?? (typeof window !== "undefined" ? window : undefined); + const media = win?.matchMedia; + if (!media) return false; + return media("(pointer: coarse)").matches || media("(hover: none)").matches; +} + +function autoAtlasMaxDecodedBytes(doc: Document | null | undefined): number { + return isMobileDocument(doc) + ? AUTO_ATLAS_MAX_DECODED_BYTES_MOBILE + : AUTO_ATLAS_MAX_DECODED_BYTES_DESKTOP; +} + +function atlasCanonicalSizeForTextureQuality( + textureQualityInput: TextureQuality | undefined, + doc: Document | null | undefined, +): number { + if (textureQualityInput !== undefined && textureQualityInput !== "auto") { + return ATLAS_CANONICAL_SIZE_EXPLICIT; + } + return isMobileDocument(doc) + ? ATLAS_CANONICAL_SIZE_EXPLICIT + : ATLAS_CANONICAL_SIZE_AUTO_DESKTOP; +} + +function formatAtlasMatrix( + entry: TextureAtlasPlan, + atlasCanonicalSize: number, +): string { + const values = entry.matrix.split(",").map((value) => Number(value)); + if (values.length !== 16 || values.some((value) => !Number.isFinite(value))) { + return entry.canonicalMatrix; + } + values[0] *= entry.canvasW / atlasCanonicalSize; + values[1] *= entry.canvasW / atlasCanonicalSize; + values[2] *= entry.canvasW / atlasCanonicalSize; + values[4] *= entry.canvasH / atlasCanonicalSize; + values[5] *= entry.canvasH / atlasCanonicalSize; + values[6] *= entry.canvasH / atlasCanonicalSize; + return values.join(","); +} + +function applyPackedAtlasCanonicalSize( + packed: PackedAtlas, + atlasCanonicalSize: number, +): PackedAtlas { + for (const entry of packed.entries) { + if (!entry) continue; + entry.atlasCanonicalSize = atlasCanonicalSize; + entry.atlasMatrix = formatAtlasMatrix(entry, atlasCanonicalSize); + } + return packed; +} + +function autoAtlasScale(pages: PackedPage[], maxDecodedBytes: number): number { const area = atlasArea(pages); let atlasScale = 0.5; if (area <= AUTO_ATLAS_LOW_AREA) atlasScale = 1; else if (area <= AUTO_ATLAS_MEDIUM_AREA) atlasScale = 0.75; - return normalizeAtlasScale(Math.min(atlasScale, autoAtlasScaleCap(pages))); + return normalizeAtlasScale(Math.min(atlasScale, autoAtlasScaleCap(pages, maxDecodedBytes))); } function atlasBitmapMaxSide(pages: PackedPage[], atlasScale: number): number { @@ -293,14 +355,14 @@ function atlasDecodedBytes(pages: PackedPage[], atlasScale: number): number { , 0); } -function autoAtlasBudgetFactor(pages: PackedPage[], atlasScale: number): number { +function autoAtlasBudgetFactor(pages: PackedPage[], atlasScale: number, maxDecodedBytes: number): number { const maxSide = atlasBitmapMaxSide(pages, atlasScale); const decodedBytes = atlasDecodedBytes(pages, atlasScale); const sideFactor = maxSide > AUTO_ATLAS_MAX_BITMAP_SIDE ? AUTO_ATLAS_MAX_BITMAP_SIDE / maxSide : 1; - const memoryFactor = decodedBytes > AUTO_ATLAS_MAX_DECODED_BYTES - ? Math.sqrt(AUTO_ATLAS_MAX_DECODED_BYTES / decodedBytes) + const memoryFactor = decodedBytes > maxDecodedBytes + ? Math.sqrt(maxDecodedBytes / decodedBytes) : 1; return Math.min(sideFactor, memoryFactor); } @@ -308,15 +370,16 @@ function autoAtlasBudgetFactor(pages: PackedPage[], atlasScale: number): number function packTextureAtlasPlansAuto( plans: Array, fullScalePacked: PackedAtlas, + maxDecodedBytes: number, ): { packed: PackedAtlas; atlasScale: number } { - let atlasScale = autoAtlasScale(fullScalePacked.pages); + let atlasScale = autoAtlasScale(fullScalePacked.pages, maxDecodedBytes); let packed = atlasScale === 1 ? fullScalePacked : packTextureAtlasPlans(plans, atlasScale); // Lower scales increase padding, so verify the final packed bitmap budget. for (let i = 0; i < 4; i++) { - const factor = autoAtlasBudgetFactor(packed.pages, atlasScale); + const factor = autoAtlasBudgetFactor(packed.pages, atlasScale, maxDecodedBytes); if (factor >= 1) break; const nextAtlasScale = normalizeAtlasScale(atlasScale * factor * AUTO_ATLAS_SCALE_GUARD); @@ -331,14 +394,25 @@ function packTextureAtlasPlansAuto( function packTextureAtlasPlansWithScale( plans: Array, textureQualityInput: TextureQuality | undefined, -): { packed: PackedAtlas; atlasScale: number } { + doc: Document | null | undefined, +): { packed: PackedAtlas; atlasScale: number; atlasCanonicalSize: number } { + const atlasCanonicalSize = atlasCanonicalSizeForTextureQuality(textureQualityInput, doc); if (textureQualityInput !== undefined && textureQualityInput !== "auto") { const atlasScale = normalizeAtlasScale(textureQualityInput); - return { packed: packTextureAtlasPlans(plans, atlasScale), atlasScale }; + return { + packed: applyPackedAtlasCanonicalSize(packTextureAtlasPlans(plans, atlasScale), atlasCanonicalSize), + atlasScale, + atlasCanonicalSize, + }; } const fullScalePacked = packTextureAtlasPlans(plans, 1); - return packTextureAtlasPlansAuto(plans, fullScalePacked); + const autoPacked = packTextureAtlasPlansAuto(plans, fullScalePacked, autoAtlasMaxDecodedBytes(doc)); + return { + packed: applyPackedAtlasCanonicalSize(autoPacked.packed, atlasCanonicalSize), + atlasScale: autoPacked.atlasScale, + atlasCanonicalSize, + }; } function atlasPadding(atlasScale: number): number { @@ -445,6 +519,15 @@ function borderShapeSupported(): boolean { return media("(pointer: fine)").matches && media("(hover: hover)").matches; } +function solidTriangleSupported(): boolean { + const userAgent = (typeof window !== "undefined" ? window.navigator : globalThis.navigator)?.userAgent ?? ""; + if (!userAgent) return true; + + const isChromiumFamily = /\b(?:Chrome|HeadlessChrome|Chromium|Edg|OPR)\//.test(userAgent); + const isSafariFamily = /\bVersion\/[\d.]+.*\bSafari\//.test(userAgent); + return !isSafariFamily || isChromiumFamily; +} + function incrementCount(map: Map, key: string): void { map.set(key, (map.get(key) ?? 0) + 1); } @@ -496,13 +579,14 @@ export function getSolidPaintDefaults( const paintCounts = new Map(); const dynamicCounts = new Map(); const dynamicColors = new Map(); + const useStableTriangle = solidTriangleSupported(); const useBorderShape = textureLighting !== "dynamic" && borderShapeSupported(); for (const plan of plans) { if (!plan || plan.texture) continue; if (textureLighting === "dynamic") { - if (!isSolidTrianglePlan(plan) && !isFullRectSolid(plan)) continue; + if (!(useStableTriangle && isSolidTrianglePlan(plan)) && !isFullRectSolid(plan)) continue; const color = parseHex(plan.polygon.color ?? "#cccccc"); const key = rgbKey(color); incrementCount(dynamicCounts, key); @@ -510,7 +594,7 @@ export function getSolidPaintDefaults( continue; } - if (!isSolidTrianglePlan(plan) && !isFullRectSolid(plan) && !useBorderShape) continue; + if (!(useStableTriangle && isSolidTrianglePlan(plan)) && !isFullRectSolid(plan) && !useBorderShape) continue; incrementCount(paintCounts, plan.shadedColor); } @@ -593,6 +677,14 @@ function formatBorderShapeMatrix(entry: TextureAtlasPlan): string { ); } +function formatSolidQuadMatrix(entry: TextureAtlasPlan): string { + return formatScaledMatrixFromPlan( + entry, + (entry.canvasW || 1) / SOLID_QUAD_CANONICAL_SIZE, + (entry.canvasH || 1) / SOLID_QUAD_CANONICAL_SIZE, + ); +} + function isConvexPolygonPoints(points: Array<[number, number]>): boolean { if (points.length < 3) return false; let sign = 0; @@ -773,12 +865,14 @@ function computeProjectiveQuadMatrix( p3[2] * w3 - p0[2], ]; - return [ + const values = [ xCol[0], xCol[1], xCol[2], g, yCol[0], yCol[1], yCol[2], h, normal[0], normal[1], normal[2], 0, p0[0], p0[1], p0[2], 1, - ].join(","); + ]; + for (let i = 0; i < 8; i += 1) values[i] /= SOLID_QUAD_CANONICAL_SIZE; + return values.join(","); } function cssPoints(vertices: Vec3[], tile: number, elev: number): Vec3[] { @@ -1001,20 +1095,21 @@ function solidTriangleStyle( const apex = worldPoint(apex2); const baseLeft = worldPoint([baseLeft2[0], baseY]); const baseRight = worldPoint([baseRight2[0], baseY]); + const halfBase = SOLID_TRIANGLE_CANONICAL_SIZE / 2; const xCol: Vec3 = [ - (baseRight[0] - baseLeft[0]) / 2, - (baseRight[1] - baseLeft[1]) / 2, - (baseRight[2] - baseLeft[2]) / 2, + (baseRight[0] - baseLeft[0]) / SOLID_TRIANGLE_CANONICAL_SIZE, + (baseRight[1] - baseLeft[1]) / SOLID_TRIANGLE_CANONICAL_SIZE, + (baseRight[2] - baseLeft[2]) / SOLID_TRIANGLE_CANONICAL_SIZE, ]; const txCol: Vec3 = [ - apex[0] - xCol[0], - apex[1] - xCol[1], - apex[2] - xCol[2], + apex[0] - xCol[0] * halfBase, + apex[1] - xCol[1] * halfBase, + apex[2] - xCol[2] * halfBase, ]; const yCol: Vec3 = [ - baseLeft[0] - txCol[0], - baseLeft[1] - txCol[1], - baseLeft[2] - txCol[2], + (baseLeft[0] - txCol[0]) / SOLID_TRIANGLE_CANONICAL_SIZE, + (baseLeft[1] - txCol[1]) / SOLID_TRIANGLE_CANONICAL_SIZE, + (baseLeft[2] - txCol[2]) / SOLID_TRIANGLE_CANONICAL_SIZE, ]; const canonicalMatrix = formatMatrix3dValues([ xCol[0], xCol[1], xCol[2], 0, @@ -1604,6 +1699,18 @@ export function computeTextureAtlasPlan( nx, ny, nz, 0, tx, ty, tz, 1, ].join(","); + const atlasMatrix = [ + xAxis[0] * canvasW / ATLAS_CANONICAL_SIZE_EXPLICIT, + xAxis[1] * canvasW / ATLAS_CANONICAL_SIZE_EXPLICIT, + xAxis[2] * canvasW / ATLAS_CANONICAL_SIZE_EXPLICIT, + 0, + yAxis[0] * canvasH / ATLAS_CANONICAL_SIZE_EXPLICIT, + yAxis[1] * canvasH / ATLAS_CANONICAL_SIZE_EXPLICIT, + yAxis[2] * canvasH / ATLAS_CANONICAL_SIZE_EXPLICIT, + 0, + nx, ny, nz, 0, + tx, ty, tz, 1, + ].join(","); const normal: Vec3 = [nx, ny, nz]; const projectiveMatrix = !texture && vertices.length === 4 ? computeProjectiveQuadMatrix(screenPts, xAxis, yAxis, normal, tx, ty, tz) @@ -1648,6 +1755,7 @@ export function computeTextureAtlasPlan( layerElevation: elev, matrix, canonicalMatrix, + atlasMatrix, projectiveMatrix, canvasW, canvasH, @@ -1868,7 +1976,7 @@ export function useTextureAtlas( const disabled = computed(() => new Set(strategies.value?.disable ?? [])); const useFullRectSolid = computed(() => !disabled.value.has("b")); const useProjectiveQuad = computed(() => useFullRectSolid.value); - const useStableTriangle = computed(() => !disabled.value.has("u")); + const useStableTriangle = computed(() => !disabled.value.has("u") && solidTriangleSupported()); const useBorderShape = computed(() => !disabled.value.has("i") && textureLighting.value !== "dynamic" && borderShapeSupported()); const atlasState = computed(() => { @@ -1883,7 +1991,11 @@ export function useTextureAtlas( ) return null; return plan; }); - return packTextureAtlasPlansWithScale(atlasPlans, textureQuality.value); + return packTextureAtlasPlansWithScale( + atlasPlans, + textureQuality.value, + typeof document !== "undefined" ? document : null, + ); }); const pages = ref( atlasState.value.packed.pages.map((page) => ({ width: page.width, height: page.height, url: null })), @@ -1967,13 +2079,14 @@ export function renderTextureAtlasPoly({ pointerEvents?: "auto" | "none"; }): VNode { const dynamic = textureLighting === "dynamic"; + const atlasCanonicalSize = entry.atlasCanonicalSize ?? ATLAS_CANONICAL_SIZE_EXPLICIT; const atlasWidth = entry.canvasW || 1; const atlasHeight = entry.canvasH || 1; const atlasPosition = page - ? `${formatCssLength(-entry.x / atlasWidth)} ${formatCssLength(-entry.y / atlasHeight)}` + ? `${formatCssLength((-entry.x / atlasWidth) * atlasCanonicalSize)} ${formatCssLength((-entry.y / atlasHeight) * atlasCanonicalSize)}` : undefined; const atlasSize = page - ? `${formatCssLength(page.width / atlasWidth)} ${formatCssLength(page.height / atlasHeight)}` + ? `${formatCssLength((page.width / atlasWidth) * atlasCanonicalSize)} ${formatCssLength((page.height / atlasHeight) * atlasCanonicalSize)}` : undefined; // Dynamic mode: emit ONLY the per-polygon surface normal vars + the @@ -1989,7 +2102,8 @@ export function renderTextureAtlasPoly({ : undefined; const style: CSSProperties = { - transform: formatMatrix3d(entry.canonicalMatrix), + transform: formatMatrix3d(entry.atlasMatrix), + "--polycss-atlas-size": `${atlasCanonicalSize}px`, background, backgroundImage: dynamic && page?.url ? `url(${page.url})` : undefined, backgroundPosition: dynamic ? atlasPosition : undefined, @@ -2055,7 +2169,7 @@ export function renderTextureBorderShapePoly({ const useIForFullRect = fullRect && forceBorderShape && borderShapeSupported(); const borderShape = (!fullRect || useIForFullRect) ? cssBorderShapeForPlan(entry) : null; const useDefaultPaint = entry.shadedColor === solidPaintDefaults?.paintColor; - const transform = formatMatrix3d(borderShape ? formatBorderShapeMatrix(entry) : entry.canonicalMatrix); + const transform = formatMatrix3d(borderShape ? formatBorderShapeMatrix(entry) : formatSolidQuadMatrix(entry)); const style: CSSProperties = fullRect ? { transform, diff --git a/packages/vue/src/shapes/Poly.ts b/packages/vue/src/shapes/Poly.ts index d4ad2fb5..d2799aef 100644 --- a/packages/vue/src/shapes/Poly.ts +++ b/packages/vue/src/shapes/Poly.ts @@ -129,8 +129,8 @@ export interface PolyProps { rotation?: Vec3; context?: PolyContext; textureLighting?: PolyTextureLightingMode; - /** Raster scale for generated atlas pages. `"auto"` (default) downscales to - * a device-appropriate memory budget (~4 MB mobile / ~16 MB desktop). */ + /** Atlas bitmap budget and CSS sprite size. `"auto"` (default) uses a + * device-appropriate memory budget and desktop/mobile sprite sizing. */ textureQuality?: TextureQuality; baseColor?: string; pointerEvents?: "auto" | "none"; diff --git a/packages/vue/src/styles/styles.test.ts b/packages/vue/src/styles/styles.test.ts index 3d8fd3e5..e2c3464f 100644 --- a/packages/vue/src/styles/styles.test.ts +++ b/packages/vue/src/styles/styles.test.ts @@ -47,6 +47,11 @@ describe("injectPolyBaseStyles", () => { expect(el.textContent).toContain("transform-origin: 0 0"); expect(el.textContent).toContain("backface-visibility: hidden"); expect(el.textContent).toContain("background-repeat: no-repeat"); + expect(el.textContent).toContain("width: 64px;"); + expect(el.textContent).toContain("height: 64px;"); + expect(el.textContent).toContain("width: var(--polycss-atlas-size, 64px);"); + expect(el.textContent).toContain("height: var(--polycss-atlas-size, 64px);"); + expect(el.textContent).toContain("border-width: 0 32px 64px 32px;"); expect(el.textContent).toContain("width: 0;"); expect(el.textContent).toContain("height: 0;"); }); diff --git a/packages/vue/src/styles/styles.ts b/packages/vue/src/styles/styles.ts index 2eac5e74..6b64394e 100644 --- a/packages/vue/src/styles/styles.ts +++ b/packages/vue/src/styles/styles.ts @@ -99,8 +99,8 @@ const CORE_BASE_STYLES = ` .polycss-scene b { background: currentColor; - width: 1px; - height: 1px; + width: 64px; + height: 64px; } .polycss-scene i { @@ -110,8 +110,8 @@ const CORE_BASE_STYLES = ` } .polycss-scene s { - width: 1px; - height: 1px; + width: var(--polycss-atlas-size, 64px); + height: var(--polycss-atlas-size, 64px); } .polycss-scene u { @@ -121,7 +121,7 @@ const CORE_BASE_STYLES = ` box-sizing: content-box; border: 0 solid transparent; border-color: transparent transparent currentColor transparent; - border-width: 0 1px 1px 1px; + border-width: 0 32px 64px 32px; } /* ── Dynamic lighting cascade vars (scene root → polygons) ─────────────── */ diff --git a/website/src/content/docs/components/poly-scene.mdx b/website/src/content/docs/components/poly-scene.mdx index a80caea2..b4da7ffe 100644 --- a/website/src/content/docs/components/poly-scene.mdx +++ b/website/src/content/docs/components/poly-scene.mdx @@ -18,7 +18,7 @@ It's available as a custom element (``), via the imperative `createP | `directionalLight` | `PolyDirectionalLight` | None | Directional light source. | | `ambientLight` | `PolyAmbientLight` | None | Ambient fill light. | | `textureLighting` | `"baked" \| "dynamic"` | `"baked"` | Whether texture lighting is rasterized into atlases or computed with CSS variables. | -| `textureQuality` | `number \| "auto"` | `"auto"` | Raster scale for generated atlas pages. Auto caps large runtime bitmaps by workload; lower numeric values reduce texture memory and detail. | +| `textureQuality` | `number \| "auto"` | `"auto"` | Atlas bitmap budget and compositor sprite size. Auto caps large runtime bitmaps and uses a larger desktop sprite to avoid Safari/Firefox flattening artifacts; lower numeric values reduce texture memory and detail. | | `polygons` | `Polygon[]` | None | (Framework only.) Flat array of polygon objects rendered as direct children. Composes with JSX/slot children. | | `children` | None | None | `` / `` / `` (vanilla) or `` / `` / `` (React / Vue). | @@ -35,7 +35,7 @@ It's available as a custom element (``), via the imperative `createP | `position` | `Vec3` | `[x, y, z]` offset in scene space. | | `scale` | `number \| Vec3` | Uniform or per-axis scale. | | `rotation` | `Vec3` | Euler rotation in degrees `[x, y, z]`. | -| `textureQuality` | `number \| "auto"` | Raster scale for generated atlas pages. React / Vue only; vanilla meshes inherit the scene's `texture-quality`. | +| `textureQuality` | `number \| "auto"` | Atlas bitmap budget and compositor sprite size. React / Vue only; vanilla meshes inherit the scene's `texture-quality`. | | `autoCenter` | `boolean` | Shift the loaded mesh so its bounding-box center sits at the local origin before applying `position`. Useful when assets aren't centered in their file coordinates. | | `mtl` | `string` | Companion `.mtl` URL for OBJ models. | | `parseOptions` | `UseMeshOptions` | Parser options forwarded to `loadMesh`; `meshResolution` defaults to `"lossy"`. | diff --git a/website/src/content/docs/guides/performance.mdx b/website/src/content/docs/guides/performance.mdx index 145998fd..b01d07a4 100644 --- a/website/src/content/docs/guides/performance.mdx +++ b/website/src/content/docs/guides/performance.mdx @@ -57,7 +57,7 @@ const result = await loadMesh("/castle.obj", { ## `textureQuality` and texture memory -Generated atlas pages default to `textureQuality="auto"`. Auto starts from the packed atlas area and then caps oversized runtime bitmaps by page side length and decoded-memory budget. For dense textured meshes this prevents very large canvas/PNG work at mount while keeping DOM geometry and hit areas unchanged. +Generated atlas pages default to `textureQuality="auto"`. Auto starts from the packed atlas area, caps oversized runtime bitmaps by page side length and decoded-memory budget, and chooses the fixed CSS sprite size used by atlas leaves. Desktop-class auto uses a 128px sprite to avoid Safari/Firefox compositor flattening artifacts; mobile-class auto and explicit numeric quality use 64px to keep layer memory lower. ```html @@ -78,7 +78,7 @@ Generated atlas pages default to `textureQuality="auto"`. Auto starts from the p ``` -Use explicit numeric values when you want to override auto: `0.5` or `0.75` for distant or dense assets, `1` for close-up inspection when the runtime bitmap cost is acceptable. +Use explicit numeric values when you want to override auto raster scale: `0.5` or `0.75` for distant or dense assets, `1` for close-up inspection when the runtime bitmap cost is acceptable. Numeric quality keeps the 64px atlas sprite size. ## Atlas and Blob URL Lifecycle diff --git a/website/src/content/docs/guides/shapes.mdx b/website/src/content/docs/guides/shapes.mdx index 4e17fdc0..36753bff 100644 --- a/website/src/content/docs/guides/shapes.mdx +++ b/website/src/content/docs/guides/shapes.mdx @@ -17,6 +17,25 @@ Every polygon rendered by polycss is a real DOM element. You can attach standard defaults='{"rotX":65,"rotY":45,"zoom":0.08}' /> +## Box helper + +Use `boxPolygons()` when your shape is an axis-aligned box or cuboid. It returns ordinary `Polygon[]`, so the result renders through the same `` / `createPolyScene()` path as parsed OBJ, GLB, and VOX meshes. + +```ts +import { boxPolygons } from "@layoutit/polycss-react"; + +const polygons = boxPolygons({ + min: [0, 0, 0], + max: [2, 1, 0.5], + color: "#d8d2c7", + data: { tileId: "tile-1" }, + faces: { + top: { texture: "/tile.png", data: { face: "top" } }, + bottom: false, + }, +}); +``` + ## The polygon primitive `` (vanilla) and `` (React / Vue) render a single polygon as an atlas-backed `` for UV-textured and flat-color faces. They forward standard DOM props (`onclick`, `class`, `style`, `aria-*`, etc.). diff --git a/website/src/content/docs/guides/textures.mdx b/website/src/content/docs/guides/textures.mdx index 016bc271..71d76e63 100644 --- a/website/src/content/docs/guides/textures.mdx +++ b/website/src/content/docs/guides/textures.mdx @@ -153,15 +153,15 @@ For textured polygons, polycss runs a one-time atlas canvas pass at mount. Flat- 1. Extract the texture image from the file (or fetch it by URL) when the polygon has a texture. 2. Solve a 6-DOF affine transform from the polygon's UV coordinates to its 2D screen footprint when UVs are available. 3. Pack polygon footprints into one or more atlas pages. -4. Clip, draw texture pixels or shaded color fills, and export atlas pages to blob URLs via `canvas.toBlob()`. `textureQuality="auto"` can rasterize these pages below full CSS resolution when packed pages would create oversized runtime bitmaps. +4. Clip, draw texture pixels or shaded color fills, and export atlas pages to blob URLs via `canvas.toBlob()`. `textureQuality="auto"` can rasterize these pages below full CSS resolution when packed pages would create oversized runtime bitmaps, and also selects the atlas leaf sprite size used for CSS compositing. 5. Repair antialiased atlas pixels along shared textured edges, then render each polygon as an `` with `background-image`, `background-size`, and `background-position`. Generated atlas blob URLs are revoked on unmount (call `dispose()` or let `PolyMesh` / `usePolyMesh` handle it). ## Tips -- **`targetSize`**: scale the model so its longest axis fits this many world units (default: `60`). `.vox` models snap to the nearest integer voxel CSS cell size, so the final size may differ slightly to keep voxel slice brushes on integer pixel coordinates. -- **`textureQuality`**: leave at `"auto"` for workload-based bitmap caps, or set a numeric scale for explicit quality. `0.5` uses about one quarter of the atlas bitmap memory of `1`. +- **`targetSize`**: scale the model so its longest axis fits this many world units (default: `60`). `.vox` models snap to the nearest integer voxel CSS cell size, so the final size may differ slightly to keep voxel fast-path coordinates integral. +- **`textureQuality`**: leave at `"auto"` for workload-based bitmap caps and browser/device sprite sizing, or set a numeric raster scale for explicit quality. `0.5` uses about one quarter of the atlas bitmap memory of `1`. - Shared textured edges are repaired automatically during atlas generation. Geometry stays unchanged; only low-alpha atlas pixels at those shared edges are filled from nearby opaque texels. - **`baseUrl`**: for OBJ/glTF files with external texture paths, pass the file's URL so relative paths resolve correctly. - For large meshes: blob URLs for embedded textures and generated atlases are revoked when `dispose()` is called. Always let polycss manage this: don't hold references to blob URLs across remounts.