Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ packages/vue/tsconfig.tsbuildinfo
/bench/polycss.js
/bench/polycss-elements.js
/bench/polycss-react.js
/bench/polycss-render-stats.js
/bench/polycss-vue.js
/bench/results/

Expand Down
12 changes: 7 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,26 +62,28 @@ All solid/atlas tags work in both modes. The `.vox` direct-matrix fast path is b

## The "no JS in the render loop" principle

This is the load-bearing constraint behind the whole engine. **JavaScript never runs per-frame to paint polygons.** Once the scene is built and the atlas is rasterised, the browser drives the render entirely through CSS — `matrix3d` transforms, `calc()`-driven custom properties, `background-blend-mode`, `border-shape`, etc.
This is the load-bearing constraint behind the whole engine. **JavaScript should not run per-frame to paint polygons when the motion can be expressed as a scene, mesh, camera, or light update.** Once the scene is built and the atlas is rasterised, the browser drives most rendering through CSS — `matrix3d` transforms, `calc()`-driven custom properties, `background-blend-mode`, `border-shape`, etc.

The current exception is imported skeletal animation. glTF/GLB skinning changes each polygon independently, so the vanilla stable-DOM animation path samples the active clip in JS, keeps the leaf set mounted, caches baked stable-triangle transform frames, and refreshes baked color on a cadence. That optimized path is the default; do not add a user-facing "baseline vs optimized" toggle or maintain a legacy slow path in product UI.

| Where JS runs | Where JS does NOT run |
|---|---|
| Scene construction (`createPolyScene`, mesh ops, vertex snapping) | Per-frame polygon paint |
| OBJ/glTF/GLB import, mesh optimisation, coplanar merging | Per-frame Lambert evaluation (dynamic mode is pure CSS) |
| Atlas planning + rasterisation (one-shot to `<canvas>`, then `toBlob`) | Per-frame atlas redraw (only on baked-mode light changes) |
| Control input handling (`PolyOrbitControls`, `PolyMapControls`, `PolyTransformControls`) | Per-frame transform recomputation of every polygon — only the scene-root or mesh-root transform changes |
| Control input handling (`PolyOrbitControls`, `PolyMapControls`, `PolyTransformControls`) | Per-frame transform recomputation of every polygon for camera/mesh motion — only the scene-root or mesh-root transform changes |
| Camera math (matrix4 product → scene-root `transform` CSS var) | Per-polygon JS in any hot path |
| Hover/selection raycasting (only on pointer events, not per frame) | Continuous re-rendering "ticks" |

If you find yourself wanting a `requestAnimationFrame` loop to update many DOM nodes, stop. Find the CSS variable that should be carrying the change, and update that single variable on a single ancestor. Cascading + `@property`-registered custom properties do the rest.
If you find yourself wanting a `requestAnimationFrame` loop to update many DOM nodes outside skeletal animation, stop. Find the CSS variable that should be carrying the change, and update that single variable on a single ancestor. Cascading + `@property`-registered custom properties do the rest.

## Naming (three.js parity)

- Every public export gets a `Poly` prefix. Exceptions are generic math types: `Vec2`, `Vec3`, `Polygon`, `PolyMaterial` (already prefixed).
- **Hooks/composables:** `usePolyCamera`, `usePolyMesh`, `usePolySceneContext`, `usePolySelect`, `usePolySelectionApi`, `usePolyAnimation`.
- **Components:** `PolyPerspectiveCamera`, `PolyOrthographicCamera`, `PolyOrbitControls`, `PolyMapControls`, `PolyTransformControls`, `PolySelect`, `PolyAxesHelper`, `PolyDirectionalLightHelper`.
- **Types:** `PolyDirectionalLight`, `PolyAmbientLight`, `PolyTextureLightingMode`, `PolyAnimationMixer`.
- **Functions:** `findPolyMeshHandle`, `injectPolyBaseStyles`, `buildPolyVoxelFaceData`, `buildPolyVoxelSlicePlan`.
- **Types:** `PolyDirectionalLight`, `PolyAmbientLight`, `PolyTextureLightingMode`, `PolyAnimationMixer`, `PolyRenderStats`.
- **Functions:** `findPolyMeshHandle`, `injectPolyBaseStyles`, `collectPolyRenderStats`, `buildPolyVoxelFaceData`, `buildPolyVoxelSlicePlan`.
- **Vanilla factories:** `create*` names stay as-is (`createPolyScene`, `createTransformControls`, `createSelect`).
- **HTML custom elements:** `poly-` prefix + kebab-case. Existing tags: `<poly-scene>`, `<poly-mesh>`, `<poly-polygon>`, `<poly-perspective-camera>`, `<poly-orthographic-camera>`, `<poly-axes-helper>`, `<poly-directional-light-helper>`. Any new element follows the same shape (e.g. `<poly-transform-controls>`, `<poly-select>`).
- **Leaf DOM tags (`<b>`, `<i>`, `<s>`, `<u>`):** internal render-strategy tags. Not part of the public API and not user-facing — do not document them as such.
Expand Down
170 changes: 135 additions & 35 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
<p align="center">
<img src="website/public/voxisologo.png" alt="polycss" width="300" />
<img src="https://polycss.com/voxisologo.png" alt="polycss" width="300" />
</p>

# polycss

Render textured 3D meshes as inspectable DOM. No WebGL, no scene canvas, no runtime 3D engine: just DOM polygons positioned with CSS `matrix3d(...)`. Style them with CSS, inspect them in DevTools, and make them interactive through framework components, custom elements, or render props.
A CSS polygon mesh engine. A 3D renderer for the DOM. Renders OBJ, glTF, GLB, MagicaVoxel `.vox`, and generated primitives as real HTML elements transformed with CSS `matrix3d(...)`. Supports colors, textures, lighting, shadows, controls, selection, animation, and per-polygon interaction. Works with React, Vue, custom elements, or plain JavaScript.

Visit [polycss.com](https://polycss.com) for docs and model examples.

<img width="1915" height="900" alt="polycss scene" src="https://polycss.com/voxcss-intro.png" />

## Installation

```bash
Expand All @@ -21,86 +23,184 @@ npm install @layoutit/polycss-vue
npm install @layoutit/polycss
```

## Quick start: React
You can also load polycss directly from a CDN. Here is a minimal custom-element scene:

```html
<script type="module" src="https://esm.sh/@layoutit/polycss/elements"></script>

<poly-camera rot-x="65" rot-y="45">
<poly-scene>
<poly-orbit-controls drag wheel></poly-orbit-controls>
<poly-box size="100" color="#ffd166"></poly-box>
</poly-scene>
</poly-camera>
```

## Framework Components

React and Vue expose the same component model. `<PolyCamera>` owns the viewpoint, `<PolyScene>` owns lighting and atlas options, and `<PolyMesh>` loads or receives polygon data.

```tsx
import { PolyCamera, PolyScene, PolyOrbitControls, PolyIcosahedron } from "@layoutit/polycss-react";
import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/polycss-react";

export function App() {
export default function App() {
return (
<PolyCamera rotX={65} rotY={45}>
<PolyScene>
<PolyScene textureLighting="dynamic">
<PolyOrbitControls drag wheel />
<PolyIcosahedron size={100} color="#ff6644" />
<PolyMesh src="/gallery/obj/cottage.obj" mtl="/gallery/obj/cottage.mtl" />
</PolyScene>
</PolyCamera>
);
}
```

## Quick start: Vue
The Vue package mirrors the same names and props with Vue casing:

```vue
<template>
<PolyCamera :rot-x="65" :rot-y="45">
<PolyScene>
<PolyScene texture-lighting="dynamic">
<PolyOrbitControls drag wheel />
<PolyIcosahedron :size="100" color="#4ecdc4" />
<PolyMesh src="/gallery/obj/cottage.obj" mtl="/gallery/obj/cottage.mtl" />
</PolyScene>
</PolyCamera>
</template>

<script setup lang="ts">
import { PolyCamera, PolyScene, PolyOrbitControls, PolyIcosahedron } from "@layoutit/polycss-vue";
import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/polycss-vue";
</script>
```

## Quick start: Vanilla HTML

```html
<script type="module" src="https://esm.sh/@layoutit/polycss/elements"></script>

<poly-camera rot-x="65" rot-y="45">
<poly-scene>
<poly-orbit-controls drag wheel></poly-orbit-controls>
<poly-icosahedron size="100" color="#ffd166"></poly-icosahedron>
</poly-scene>
</poly-camera>
## API Reference

### PolyCamera

- `rotX`, `rotY` control the orbit angle in degrees.
- `zoom` scales the projected scene.
- `target` pans the camera target in world coordinates.
- `distance` adds dolly pull-back.
- `PolyCamera` is the orthographic default. Use `PolyPerspectiveCamera` when you want perspective depth.

### PolyScene

- `polygons` renders a static `Polygon[]` directly.
- `directionalLight` and `ambientLight` control scene lighting.
- `textureLighting` chooses `"baked"` or `"dynamic"`.
- `textureQuality` controls atlas raster budget.
- `strategies` can disable selected render strategies for diagnostics.
- `autoCenter` rotates around the rendered mesh bounds instead of world origin.

### PolyMesh

- `src` loads `.obj`, `.gltf`, `.glb`, or `.vox` files.
- `mtl` loads companion OBJ materials.
- `polygons` accepts pre-parsed geometry.
- `position`, `scale`, and `rotation` transform the mesh wrapper.
- `autoCenter` shifts the mesh bbox center to local origin.
- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization.
- `castShadow` emits CSS-projected shadows in dynamic lighting mode.

### Controls

- `<PolyOrbitControls>` adds drag orbit, shift-drag pan, wheel zoom, and optional auto-rotate.
- `<PolyMapControls>` uses pan-first map-style input.
- `<PolyFirstPersonControls>` provides keyboard and pointer-look navigation.
- `<PolyTransformControls>` adds translate/rotate gizmos for selected mesh handles.

### Polygon Data Model

Each polygon describes one renderable face:

```ts
const polygons = [
{
vertices: [[0, 0, 0], [60, 0, 0], [0, 60, 0]],
color: "#f97316",
},
{
vertices: [[0, 0, 0], [60, 0, 0], [60, 60, 0], [0, 60, 0]],
texture: "/texture.png",
uvs: [[0, 0], [1, 0], [1, 1], [0, 1]],
},
];
```

## Per-polygon interactivity

Render polygons directly when you need per-face DOM events or custom styling:

```tsx
<PolyCamera>
<PolyScene>
{polygons.map((p, index) => (
{polygons.map((polygon, index) => (
<Poly
key={index}
{...p}
onClick={() => alert(`clicked polygon ${index}`)}
{...polygon}
onClick={() => console.log("clicked polygon", index)}
className="my-polygon"
/>
))}
</PolyScene>
</PolyCamera>
```

## Loading Mesh Files

Use `loadMesh()` from `@layoutit/polycss`, `@layoutit/polycss-react`, or `@layoutit/polycss-vue` to parse supported model formats:

```ts
import { createPolyCamera, createPolyScene, loadMesh } from "@layoutit/polycss";

const host = document.getElementById("polycss")!;
const camera = createPolyCamera({ rotX: 65, rotY: 45 });
const scene = createPolyScene(host, { camera });

const mesh = await loadMesh("https://polycss.com/gallery/obj/cottage.obj", {
mtlUrl: "https://polycss.com/gallery/obj/cottage.mtl",
});

scene.add(mesh);
```

Supported formats:

- OBJ + MTL, including `map_Kd` textures and UV coordinates.
- glTF / GLB, including embedded images and `TEXCOORD_0`.
- MagicaVoxel `.vox`, with direct voxel fast paths when eligible.
- Generated primitives: box, plane, ring, sphere, torus, cylinder, cone, and Platonic solids.

## Performance

polycss renders in the DOM, so performance is mostly determined by how many polygons are mounted and how much texture atlas area they consume. The renderer uses several CSS strategies so simple surfaces stay cheap and textured or irregular surfaces fall back to atlas slices.

- One visible polygon becomes one leaf DOM element.
- Flat rectangles and stable quads use solid CSS leaves.
- Textured polygons are packed into generated texture atlases.
- Dynamic lighting runs through CSS custom properties instead of per-frame JavaScript.
- Voxel-shaped meshes mount only camera-facing leaves when the mesh is eligible.
- `meshResolution: "lossy"` can merge compatible polygons to reduce DOM node count.

For diagnostics, all renderer packages export `collectPolyRenderStats(root)`, which returns mounted polygon leaf counts, shadow counts, surface categories, and bucket counts for an already-rendered scene.

## Packages

| Package | Description |
|---|---|
| `@layoutit/polycss-core` | Parsers, geometry, lighting, and camera helpers. |
| `@layoutit/polycss-react` | React components (`PolyCamera`, `PolyScene`, `PolyOrbitControls`, `PolyMapControls`, `PolyMesh`, `Poly`). |
| `@layoutit/polycss-vue` | Vue 3 components with the same rendering surface. |
| `@layoutit/polycss` | Vanilla custom elements + imperative `createPolyScene` API. |
| `@layoutit/polycss-core` | Pure math, parsers, lighting, camera helpers, mesh optimization. Zero browser globals. |
| `@layoutit/polycss` | Vanilla custom elements and imperative `createPolyScene` API. |
| `@layoutit/polycss-react` | React components, hooks, controls, and core re-exports. |
| `@layoutit/polycss-vue` | Vue 3 components, composables, controls, and core re-exports. |

## Made with polycss

[Layoutit Voxels](https://voxels.layoutit.com)
-> A CSS Voxel editor

<img width="1000" height="600" alt="layoutit-voxels" src="https://polycss.com/layoutit-voxels.png" />

## Supported formats
[Layoutit Terra](https://terra.layoutit.com)
-> A CSS Terrain Generator

- OBJ + MTL, including `map_Kd` textures and UV coordinates
- GLB and self-contained glTF, including embedded images and `TEXCOORD_0`
- MagicaVoxel `.vox`, with face-culling and custom/default palettes
<img width="1000" height="601" alt="layoutit-terra" src="https://polycss.com/layoutit-terra.png" />

## License

Expand Down
40 changes: 38 additions & 2 deletions bench/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ contributors to verify perf claims and catch render regressions.
```sh
pnpm bench:serve # static server on :4400 with an index page
pnpm bench:perf # build bundles + run all 4 renderers × 5 scenarios
pnpm bench:animated-human # build bundles + run the animated human run bench
pnpm bench:lossy # compare lossless / previous lossy / auto lossy counts
pnpm bench:visual # screenshot diff against bench/baselines/*.png
pnpm bench:visual --record # capture new baselines (after intentional renderer changes)
Expand All @@ -30,6 +31,9 @@ All scripts also work directly:
```sh
node bench/perf-bench.mjs --mesh saucer --label run1
node bench/perf-bench.mjs --mesh chicken --renderer react,vue
node bench/animated-human-bench.mjs --mode baked,dynamic --label human-run
node bench/animated-human-bench.mjs --compare-stable-dom --trace
node bench/animated-human-bench.mjs --mesh poly-pizza/animated-robot.glb --clip run --animation-driver progressive-style-cache
node bench/lossy-optimizer-bench.mjs --json bench/results/lossy-optimizer.json
node bench/lossy-optimizer-bench.mjs --models ducky,shark,bicycle
node bench/perf-visual.mjs --mesh chicken --tolerance 0.005
Expand Down Expand Up @@ -137,6 +141,33 @@ tests above what the gallery's OBJs cover.
Use `domOrder` for pure post-render DOM-order probes; `polygonOrder` changes
the polygon array before render planning and is only for diagnostics.

`animated-human.html` is the focused animated-model page. It loads
`/gallery/glb/poly-pizza/animated-human.glb` by default, chooses the run-like
clip when available, and drives `createPolyAnimationMixer.update(dt)` into
`PolyMeshHandle.setPolygons(..., { merge:false, stableDom:true })`. The
Playwright runner accepts `--mode baked,dynamic`, `--clip <name|index|run>`,
`--target-size <n>`, `--compare-stable-dom`,
`--stable-triangle-color-steps <n>`,
`--stable-triangle-color-policy cadence|adaptive`,
`--stable-triangle-color-freeze-frames <n>`,
`--stable-triangle-color-budget <ratio|count>`,
`--stable-triangle-color-max-age <n>`,
`--stable-triangle-color-max-step <channel-delta>`,
`--animation-driver js|progressive-style-cache|js-style-cache|typed-om-style-cache|css-keyframes`,
`--compare-stable-triangle-debug`, `--require-solid-triangles`, `--trace`, and
the same GPU lane flags as the other browser benches. The color freeze option
keeps exact baked colors but staggers leaf color writes across frames; adaptive
color policy spends a capped write budget on leaves with the largest accumulated
color error first. The max-step option caps the per-write RGB channel delta so
cadence updates drift toward the next baked color instead of jumping directly to
it. `css-keyframes` is a bench-only prototype that samples the clip into
per-leaf CSS animations, removing per-frame JS playback from the measurement
window. The solid-triangle guard fails the run if the page leaves the baked
`<u>` path. Use `--stable-triangle-color-freeze-frames 0` to keep the initial
baked colors and skip color writes during animation. The stable-triangle debug
comparison is diagnostic: it splits normal updates, transform-only writes, and
plan-only updates to attribute animation bottlenecks.

---

## Files
Expand All @@ -152,6 +183,7 @@ bench/
with strategy/order/transform diagnostics
perf-react.html loads polycss-react.js (JSX entry)
perf-vue.html loads polycss-vue.js (Vue entry)
animated-human.html vanilla animated GLB page for the human run sequence
entries/
react.tsx React 19 entry: useState-driven per-frame updates
vue.ts Vue 3 entry: ref() + render funcs (no SFC compiler)
Expand All @@ -163,6 +195,10 @@ bench/
a single instance.
perf-bench.mjs Playwright runner. Fresh chromium per scenario,
ephemeral port, structured JSON output.
animated-human-bench.mjs
GPU-default Playwright runner for the animated
human run sequence. Reports FPS, mixer/update cost,
setPolygons cost, render stats, and optional trace.
lossy-optimizer-bench.mjs
Polygon-count strategy bench for lossless,
previous pair-only lossy, forced grouped lossy,
Expand Down Expand Up @@ -209,10 +245,10 @@ solid-color count per stage. Use `--models <ids>` for targeted iteration.

The default corpus starts with the previous hand-checked models
(`Elephant.glb`, `Dog.glb`, `ducky.glb`) and now runs 28 models. `Duck.glb`,
`FishAnimated.glb`, `AnimatedMushnub.glb`, the Khronos animated fox, and
`FishAnimated.glb`, `AnimatedMushnub.glb`, the Quaternius fox, and
`Shark.glb` cover known regression/safety cases; `poly-pizza/cactus-a.glb` and
`poly-pizza/glass.glb` are small grouped-plane wins; `Electricguitar.glb`,
`Dump truck.glb`, `Policecar.glb`, `Violin.glb`, and `Bicycle.glb` cover
`Dump truck.glb`, `Policecar.glb`, and `Violin.glb` cover
mostly-rectangulated and mechanical runtime cases; `AnimatedSnake.glb`,
`AnimatedWizard.glb`, `Zebra.glb`, `Bear.glb`, `Horse.glb`, `Cheetah.glb`,
`Dinosaur.glb`, `Gorilla.glb`, `Hippo.glb`, `Dragon.glb`, `Lobster.glb`,
Expand Down
Loading
Loading