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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ All solid/atlas tags work in both modes. The `.vox` direct-matrix fast path is b
### Meshing implications (what generators must respect)

- **Polygon count is the dominant cost.** Each polygon is one DOM node, one `matrix3d`, one paint. Halving the polygon count is almost always worth a more complex mesher.
- **Lossy optimization includes bounded seam repair.** The default `"lossy"` path merges compatible polygons, then repairs high-risk seams with targeted overlap and a small split budget. This can add a few triangles back when it prevents visible cracks; the goal is lower visible seam risk, not a strict guarantee that lossy always has fewer polygons than lossless.
- **Lossy optimization favors low DOM render cost.** The default `"lossy"` path scores exact and approximate merge candidates by estimated render cost and keeps the cheapest direct candidate. It can also try a more aggressive triangle-pair merge candidate inside the same boundary displacement budget, but accepts it only when the render-cost win is material and whole-mesh seam diagnostics do not get worse. It avoids per-candidate seam-repair passes in the import path; targeted seam repair remains a lower-level helper for explicit repair workflows.
- **Fill ratio matters.** A textured polygon's atlas slice equals its local-2D bounding rect. Empty space inside that slice is wasted bitmap pixels. Prefer shapes with high `area / boundingRect.area`:
- axis-aligned rectangle = 1.0 (and hits the fastest path)
- right-isosceles triangle = 0.5
Expand Down
263 changes: 227 additions & 36 deletions bench/lossy-corpus-bench.mjs

Large diffs are not rendered by default.

147 changes: 147 additions & 0 deletions packages/core/src/animation/optimizeAnimatedMeshPolygons.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { describe, expect, it } from "vitest";
import type { ParseAnimationController, ParseResult } from "../parser/types";
import type { Polygon } from "../types";
import { cullInteriorPolygons } from "../cull/cullInteriorPolygons";
import { optimizeAnimatedMeshPolygons } from "./optimizeAnimatedMeshPolygons";

function axisQuad(
cx: number,
cy: number,
cz: number,
normalAxis: "x" | "y" | "z",
sign: 1 | -1,
size = 1,
): Polygon {
const h = size / 2;
const color = "#88aacc";
if (normalAxis === "x") {
return sign > 0
? {
vertices: [
[cx, cy - h, cz - h],
[cx, cy + h, cz - h],
[cx, cy + h, cz + h],
[cx, cy - h, cz + h],
],
color,
}
: {
vertices: [
[cx, cy - h, cz - h],
[cx, cy - h, cz + h],
[cx, cy + h, cz + h],
[cx, cy + h, cz - h],
],
color,
};
}
if (normalAxis === "y") {
return sign > 0
? {
vertices: [
[cx - h, cy, cz - h],
[cx - h, cy, cz + h],
[cx + h, cy, cz + h],
[cx + h, cy, cz - h],
],
color,
}
: {
vertices: [
[cx - h, cy, cz - h],
[cx + h, cy, cz - h],
[cx + h, cy, cz + h],
[cx - h, cy, cz + h],
],
color,
};
}
return sign > 0
? {
vertices: [
[cx - h, cy - h, cz],
[cx + h, cy - h, cz],
[cx + h, cy + h, cz],
[cx - h, cy + h, cz],
],
color,
}
: {
vertices: [
[cx - h, cy - h, cz],
[cx - h, cy + h, cz],
[cx + h, cy + h, cz],
[cx + h, cy - h, cz],
],
color,
};
}

function cubeOutward(cx: number, cy: number, cz: number, size = 1): Polygon[] {
const h = size / 2;
return [
axisQuad(cx + h, cy, cz, "x", 1, size),
axisQuad(cx - h, cy, cz, "x", -1, size),
axisQuad(cx, cy + h, cz, "y", 1, size),
axisQuad(cx, cy - h, cz, "y", -1, size),
axisQuad(cx, cy, cz + h, "z", 1, size),
axisQuad(cx, cy, cz - h, "z", -1, size),
];
}

function translateFrame(polygons: Polygon[], x: number): Polygon[] {
return polygons.map((polygon) => ({
...polygon,
vertices: polygon.vertices.map((vertex): [number, number, number] => [
vertex[0] + x,
vertex[1],
vertex[2],
]),
}));
}

function makeAnimatedParseResult(polygons: Polygon[]): ParseResult {
const clips = [{ index: 0, name: "run", duration: 1, channelCount: 1 }];
const animation: ParseAnimationController = {
clips,
sample: (_clip, timeSeconds) => translateFrame(polygons, timeSeconds),
};
return {
polygons,
animation,
objectUrls: [],
dispose() {},
warnings: [],
metadata: { triangleCount: polygons.length, animations: clips },
};
}

describe("optimizeAnimatedMeshPolygons", () => {
it("filters animated frames through the culled rest-pose plan", () => {
const outer = cubeOutward(0, 0, 0, 10);
const interior = axisQuad(0, 0, 0, "z", 1, 0.1);
const result = makeAnimatedParseResult([...outer, interior]);
const kept = cullInteriorPolygons(result.polygons);
const keptSet = new Set(kept);
const keptIndices = result.polygons.flatMap((polygon, index) =>
keptSet.has(polygon) ? [index] : []
);

const optimized = optimizeAnimatedMeshPolygons(result, { meshResolution: "lossy" });
const running = optimized.animation?.clips.find((clip) => clip.name === "run");

expect(running).toBeDefined();
expect(optimized.polygons.length).toBeLessThan(result.polygons.length);
expect(optimized.polygons).toHaveLength(keptIndices.length);

for (const time of [0, 0.25, running!.duration / 2]) {
const fullFrame = result.animation!.sample(running!.name, time);
const frame = optimized.animation!.sample(running!.name, time);
expect(frame).toHaveLength(optimized.polygons.length);
for (let i = 0; i < frame.length; i += 1) {
expect(frame[i].vertices).toEqual(fullFrame[keptIndices[i]!]!.vertices);
}
}
});

});
106 changes: 89 additions & 17 deletions packages/core/src/merge/optimizePolygons.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { Polygon, Vec3 } from "../types";
import { parseGltf } from "../parser/parseGltf";
import { parseObj } from "../parser/parseObj";
import { bakeSolidTextureSamples } from "../parser/solidTextureSamples";
import { DEFAULT_SEAM_FACET_SPLIT_OPTIONS } from "./seamRepair";
import { seamOverlapDiagnostics } from "./seamRepair";
import { optimizeMeshPolygons } from "./optimizePolygons";

function rect(x0: number, y0: number, x1: number, y1: number): Polygon[] {
Expand All @@ -16,6 +16,24 @@ function rect(x0: number, y0: number, x1: number, y1: number): Polygon[] {
];
}

function shallowFoldedTrianglePairs(count: number): Polygon[] {
const polygons: Polygon[] = [];
for (let i = 0; i < count; i += 1) {
const x = i * 2;
polygons.push(
{
vertices: [[x, 0, 0], [x + 1, 0, 0], [x + 1, 1, 0]],
color: "#6688aa",
},
{
vertices: [[x, 0, 0], [x + 1, 1, 0], [x, 1, 0.04]],
color: "#6688aa",
},
);
}
return polygons;
}

function polygonSignature(polygons: Polygon[]): string[] {
return polygons.map((polygon) =>
`${polygon.color ?? ""}:${polygon.vertices.map((vertex) => vertex.join(",")).join(";")}`
Expand Down Expand Up @@ -410,7 +428,7 @@ describe("optimizeMeshPolygons", () => {
expect(renderCost(lossy)).toBeLessThanOrEqual(renderCost(lossless) + 1e-9);
});

it("keeps default lossy seam repair within the split budget over the exact lossless floor", async () => {
it("keeps default lossy solid-texture fixtures no more expensive than lossless", async () => {
installSolidTextureEnv([10, 20, 30, 255]);

for (const file of ["poly-pizza/arrow.glb", "poly-pizza/bucket.glb"]) {
Expand All @@ -422,23 +440,79 @@ describe("optimizeMeshPolygons", () => {
const lossless = optimizeMeshPolygons(baked.polygons, { meshResolution: "lossless" });
const lossy = optimizeMeshPolygons(baked.polygons, { meshResolution: "lossy" });

expect(lossy.length, file).toBeLessThanOrEqual(
lossless.length + DEFAULT_SEAM_FACET_SPLIT_OPTIONS.budget,
);
expect(renderCost(lossy), file).toBeLessThanOrEqual(renderCost(lossless) + 1e-9);
}
});

it("keeps lossy seam repair tractable on long mechanical NASA geometry", () => {
const raw = parseGltf(loadGlbGalleryFile("nasa/opportunity.glb"), { targetSize: 60 }).polygons;
it("keeps lossy approximate wins on a long strip of shallow folds", () => {
const raw = shallowFoldedTrianglePairs(80);

const lossless = optimizeMeshPolygons(raw, { meshResolution: "lossless" });
const lossy = optimizeMeshPolygons(raw, { meshResolution: "lossy" });

expect(lossy.length).toBeLessThanOrEqual(
lossless.length + DEFAULT_SEAM_FACET_SPLIT_OPTIONS.budget,
);
expect(lossy.length).toBeLessThan(lossless.length);
expect(renderCost(lossy)).toBeLessThan(renderCost(lossless));
});

it("keeps default lossy cheaper than lossless on the Large Building fixture", () => {
const raw = parseGltf(loadGlbGalleryFile("city/Large Building.glb"), { targetSize: 60 }).polygons;

const lossless = optimizeMeshPolygons(raw, { meshResolution: "lossless" });
const lossy = optimizeMeshPolygons(raw, { meshResolution: "lossy" });

expect(lossy.length).toBeLessThanOrEqual(lossless.length);
expect(renderCost(lossy)).toBeLessThanOrEqual(renderCost(lossless));
}, 10_000);

it("uses the gated aggressive approximate pass without adding unclosed seams", () => {
const raw = parseGltf(loadGlbGalleryFile("poly-pizza/animated-shark.glb"), { targetSize: 60 }).polygons;

const lossy = optimizeMeshPolygons(raw, { meshResolution: "lossy" });
const seamDiagnostics = seamOverlapDiagnostics(lossy);

expect(lossy).toHaveLength(634);
expect(seamDiagnostics.unclosedPairs).toBe(0);
expect(seamDiagnostics.maxResidualGapPx).toBe(0);
});

it("rejects large-model rect-cover candidates when topology gap diagnostics are not clean", () => {
const raw = parseGltf(loadGlbGalleryFile("nasa/opportunity.glb"), { targetSize: 60 }).polygons;

const lossless = optimizeMeshPolygons(raw, { meshResolution: "lossless" });
const lossy = optimizeMeshPolygons(raw, { meshResolution: "lossy" });
const losslessSeams = seamOverlapDiagnostics(lossless);
const lossySeams = seamOverlapDiagnostics(lossy);

expect(lossless).toHaveLength(1895);
expect(lossy).toHaveLength(1667);
expect(renderCost(lossy)).toBeLessThanOrEqual(renderCost(lossless));
expect(lossySeams.unclosedPairs).toBeLessThanOrEqual(losslessSeams.unclosedPairs);
expect(lossySeams.maxResidualGapPx).toBeLessThanOrEqual(losslessSeams.maxResidualGapPx);
}, 10_000);

it("keeps small seam-risk fixtures seam-safe under gated lossy candidates", () => {
const rock = parseGltf(loadGlbGalleryFile("poly-pizza/rock.glb"), { targetSize: 60 }).polygons;
const hauntedHouse = parseObj(
loadObjGalleryFile("opengameart/haunted-house/hauntedhouse.obj"),
{ targetSize: 60 },
).polygons;
const chest = parseObj(loadObjGalleryFile("quaternius/dungeon/Chest_gold.obj"), {
targetSize: 60,
}).polygons;

expect(optimizeMeshPolygons(rock, { meshResolution: "lossy" })).toHaveLength(58);
expect(optimizeMeshPolygons(hauntedHouse, { meshResolution: "lossy" })).toHaveLength(184);
const chestLossless = optimizeMeshPolygons(chest, { meshResolution: "lossless" });
const chestLossy = optimizeMeshPolygons(chest, { meshResolution: "lossy" });
const losslessSeams = seamOverlapDiagnostics(chestLossless);
const lossySeams = seamOverlapDiagnostics(chestLossy);

expect(chestLossless).toHaveLength(258);
expect(chestLossy).toHaveLength(250);
expect(lossySeams.unclosedPairs).toBeLessThanOrEqual(losslessSeams.unclosedPairs);
expect(lossySeams.maxResidualGapPx).toBeLessThanOrEqual(losslessSeams.maxResidualGapPx);
});

it("keeps lossless optimization from culling open spacecraft geometry", () => {
const raw = parseGltf(loadGlbGalleryFile("nasa/cubesat-1u.glb"), { targetSize: 60 }).polygons;

Expand All @@ -448,7 +522,7 @@ describe("optimizeMeshPolygons", () => {
expect(lossless.length).toBeGreaterThan(1500);
}, 10_000);

it("does not turn castle seam overlap repairs into concave render polygons", () => {
it("does not turn castle lossy output into concave render polygons", () => {
const raw = parseObj(loadObjGalleryFile("castle.obj"), { targetSize: 60 }).polygons;

const lossless = optimizeMeshPolygons(raw, { meshResolution: "lossless" });
Expand Down Expand Up @@ -492,15 +566,13 @@ describe("optimizeMeshPolygons", () => {
expect(renderCost(lossy)).toBeLessThan(renderCost(lossless));
});

it("keeps default lossy wins on the coliseum fixture", () => {
const raw = parseObj(loadObjGalleryFile("coliseum.obj"), {
targetSize: 80,
palette: ["#c9a876", "#a78760", "#8b6f47", "#6b5538"],
}).polygons;
it("keeps default lossy wins on repeated shallow triangle folds", () => {
const raw = shallowFoldedTrianglePairs(48);

const lossless = optimizeMeshPolygons(raw, { meshResolution: "lossless" });
const lossy = optimizeMeshPolygons(raw, { meshResolution: "lossy" });

expect(lossless.length - lossy.length).toBeGreaterThanOrEqual(400);
expect(lossless.length - lossy.length).toBeGreaterThanOrEqual(40);
expect(renderCost(lossy)).toBeLessThan(renderCost(lossless));
});
});
Loading
Loading