Skip to content
This repository was archived by the owner on May 19, 2026. It is now read-only.

Commit e3e1d69

Browse files
authored
feat: add voxel slice renderer fast path (#66)
Co-authored-by: agustin-littlehat <minotopo@gmail.com>
1 parent 4f85c4d commit e3e1d69

20 files changed

Lines changed: 3005 additions & 46 deletions

File tree

AGENTS.md

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

2323
**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.
2424

25+
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.
26+
2527
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.
2628

2729
### Tag-as-strategy table
@@ -38,12 +40,14 @@ Strategies are ordered cheapest → most expensive. The mesher's job is to maxim
3840

3941
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`). `<s>` is the universal fallback and cannot be disabled.
4042

43+
The voxel slice-brush fast path emits plain `<b>` 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.
44+
4145
### Lighting modes (`PolyTextureLightingMode = "baked" | "dynamic"`)
4246

4347
- **Baked.** Lambert is computed once on the CPU per polygon, multiplied into the inline `color` (for `<b>`/`<i>`/`<u>`) or into the rasterised atlas pixels (for `<s>`). Moving a light requires re-rasterising affected polys.
4448
- **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.
4549

46-
All solid/atlas tags work in both modes. The full coverage matrix is in `packages/polycss/src/styles/styles.ts`.
50+
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`.
4751

4852
### Meshing implications (what generators must respect)
4953

@@ -77,7 +81,7 @@ If you find yourself wanting a `requestAnimationFrame` loop to update many DOM n
7781
- **Hooks/composables:** `usePolyCamera`, `usePolyMesh`, `usePolySceneContext`, `usePolySelect`, `usePolySelectionApi`, `usePolyAnimation`.
7882
- **Components:** `PolyPerspectiveCamera`, `PolyOrthographicCamera`, `PolyOrbitControls`, `PolyMapControls`, `PolyTransformControls`, `PolySelect`, `PolyAxesHelper`, `PolyDirectionalLightHelper`, `PolyControls`.
7983
- **Types:** `PolyDirectionalLight`, `PolyAmbientLight`, `PolyTextureLightingMode`, `PolyAnimationMixer`.
80-
- **Functions:** `findPolyMeshHandle`, `injectPolyBaseStyles`.
84+
- **Functions:** `findPolyMeshHandle`, `injectPolyBaseStyles`, `buildPolyVoxelFaceData`, `buildPolyVoxelSlicePlan`.
8185
- **Vanilla factories:** `create*` names stay as-is (`createPolyScene`, `createPolyControls`, `createTransformControls`, `createSelect`).
8286
- **HTML custom elements:** `poly-` prefix + kebab-case. Existing tags: `<poly-scene>`, `<poly-mesh>`, `<poly-polygon>`, `<poly-controls>`, `<poly-axes-helper>`, `<poly-directional-light-helper>`. Any new element follows the same shape (e.g. `<poly-perspective-camera>`, `<poly-transform-controls>`, `<poly-select>`).
8387
- **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.

bench/perf-shared.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ export const PRESETS = {
5757
options: { targetSize: 60 },
5858
zoom: 0.4, rotX: 65, rotY: 45,
5959
},
60+
"apoc-car": {
61+
url: "/gallery/glb/apocalypse/car.glb",
62+
options: { targetSize: 60 },
63+
zoom: 0.4, rotX: 65, rotY: 45,
64+
},
6065
crate: {
6166
url: "/gallery/obj/opengameart/crate/Box.obj",
6267
mtlUrl: "/gallery/obj/opengameart/crate/Box.mtl",

packages/core/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ npm install @layoutit/polycss-core
5353
| `parseObj(text, options?)` | Parses OBJ text into `ParseResult`. Supports UV (`vt`), materials, `map_Kd` textures. |
5454
| `parseMtl(text)` | Parses MTL text into `{ colors, textures }`. |
5555
| `parseGltf(buffer, options?)` | Parses GLB or glTF `ArrayBuffer` into `ParseResult`. Extracts embedded textures as blob URLs. |
56-
| `parseVox(buffer, options?)` | Parses MagicaVoxel `.vox` `ArrayBuffer` into `ParseResult`. Face-culls interior voxel faces and fan-triangulates exposed quads. |
56+
| `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. |
5757
| `loadMesh(url, options?)` | Fetches a URL, dispatches to the right parser by extension (`.obj`, `.glb`, `.gltf`, `.vox`). Returns `Promise<ParseResult>` and defaults to `meshResolution: "lossy"`. |
5858
| `parseColor(input)` | Parse any CSS color string to `{ r, g, b, a }`. |
5959
| `shadeColor(input, lambert, ...)` | Apply Lambert shading factor to a color. |

packages/core/src/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,8 @@ export type {
140140
export type {
141141
ParseAnimationClip,
142142
ParseAnimationController,
143+
PolyVoxelCell,
144+
PolyVoxelSource,
143145
ParseResult,
144146
} from "./parser/types";
145147
export { parseObj } from "./parser/parseObj";
@@ -155,5 +157,20 @@ export {
155157
export type { SolidTextureSampleOptions } from "./parser/solidTextureSamples";
156158
export { parseVox } from "./parser/parseVox";
157159
export type { VoxParseOptions } from "./parser/parseVox";
160+
export {
161+
buildFaceDataFromVoxelSource as buildPolyVoxelFaceData,
162+
buildSlicePlan as buildPolyVoxelSlicePlan,
163+
NEXT_LAYER_STEP as POLY_VOXEL_NEXT_LAYER_STEP,
164+
} from "./voxel/voxelSlicePlanner";
165+
export type {
166+
Brush as PolyVoxelBrush,
167+
FaceBuffer as PolyVoxelFaceBuffer,
168+
FaceData as PolyVoxelFaceData,
169+
FaceKey as PolyVoxelFaceKey,
170+
PlaneAxis as PolyVoxelPlaneAxis,
171+
PolyVoxelFace,
172+
PolyVoxelWallsMask,
173+
SlicePlan as PolyVoxelSlicePlan,
174+
} from "./voxel/voxelSlicePlanner";
158175
export { loadMesh } from "./parser/loadMesh";
159176
export type { LoadMeshOptions } from "./parser/loadMesh";

packages/core/src/parser/loadMesh.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ export interface LoadMeshOptions {
6363
const FETCH_NAME = "loadMesh";
6464

6565
function withMeshResolution(result: ParseResult, options?: LoadMeshOptions): ParseResult {
66+
// parseVox already emits greedy axis-aligned quads, and voxel fast paths
67+
// need load-time latency dominated by the raw voxel source rather than a
68+
// second generic polygon optimizer pass with marginal fallback savings.
69+
if (result.voxelSource) return result;
6670
const polygons = optimizeMeshPolygons(result.polygons, {
6771
meshResolution: options?.meshResolution,
6872
});

packages/core/src/parser/parseVox.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,35 @@ describe("parseVox — minimal synthetic buffer", () => {
310310
expect(result.polygons.length).toBe(6);
311311
});
312312

313+
it("preserves normalized raw voxel source for slice-brush rendering", () => {
314+
const buf = buildVoxBuffer([3, 2, 1], [{ x: 2, y: 1, z: 0, colorIndex: 1 }]);
315+
const result = parseVox(buf, { targetSize: 30, gridShift: 2 });
316+
expect(result.voxelSource).toEqual({
317+
kind: "magica-vox",
318+
cells: [{ x: 0, y: 0, z: 0, color: "#ffffff" }],
319+
rows: 1,
320+
cols: 1,
321+
depth: 1,
322+
scale: 30,
323+
gridShift: 2,
324+
sourceBytes: buf.byteLength,
325+
});
326+
});
327+
328+
it("snaps voxel scale to integer CSS cell sizes", () => {
329+
const buf = buildVoxBuffer(
330+
[80, 1, 1],
331+
[
332+
{ x: 0, y: 0, z: 0, colorIndex: 1 },
333+
{ x: 79, y: 0, z: 0, colorIndex: 1 },
334+
],
335+
);
336+
const result = parseVox(buf, { targetSize: 70, gridShift: 0 });
337+
expect(result.voxelSource?.scale).toBe(0.88);
338+
const xs = result.polygons.flatMap((p) => p.vertices.map((v) => v[0]));
339+
expect(Math.max(...xs) - Math.min(...xs)).toBeCloseTo(70.4, 3);
340+
});
341+
313342
it("two adjacent voxels share one face — greedy-meshed to 6 polys", () => {
314343
// Two voxels side by side on X: (0,0,0) and (1,0,0). Same material →
315344
// greedy mesh runs each long face as a single 2×1 rectangle:

packages/core/src/parser/parseVox.ts

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,19 @@
2222
* swap). Voxel coordinates are always non-negative (origin at 0), so no
2323
* shift is required by default.
2424
*
25-
* Output mesh is uniformly scaled to fit `targetSize` units along the
26-
* longest bbox axis.
25+
* Output mesh is uniformly scaled near `targetSize` units along the longest
26+
* bbox axis, snapped to the nearest integer CSS cell so voxel slice renderers
27+
* can avoid fractional brush coordinates without adding scale transforms.
2728
*/
2829
import type { Polygon, Vec3 } from "../types";
29-
import type { ParseResult } from "./types";
30+
import { BASE_TILE } from "../camera/camera";
31+
import type { ParseResult, PolyVoxelSource } from "./types";
3032

3133
export interface VoxParseOptions {
3234
/**
33-
* Largest mesh extent (in scene-space units). The mesh is uniformly
34-
* scaled so its longest bbox dimension equals this. Default: 60.
35+
* Largest mesh extent (in scene-space units). For `.vox`, the requested
36+
* extent is snapped to the nearest integer CSS cell size to keep voxel
37+
* slice brushes on integer pixel coordinates. Default: 60.
3538
*/
3639
targetSize?: number;
3740
/**
@@ -406,7 +409,26 @@ export function parseVox(buffer: ArrayBuffer, options?: VoxParseOptions): ParseR
406409
}
407410
}
408411
const maxDim = Math.max(maxX - minX, maxY - minY, maxZ - minZ);
409-
const scale = maxDim > 0 ? targetSize / maxDim : 1;
412+
const rawScale = maxDim > 0 ? targetSize / maxDim : 1;
413+
const targetCellPx = rawScale * BASE_TILE;
414+
const scale = Number.isFinite(targetCellPx) && targetCellPx > 0
415+
? Math.max(1, Math.round(targetCellPx)) / BASE_TILE
416+
: rawScale;
417+
const voxelSource: PolyVoxelSource = {
418+
kind: "magica-vox",
419+
cells: voxels.map((v) => ({
420+
x: v.x - minX,
421+
y: v.y - minY,
422+
z: v.z - minZ,
423+
color: resolveColor(v.colorIndex),
424+
})),
425+
rows: Math.max(0, maxX - minX),
426+
cols: Math.max(0, maxY - minY),
427+
depth: Math.max(0, maxZ - minZ),
428+
scale,
429+
gridShift,
430+
sourceBytes,
431+
};
410432

411433
const round = (n: number): number => Math.round(n * 1000) / 1000;
412434
const project = (v: Vec3): Vec3 => [
@@ -422,18 +444,15 @@ export function parseVox(buffer: ArrayBuffer, options?: VoxParseOptions): ParseR
422444

423445
return {
424446
polygons,
447+
voxelSource,
425448
objectUrls: [],
426449
dispose: () => { /* no-op: parseVox has no minted blob URLs */ },
427450
warnings: [],
428451
metadata: {
429452
triangleCount: polygons.length,
430453
sourceBytes,
431-
// voxelCount is a vox-specific extension to the base metadata shape.
432-
// Cast as any to avoid the structural type mismatch — we keep it in
433-
// metadata so callers can access it without polluting the ParseResult type.
434-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
435454
voxelCount: voxels.length,
436-
} as ParseResult["metadata"],
455+
},
437456
};
438457
}
439458

packages/core/src/parser/types.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,24 @@
1111
*/
1212
import type { Polygon } from "../types";
1313

14+
export interface PolyVoxelCell {
15+
x: number;
16+
y: number;
17+
z: number;
18+
color: string;
19+
}
20+
21+
export interface PolyVoxelSource {
22+
kind: "magica-vox";
23+
cells: PolyVoxelCell[];
24+
rows: number;
25+
cols: number;
26+
depth: number;
27+
scale: number;
28+
gridShift: number;
29+
sourceBytes: number;
30+
}
31+
1432
export interface ParseAnimationClip {
1533
/** Stable numeric index in the source file's animation array. */
1634
index: number;
@@ -35,6 +53,8 @@ export interface ParseAnimationController {
3553
export interface ParseResult {
3654
/** The mesh, as a flat polygon list. Already vertex-permuted to polycss space. */
3755
polygons: Polygon[];
56+
/** Optional raw voxel source for `.vox` fast paths; polygon fallback remains authoritative. */
57+
voxelSource?: PolyVoxelSource;
3858
/** Optional animation sampler for formats that carry timeline data. */
3959
animation?: ParseAnimationController;
4060
/**
@@ -66,5 +86,7 @@ export interface ParseResult {
6686
animations?: ParseAnimationClip[];
6787
/** Source file size in bytes (for diagnostics). */
6888
sourceBytes?: number;
89+
/** Voxel count for `.vox` sources. */
90+
voxelCount?: number;
6991
};
7092
}

0 commit comments

Comments
 (0)