three-virtual-geometry
Version:
Virtual geometry for three.js WebGPU: automatic cluster LOD and GPU-driven culling for billions of triangles. Independent implementation, not affiliated with Epic Games.
1,735 lines • 71 kB
JavaScript
#!/usr/bin/env node
// scripts/bake.ts
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { basename, dirname, extname, join, resolve } from "node:path";
import { pathToFileURL, fileURLToPath } from "node:url";
import * as THREE2 from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { OBJLoader } from "three/addons/loaders/OBJLoader.js";
import { STLLoader } from "three/addons/loaders/STLLoader.js";
import { PLYLoader } from "three/addons/loaders/PLYLoader.js";
import { MeshoptDecoder as MeshoptDecoder2 } from "meshoptimizer";
// src/core/preprocess/buildVirtualMesh.ts
import { MeshoptClusterizer, MeshoptSimplifier as MeshoptSimplifier2 } from "meshoptimizer";
// src/core/constants.ts
var MAX_MESHLET_TRIANGLES = 128;
var MAX_MESHLET_VERTICES = 128;
var ERROR_INFINITY = 1e30;
var MESHLET_BOUNDS_STRIDE = 16;
var MESHLET_INFO_STRIDE = 4;
// src/core/preprocess/partition.ts
function partitionMeshlets(meshlets, groupSize) {
const count = meshlets.length;
if (count <= groupSize) return [meshlets.map((_, i) => i)];
const edgeOwners = /* @__PURE__ */ new Map();
for (let i = 0; i < count; i++) {
for (const e of meshlets[i].boundaryEdges) {
const owners = edgeOwners.get(e);
if (owners) {
if (owners[owners.length - 1] !== i) owners.push(i);
} else edgeOwners.set(e, [i]);
}
}
const adjacency = Array.from({ length: count }, () => /* @__PURE__ */ new Map());
for (const owners of edgeOwners.values()) {
for (let a = 0; a < owners.length; a++) {
for (let b = a + 1; b < owners.length; b++) {
const i = owners[a];
const j = owners[b];
adjacency[i].set(j, (adjacency[i].get(j) || 0) + 1);
adjacency[j].set(i, (adjacency[j].get(i) || 0) + 1);
}
}
}
const order = mortonOrder(meshlets.map((m) => m.center));
const assigned = new Uint8Array(count);
const groups = [];
for (const seed of order) {
if (assigned[seed]) continue;
const group = [seed];
assigned[seed] = 1;
const frontier = /* @__PURE__ */ new Map();
const addNeighbours = (m) => {
for (const [n, shared] of adjacency[m]) {
if (!assigned[n]) frontier.set(n, (frontier.get(n) || 0) + shared);
}
};
addNeighbours(seed);
while (group.length < groupSize && frontier.size > 0) {
let best = -1;
let bestScore = -1;
let bestDist = Infinity;
for (const [n, score] of frontier) {
if (assigned[n]) continue;
const d = dist2(meshlets[n].center, meshlets[seed].center);
if (score > bestScore || score === bestScore && d < bestDist) {
best = n;
bestScore = score;
bestDist = d;
}
}
if (best < 0) break;
frontier.delete(best);
assigned[best] = 1;
group.push(best);
addNeighbours(best);
}
groups.push(group);
}
const result = [];
let pending = [];
const minSize = Math.max(2, Math.floor(groupSize / 2));
for (const group of groups) {
if (group.length >= minSize) {
result.push(group);
continue;
}
if (pending.length + group.length > groupSize) {
result.push(pending);
pending = [];
}
pending.push(...group);
}
if (pending.length > 0) result.push(pending);
return result;
}
function dist2(a, b) {
const dx = a[0] - b[0];
const dy = a[1] - b[1];
const dz = a[2] - b[2];
return dx * dx + dy * dy + dz * dz;
}
function mortonOrder(points) {
const min = [Infinity, Infinity, Infinity];
const max = [-Infinity, -Infinity, -Infinity];
for (const p of points) {
for (let k = 0; k < 3; k++) {
min[k] = Math.min(min[k], p[k]);
max[k] = Math.max(max[k], p[k]);
}
}
const extent = Math.max(max[0] - min[0], max[1] - min[1], max[2] - min[2]) || 1;
const codes = points.map((p) => {
const q = (k) => Math.min(1023, Math.max(0, Math.floor((p[k] - min[k]) / extent * 1023)));
return part1By2(q(0)) | part1By2(q(1)) << 1 | part1By2(q(2)) << 2;
});
return points.map((_, i) => i).sort((a, b) => codes[a] - codes[b]);
}
function part1By2(x) {
x &= 1023;
x = (x | x << 16) & 50331903;
x = (x | x << 8) & 50393103;
x = (x | x << 4) & 51130563;
x = (x | x << 2) & 153391689;
return x;
}
// src/core/preprocess/voxelProxy.ts
var VOXEL_ERROR_CELLS = 2;
function buildVoxelProxy(positions, colors, indices, resolution, uvs) {
if (indices.length === 0) return null;
const min = [Infinity, Infinity, Infinity];
const max = [-Infinity, -Infinity, -Infinity];
for (let i = 0; i < indices.length; i++) {
const o = indices[i] * 3;
for (let k = 0; k < 3; k++) {
min[k] = Math.min(min[k], positions[o + k]);
max[k] = Math.max(max[k], positions[o + k]);
}
}
const extent = Math.max(max[0] - min[0], max[1] - min[1], max[2] - min[2]);
if (!(extent > 0)) return null;
const cell = extent / resolution;
const PAD = 3;
const n = [0, 1, 2].map((k) => Math.ceil((max[k] - min[k]) / cell) + 1 + PAD * 2);
const origin = [0, 1, 2].map((k) => min[k] - PAD * cell);
const [nx, ny, nz] = n;
const sx = 1;
const sy = nx;
const sz = nx * ny;
const total = nx * ny * nz;
const occupied = new Uint8Array(total);
const colorSum = colors ? new Float32Array(total * 3) : null;
const colorCount = colors ? new Uint32Array(total) : null;
const voxelUv = uvs ? new Float32Array(total * 2).fill(NaN) : null;
let sampleU = 0;
let sampleV = 0;
const mark = (x, y, z, r, g, b) => {
const i = Math.floor((x - origin[0]) / cell) * sx + Math.floor((y - origin[1]) / cell) * sy + Math.floor((z - origin[2]) / cell) * sz;
occupied[i] = 1;
if (voxelUv && Number.isNaN(voxelUv[i * 2])) {
voxelUv[i * 2] = sampleU;
voxelUv[i * 2 + 1] = sampleV;
}
if (colorSum) {
colorSum[i * 3] += r;
colorSum[i * 3 + 1] += g;
colorSum[i * 3 + 2] += b;
colorCount[i]++;
}
};
for (let t = 0; t < indices.length; t += 3) {
const a = indices[t] * 3;
const b = indices[t + 1] * 3;
const c = indices[t + 2] * 3;
const longest = Math.max(dist(positions, a, b), dist(positions, b, c), dist(positions, c, a));
const steps = Math.max(1, Math.ceil(longest / (cell * 0.5)));
for (let i = 0; i <= steps; i++) {
for (let j = 0; j <= steps - i; j++) {
const u = i / steps;
const v = j / steps;
const w = 1 - u - v;
const x = positions[a] * w + positions[b] * u + positions[c] * v;
const y = positions[a + 1] * w + positions[b + 1] * u + positions[c + 1] * v;
const z = positions[a + 2] * w + positions[b + 2] * u + positions[c + 2] * v;
if (uvs) {
const ta = indices[t] * 2;
const tb = indices[t + 1] * 2;
const tc = indices[t + 2] * 2;
sampleU = uvs[ta] * w + uvs[tb] * u + uvs[tc] * v;
sampleV = uvs[ta + 1] * w + uvs[tb + 1] * u + uvs[tc + 1] * v;
}
if (colors) {
mark(
x,
y,
z,
colors[a] * w + colors[b] * u + colors[c] * v,
colors[a + 1] * w + colors[b + 1] * u + colors[c + 1] * v,
colors[a + 2] * w + colors[b + 2] * u + colors[c + 2] * v
);
} else mark(x, y, z, 0, 0, 0);
}
}
}
const closed = erode(dilate(occupied, n), n);
const field = new Float32Array(total);
for (let i = 0; i < total; i++) field[i] = closed[i];
blurAxis(field, n, sx, nx);
blurAxis(field, n, sy, ny);
blurAxis(field, n, sz, nz);
const ISO = 0.5;
const cellsX = nx - 1;
const cellsY = ny - 1;
const cellsZ = nz - 1;
const cellVertex = new Int32Array(cellsX * cellsY * cellsZ).fill(-1);
const outPositions = [];
const outNormals = [];
const outColors = [];
const outUvs = [];
const corner = new Float32Array(8);
for (let z = 0; z < cellsZ; z++) {
for (let y = 0; y < cellsY; y++) {
for (let x = 0; x < cellsX; x++) {
const base = x * sx + y * sy + z * sz;
let inside = 0;
for (let c = 0; c < 8; c++) {
corner[c] = field[base + (c & 1) * sx + (c >> 1 & 1) * sy + (c >> 2 & 1) * sz];
if (corner[c] >= ISO) inside++;
}
if (inside === 0 || inside === 8) continue;
let px = 0;
let py = 0;
let pz = 0;
let crossings = 0;
for (let c = 0; c < 8; c++) {
for (const bit of [1, 2, 4]) {
if (c & bit) continue;
const d = c | bit;
const f0 = corner[c];
const f1 = corner[d];
if (f0 >= ISO === f1 >= ISO) continue;
const s = (ISO - f0) / (f1 - f0);
px += (c & 1) + (bit === 1 ? s : 0);
py += (c >> 1 & 1) + (bit === 2 ? s : 0);
pz += (c >> 2 & 1) + (bit === 4 ? s : 0);
crossings++;
}
}
cellVertex[x + y * cellsX + z * cellsX * cellsY] = outPositions.length / 3;
outPositions.push(origin[0] + (x + 0.5 + px / crossings) * cell, origin[1] + (y + 0.5 + py / crossings) * cell, origin[2] + (z + 0.5 + pz / crossings) * cell);
const gx = corner[1] + corner[3] + corner[5] + corner[7] - corner[0] - corner[2] - corner[4] - corner[6];
const gy = corner[2] + corner[3] + corner[6] + corner[7] - corner[0] - corner[1] - corner[4] - corner[5];
const gz = corner[4] + corner[5] + corner[6] + corner[7] - corner[0] - corner[1] - corner[2] - corner[3];
const len = Math.hypot(gx, gy, gz) || 1;
outNormals.push(-gx / len, -gy / len, -gz / len);
if (colorSum) {
let r = 0;
let g = 0;
let b = 0;
let count = 0;
for (let c = 0; c < 8; c++) {
const i = base + (c & 1) * sx + (c >> 1 & 1) * sy + (c >> 2 & 1) * sz;
if (!colorCount[i]) continue;
r += colorSum[i * 3];
g += colorSum[i * 3 + 1];
b += colorSum[i * 3 + 2];
count += colorCount[i];
}
if (count) outColors.push(r / count, g / count, b / count);
else outColors.push(NaN, NaN, NaN);
}
if (voxelUv) {
let u = NaN;
let v = NaN;
for (let c = 0; c < 8 && Number.isNaN(u); c++) {
const i = base + (c & 1) * sx + (c >> 1 & 1) * sy + (c >> 2 & 1) * sz;
u = voxelUv[i * 2];
v = voxelUv[i * 2 + 1];
}
outUvs.push(u, v);
}
}
}
}
const outIndices = [];
const cellIndex = (x, y, z) => cellVertex[x + y * cellsX + z * cellsX * cellsY];
const quad = (a, b, c, d, nx_, ny_, nz_) => {
const ux = outPositions[c * 3] - outPositions[a * 3];
const uy = outPositions[c * 3 + 1] - outPositions[a * 3 + 1];
const uz = outPositions[c * 3 + 2] - outPositions[a * 3 + 2];
const vx = outPositions[d * 3] - outPositions[b * 3];
const vy = outPositions[d * 3 + 1] - outPositions[b * 3 + 1];
const vz = outPositions[d * 3 + 2] - outPositions[b * 3 + 2];
const cx = uy * vz - uz * vy;
const cy = uz * vx - ux * vz;
const cz = ux * vy - uy * vx;
if (cx * nx_ + cy * ny_ + cz * nz_ >= 0) outIndices.push(a, b, c, a, c, d);
else outIndices.push(a, c, b, a, d, c);
};
for (let z = 1; z < nz - 1; z++) {
for (let y = 1; y < ny - 1; y++) {
for (let x = 1; x < nx - 1; x++) {
const i = x * sx + y * sy + z * sz;
const here = field[i] >= ISO;
if (here !== field[i + sx] >= ISO) {
const s = here ? 1 : -1;
quad(cellIndex(x, y - 1, z - 1), cellIndex(x, y, z - 1), cellIndex(x, y, z), cellIndex(x, y - 1, z), s, 0, 0);
}
if (here !== field[i + sy] >= ISO) {
const s = here ? 1 : -1;
quad(cellIndex(x - 1, y, z - 1), cellIndex(x, y, z - 1), cellIndex(x, y, z), cellIndex(x - 1, y, z), 0, s, 0);
}
if (here !== field[i + sz] >= ISO) {
const s = here ? 1 : -1;
quad(cellIndex(x - 1, y - 1, z), cellIndex(x, y - 1, z), cellIndex(x, y, z), cellIndex(x - 1, y, z), 0, 0, s);
}
}
}
}
if (outIndices.length === 0) return null;
let proxyColors = null;
if (colorSum) {
proxyColors = Float32Array.from(outColors);
fillMissing(proxyColors, outIndices, 3);
}
let proxyUvs = null;
if (voxelUv) {
proxyUvs = Float32Array.from(outUvs);
fillMissing(proxyUvs, outIndices, 2);
}
return {
positions: Float32Array.from(outPositions),
normals: Float32Array.from(outNormals),
colors: proxyColors,
uvs: proxyUvs,
indices: Uint32Array.from(outIndices),
cellSize: cell
};
}
function dist(p, a, b) {
return Math.hypot(p[a] - p[b], p[a + 1] - p[b + 1], p[a + 2] - p[b + 2]);
}
function dilate(src, n) {
let a = src;
for (let axis = 0; axis < 3; axis++) a = morph(a, n, axis, 1);
return a;
}
function erode(src, n) {
let a = src;
for (let axis = 0; axis < 3; axis++) a = morph(a, n, axis, 0);
return a;
}
function morph(src, n, axis, grow) {
const [nx, ny, nz] = n;
const stride = axis === 0 ? 1 : axis === 1 ? nx : nx * ny;
const size = n[axis];
const out = new Uint8Array(src.length);
for (let z = 0; z < nz; z++) {
for (let y = 0; y < ny; y++) {
for (let x = 0; x < nx; x++) {
const i = x + y * nx + z * nx * ny;
const c = axis === 0 ? x : axis === 1 ? y : z;
const prev = c > 0 ? src[i - stride] : 0;
const next = c < size - 1 ? src[i + stride] : 0;
out[i] = grow ? src[i] | prev | next : src[i] & prev & next;
}
}
}
return out;
}
function blurAxis(field, n, stride, size) {
const [nx, ny, nz] = n;
const line = new Float32Array(size);
const lines = nx * ny * nz / size;
for (let l = 0; l < lines; l++) {
let start;
if (stride === 1) start = l * nx;
else if (stride === nx) start = l % nx + Math.floor(l / nx) * nx * ny;
else start = l;
for (let k = 0; k < size; k++) line[k] = field[start + k * stride];
for (let k = 0; k < size; k++) {
const a = k > 0 ? line[k - 1] : 0;
const b = k < size - 1 ? line[k + 1] : 0;
field[start + k * stride] = (a + line[k] + b) / 3;
}
}
}
function fillMissing(values, indices, size) {
const count = values.length / size;
for (let pass = 0; pass < 8; pass++) {
let missing = 0;
const sum = new Float32Array(count * size);
const hits = new Uint32Array(count);
for (let t = 0; t < indices.length; t += 3) {
for (let k = 0; k < 3; k++) {
const v = indices[t + k];
if (!Number.isNaN(values[v * size])) continue;
for (let m = 1; m < 3; m++) {
const u = indices[t + (k + m) % 3];
if (Number.isNaN(values[u * size])) continue;
for (let c = 0; c < size; c++) sum[v * size + c] += values[u * size + c];
hits[v]++;
}
}
}
for (let v = 0; v < count; v++) {
if (!Number.isNaN(values[v * size])) continue;
if (hits[v]) {
for (let c = 0; c < size; c++) values[v * size + c] = sum[v * size + c] / hits[v];
} else missing++;
}
if (!missing) return;
}
for (let i = 0; i < values.length; i++) if (Number.isNaN(values[i])) values[i] = 0.5;
}
// src/core/preprocess/aggregate.ts
import { MeshoptSimplifier } from "meshoptimizer";
var PAIR_WINDOW = 24;
function simplifyAggregate(indices, positions, weld2, owner, group, targetTriangles) {
const triangleCount = indices.length / 3;
const parent = /* @__PURE__ */ new Map();
const find = (x) => {
let r = x;
while (parent.get(r) !== r) r = parent.get(r);
while (parent.get(x) !== r) {
const next = parent.get(x);
parent.set(x, r);
x = next;
}
return r;
};
for (let i = 0; i < indices.length; i++) {
const w = weld2[indices[i]];
if (!parent.has(w)) parent.set(w, w);
}
for (let t = 0; t < indices.length; t += 3) {
const a = find(weld2[indices[t]]);
for (let k = 1; k < 3; k++) {
const b = find(weld2[indices[t + k]]);
if (a !== b) parent.set(b, a);
}
}
const byRoot = /* @__PURE__ */ new Map();
for (let t = 0; t < triangleCount; t++) {
const root = find(weld2[indices[t * 3]]);
let list = byRoot.get(root);
if (!list) byRoot.set(root, list = []);
list.push(t);
}
if (byRoot.size < 2) return null;
const initial = [];
for (const triangles of byRoot.values()) {
const own = new Uint32Array(triangles.length * 3);
let free = true;
triangles.forEach((t, i) => {
for (let k = 0; k < 3; k++) {
const v = indices[t * 3 + k];
own[i * 3 + k] = v;
if (owner[weld2[v]] !== group) free = false;
}
});
initial.push({ indices: own, free });
}
const candidates = [reduce(true), reduce(false)].filter((c) => c !== null);
const reaches = candidates.filter((c) => c.indices.length / 3 <= targetTriangles * 1.25);
const pool = reaches.length ? reaches : candidates;
pool.sort((a, b) => reaches.length ? a.error - b.error : a.indices.length - b.indices.length);
return pool[0] ?? null;
function reduce(simplifyFirst) {
let error = 0;
const pieces = initial.map((p) => ({ ...p }));
const ratio = targetTriangles / triangleCount;
let total = 0;
for (const piece of pieces) {
const target = Math.max(6, Math.floor(piece.indices.length * ratio / 3) * 3);
if (simplifyFirst && piece.free && piece.indices.length > target) {
const local = compactLocal(piece.indices, positions);
const [result, e] = MeshoptSimplifier.simplify(local.indices, local.positions, 3, target, 1e10, ["ErrorAbsolute"]);
if (result.length > 0 && result.length < piece.indices.length) {
piece.indices = result.map((i) => local.toGlobal[i]);
error = Math.max(error, e);
}
}
total += piece.indices.length / 3;
}
const newPositions = [];
const newSources = [];
const firstNewVertex = positions.length / 3;
if (total > targetTriangles) {
const free = pieces.map((p, i) => ({ p, i })).filter(({ p }) => p.free);
const info = /* @__PURE__ */ new Map();
for (const { p, i } of free) info.set(i, describe(p.indices, positions));
const order = free.map(({ i }) => i);
const bounds = boundsOf(order.map((i) => info.get(i).center));
const span = Math.max(bounds[3] - bounds[0], bounds[4] - bounds[1], bounds[5] - bounds[2], 1e-9);
const key = (c) => morton([(c[0] - bounds[0]) / span, (c[1] - bounds[1]) / span, (c[2] - bounds[2]) / span]);
order.sort((a, b) => key(info.get(a).center) - key(info.get(b).center));
const nearest = order.map((a, n) => {
let d = Infinity;
for (let m = Math.max(0, n - PAIR_WINDOW); m < Math.min(order.length, n + 1 + PAIR_WINDOW); m++) {
if (m !== n) d = Math.min(d, distance(info.get(a).center, info.get(order[m]).center));
}
return d;
});
const spacing = [...nearest].sort((x, y) => x - y)[Math.floor(nearest.length / 2)];
const taken = /* @__PURE__ */ new Set();
for (let n = 0; n < order.length && total > targetTriangles; n++) {
const a = order[n];
if (taken.has(a)) continue;
let best = -1;
let bestDistance = spacing * 2;
for (let m = n + 1; m < Math.min(order.length, n + 1 + PAIR_WINDOW); m++) {
const b = order[m];
if (taken.has(b)) continue;
const d = distance(info.get(a).center, info.get(b).center);
if (d <= bestDistance) {
bestDistance = d;
best = b;
}
}
if (best < 0) continue;
taken.add(a);
taken.add(best);
const [keep, drop] = info.get(a).area >= info.get(best).area ? [a, best] : [best, a];
const k = info.get(keep);
const dropped = info.get(drop);
const pairArea = Math.max(k.area + dropped.area, 1e-12);
const factor = pairArea / Math.max(k.area, 1e-12);
const w = dropped.area / pairArea;
const shift = [0, 1, 2].map((i) => (dropped.center[i] - k.center[i]) * w);
const widened = widen(pieces[keep].indices, positions, k, factor, shift, firstNewVertex + newSources.length, newPositions, newSources);
error = Math.max(error, bestDistance * Math.max(w, 1 - w), widened.growth);
total -= pieces[drop].indices.length / 3;
pieces[keep].indices = widened.indices;
pieces[drop].indices = new Uint32Array(0);
}
}
const out = new Uint32Array(pieces.reduce((n, p) => n + p.indices.length, 0));
let at = 0;
for (const p of pieces) {
out.set(p.indices, at);
at += p.indices.length;
}
if (out.length >= indices.length) return null;
return { indices: out, error, newPositions: Float32Array.from(newPositions), newSources: Uint32Array.from(newSources), firstNewVertex };
}
}
function widen(indices, positions, piece, areaFactor, shift, firstId, outPositions, outSources) {
const elongated = piece.extent[0] > piece.extent[1] * 2;
const scale = elongated ? [1, areaFactor] : [Math.sqrt(areaFactor), Math.sqrt(areaFactor)];
const growth = Math.max((scale[0] - 1) * piece.extent[0], (scale[1] - 1) * piece.extent[1]) / 2;
const map = /* @__PURE__ */ new Map();
const out = new Uint32Array(indices.length);
const c = piece.center;
for (let i = 0; i < indices.length; i++) {
const v = indices[i];
let id = map.get(v);
if (id === void 0) {
id = firstId + map.size;
map.set(v, id);
const d = [positions[v * 3] - c[0], positions[v * 3 + 1] - c[1], positions[v * 3 + 2] - c[2]];
const p = [positions[v * 3] + shift[0], positions[v * 3 + 1] + shift[1], positions[v * 3 + 2] + shift[2]];
for (let a = 0; a < 2; a++) {
const axis = piece.axes[a];
const along = d[0] * axis[0] + d[1] * axis[1] + d[2] * axis[2];
for (let k = 0; k < 3; k++) p[k] += (scale[a] - 1) * along * axis[k];
}
outPositions.push(p[0], p[1], p[2]);
outSources.push(v);
}
out[i] = id;
}
return { indices: out, growth };
}
function describe(indices, positions) {
const unique = [...new Set(indices)];
const center = [0, 0, 0];
for (const v of unique) for (let k = 0; k < 3; k++) center[k] += positions[v * 3 + k] / unique.length;
const cov = [0, 0, 0, 0, 0, 0, 0, 0, 0];
for (const v of unique) {
const d = [positions[v * 3] - center[0], positions[v * 3 + 1] - center[1], positions[v * 3 + 2] - center[2]];
for (let r = 0; r < 3; r++) for (let q = 0; q < 3; q++) cov[r * 3 + q] += d[r] * d[q];
}
const axes = eigenvectors(cov);
const extent = [0, 0, 0];
for (let a = 0; a < 3; a++) {
let lo = Infinity;
let hi = -Infinity;
for (const v of unique) {
const along = (positions[v * 3] - center[0]) * axes[a][0] + (positions[v * 3 + 1] - center[1]) * axes[a][1] + (positions[v * 3 + 2] - center[2]) * axes[a][2];
lo = Math.min(lo, along);
hi = Math.max(hi, along);
}
extent[a] = hi - lo;
}
let area = 0;
for (let t = 0; t < indices.length; t += 3) {
const a = indices[t] * 3;
const b = indices[t + 1] * 3;
const c = indices[t + 2] * 3;
const u = [positions[b] - positions[a], positions[b + 1] - positions[a + 1], positions[b + 2] - positions[a + 2]];
const w = [positions[c] - positions[a], positions[c + 1] - positions[a + 1], positions[c + 2] - positions[a + 2]];
area += 0.5 * Math.hypot(u[1] * w[2] - u[2] * w[1], u[2] * w[0] - u[0] * w[2], u[0] * w[1] - u[1] * w[0]);
}
return { area, center, axes, extent };
}
function eigenvectors(m) {
const a = [...m];
const v = [1, 0, 0, 0, 1, 0, 0, 0, 1];
for (let sweep = 0; sweep < 24; sweep++) {
const off = Math.abs(a[1]) + Math.abs(a[2]) + Math.abs(a[5]);
if (off < 1e-12 * (Math.abs(a[0]) + Math.abs(a[4]) + Math.abs(a[8]) + 1e-30)) break;
for (const [p, q] of [
[0, 1],
[0, 2],
[1, 2]
]) {
const apq = a[p * 3 + q];
if (Math.abs(apq) < 1e-30) continue;
const theta = (a[q * 3 + q] - a[p * 3 + p]) / (2 * apq);
const t = Math.sign(theta || 1) / (Math.abs(theta) + Math.sqrt(theta * theta + 1));
const c = 1 / Math.sqrt(t * t + 1);
const s = t * c;
for (let k = 0; k < 3; k++) {
const akp = a[k * 3 + p];
const akq = a[k * 3 + q];
a[k * 3 + p] = c * akp - s * akq;
a[k * 3 + q] = s * akp + c * akq;
}
for (let k = 0; k < 3; k++) {
const apk = a[p * 3 + k];
const aqk = a[q * 3 + k];
a[p * 3 + k] = c * apk - s * aqk;
a[q * 3 + k] = s * apk + c * aqk;
}
for (let k = 0; k < 3; k++) {
const vkp = v[k * 3 + p];
const vkq = v[k * 3 + q];
v[k * 3 + p] = c * vkp - s * vkq;
v[k * 3 + q] = s * vkp + c * vkq;
}
}
}
const order = [0, 1, 2].sort((i, j) => a[j * 3 + j] - a[i * 3 + i]);
return order.map((i) => {
const x = v[i];
const y = v[3 + i];
const z = v[6 + i];
const l = Math.hypot(x, y, z) || 1;
return [x / l, y / l, z / l];
});
}
function compactLocal(indices, positions) {
const map = /* @__PURE__ */ new Map();
const toGlobal = [];
const local = new Uint32Array(indices.length);
for (let i = 0; i < indices.length; i++) {
let l = map.get(indices[i]);
if (l === void 0) {
l = toGlobal.length;
map.set(indices[i], l);
toGlobal.push(indices[i]);
}
local[i] = l;
}
const localPositions = new Float32Array(toGlobal.length * 3);
toGlobal.forEach((g, i) => localPositions.set(positions.subarray(g * 3, g * 3 + 3), i * 3));
return { indices: local, positions: localPositions, toGlobal };
}
function boundsOf(points) {
const b = [Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity];
for (const p of points) {
for (let k = 0; k < 3; k++) {
b[k] = Math.min(b[k], p[k]);
b[k + 3] = Math.max(b[k + 3], p[k]);
}
}
return b;
}
var distance = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
function morton(p) {
const spread = (x) => {
let v = Math.min(1023, Math.max(0, Math.floor(x * 1024))) & 1023;
v = (v | v << 16) & 50331903;
v = (v | v << 8) & 50393103;
v = (v | v << 4) & 51130563;
v = (v | v << 2) & 153391689;
return v;
};
return (spread(p[0]) | spread(p[1]) << 1 | spread(p[2]) << 2) >>> 0;
}
// src/core/preprocess/buildVirtualMesh.ts
var DEFAULTS = {
groupSize: 12,
maxLodLevels: 24,
maxGroupKeepRatio: 0.9,
simplifyRatio: 0.5,
minLevelReduction: 0.03,
prune: false,
tailSimplify: true,
minRootTriangles: 4,
voxelLods: true,
voxelResolution: 64,
aggregateLods: true
};
var TAIL_START_TRIANGLES = 1024;
var voxelResolutions = (finest) => {
const list = [];
for (let r = Math.max(4, Math.round(finest)); r >= 4; r = Math.floor(r / 2)) list.push(r);
return list;
};
var yieldToBrowser = () => new Promise((r) => setTimeout(r, 0));
async function buildVirtualMesh(src, options = {}) {
const opts = { ...DEFAULTS, ...options };
const t0 = performance.now();
await MeshoptClusterizer.ready;
await MeshoptSimplifier2.ready;
let positions = src.positions;
let normals = src.normals;
let colors = src.colors ?? null;
let uvs = src.uvs ?? null;
let weld2 = MeshoptSimplifier2.generatePositionRemap(positions, 3);
let voxelLevels = 0;
let aggregateLevels = 0;
const flags = ["LockBorder", "ErrorAbsolute"];
if (opts.prune) flags.push("Prune");
const all = [];
const proxyCache = /* @__PURE__ */ new Map();
const leafs = splitIntoMeshlets(src.indices, 0, null, 0);
let current = leafs;
let lastTriangles = countTriangles(current);
let lodLevels = 1;
const estimatedTotal = Math.max(1, leafs.length * 2);
for (let level = 1; level <= opts.maxLodLevels && current.length > 1; level++) {
if (opts.tailSimplify && lastTriangles <= TAIL_START_TRIANGLES) break;
if (opts.tailSimplify && opts.voxelLods && aggregateLevels > 0 && voxelBeats(current, lastTriangles)) break;
const groups = partitionMeshlets(current, opts.groupSize);
const next = [];
const owner = opts.aggregateLods ? groupOwners(groups, current) : null;
let voxelLimit = NaN;
for (const [g, groupIdx] of groups.entries()) {
const group = groupIdx.map((i) => current[i]);
const merged = concatIndices(group);
const local = compact(merged, positions);
const target = Math.max(3, Math.floor(merged.length * opts.simplifyRatio / 3) * 3);
const [simplified, simplifyError] = MeshoptSimplifier2.simplify(local.indices, local.positions, 3, target, 1e10, flags);
let globalIndices = new Uint32Array(simplified.length);
for (let i = 0; i < simplified.length; i++) globalIndices[i] = local.toGlobal[simplified[i]];
let shapeError = Math.max(simplifyError, extentShrink(local.indices, simplified, local.positions));
if (owner && simplified.length > target * 1.25) {
const aggregate = simplifyAggregate(merged, positions, weld2, owner, g, target / 3);
if (aggregate && Number.isNaN(voxelLimit)) voxelLimit = opts.tailSimplify && opts.voxelLods ? voxelError(lastTriangles) : Infinity;
const childError2 = Math.max(...group.map((m) => m.error));
if (aggregate && aggregate.indices.length < simplified.length && childError2 + aggregate.error < voxelLimit) {
const s = aggregate.newSources;
if (s.length > 0) {
const pick = (src2, size) => {
const out = new Float32Array(s.length * size);
s.forEach((v, i) => out.set(src2.subarray(v * size, v * size + size), i * size));
return out;
};
appendVertices(aggregate.newPositions, pick(normals, 3), colors ? pick(colors, 3) : null, uvs ? pick(uvs, 2) : null);
}
globalIndices = aggregate.indices;
shapeError = Math.max(aggregate.error, extentShrink(merged, globalIndices, positions));
aggregateLevels++;
}
}
if (globalIndices.length > merged.length * opts.maxGroupKeepRatio) {
continue;
}
const lodSphere = enclosingSphere(group.map((m) => m.lodSphere));
const childError = Math.max(...group.map((m) => m.error));
const error = childError + Math.max(shapeError, 1e-7);
for (const child of group) {
child.parentSphere = lodSphere;
child.parentError = error;
}
if (globalIndices.length > 0) {
const outputs = splitIntoMeshlets(globalIndices, level, lodSphere, error);
next.push(...outputs);
for (const child of group) child.replacements = outputs;
}
}
await opts.onProgress?.(Math.min(0.99, all.length / estimatedTotal));
await yieldToBrowser();
if (next.length === 0) break;
lodLevels = level + 1;
current = next;
const triangles = countTriangles(current);
if (triangles > lastTriangles * (1 - opts.minLevelReduction)) break;
lastTriangles = triangles;
}
if (opts.tailSimplify) lodLevels = simplifyTail(all.filter((m) => m.parentError >= ERROR_INFINITY), lodLevels);
await opts.onProgress?.(1);
return pack({ positions, normals, colors: colors ?? void 0, uvs: uvs ?? void 0, indices: src.indices }, all, lodLevels, voxelLevels, performance.now() - t0, aggregateLevels);
function voxelError(triangles) {
const target = Math.max(3, Math.floor(triangles / 2) * 3);
let best = Infinity;
for (const resolution of voxelResolutions(opts.voxelResolution)) {
const proxy = voxelProxy(resolution);
if (!proxy) continue;
const [simplified, proxyError] = MeshoptSimplifier2.simplify(proxy.indices, proxy.positions, 3, Math.min(target, proxy.indices.length), 1e10, ["ErrorAbsolute"]);
if (simplified.length > 0 && simplified.length <= target) best = Math.min(best, VOXEL_ERROR_CELLS * proxy.cellSize + proxyError);
}
return best;
}
function voxelBeats(meshlets, triangles) {
let levelError = 0;
for (const m of meshlets) levelError = Math.max(levelError, m.error);
return voxelError(triangles) <= levelError;
}
function groupOwners(groups, meshlets) {
const owner = new Int32Array(weld2.length).fill(-1);
groups.forEach((group, g) => {
for (const i of group) {
const indices = meshlets[i].indices;
for (let k = 0; k < indices.length; k++) {
const w = weld2[indices[k]];
if (owner[w] === -1) owner[w] = g;
else if (owner[w] !== g) owner[w] = -2;
}
}
});
return owner;
}
function appendVertices(p, n, c, uv) {
const first = positions.length / 3;
const count = p.length / 3;
const grow = (a, b) => {
const out = new Float32Array(a.length + b.length);
out.set(a);
out.set(b, a.length);
return out;
};
positions = grow(positions, p);
normals = grow(normals, n);
if (colors) colors = grow(colors, c ?? new Float32Array(count * 3).fill(1));
if (uvs) uvs = grow(uvs, uv ?? new Float32Array(count * 2));
const w = new Uint32Array(weld2.length + count);
w.set(weld2);
for (let i = 0; i < count; i++) w[weld2.length + i] = first + i;
weld2 = w;
return first;
}
function voxelProxy(resolution) {
if (!proxyCache.has(resolution)) proxyCache.set(resolution, buildVoxelProxy(src.positions, src.colors, src.indices, resolution, src.uvs));
return proxyCache.get(resolution);
}
function simplifyTail(roots, levels) {
if (roots.length === 0) return levels;
const tailFlags = ["ErrorAbsolute"];
if (opts.prune) tailFlags.push("Prune");
let nodes = roots;
let level = levels;
for (; level < levels + opts.maxLodLevels; level++) {
const merged = concatIndices(nodes);
if (merged.length / 3 <= opts.minRootTriangles) break;
const local = compact(merged, positions);
const target = Math.max(3, Math.floor(merged.length / 2 / 3) * 3);
const keepLimit = merged.length * opts.maxGroupKeepRatio;
const childError = Math.max(...nodes.map((m) => m.error));
let [result, error] = MeshoptSimplifier2.simplify(local.indices, local.positions, 3, target, 1e10, tailFlags);
if (result.length > keepLimit) {
const [sloppy, relativeError] = MeshoptSimplifier2.simplifySloppy(local.indices, local.positions, 3, null, target, 1);
if (sloppy.length < result.length) {
result = sloppy;
error = relativeError * MeshoptSimplifier2.getScale(local.positions, 3);
}
}
let globalIndices = null;
let parentError = Infinity;
if (result.length <= keepLimit) {
parentError = childError + Math.max(Math.max(error, extentShrink(local.indices, result, local.positions)), 1e-7);
globalIndices = new Uint32Array(result.length);
for (let i = 0; i < result.length; i++) globalIndices[i] = local.toGlobal[result[i]];
}
let voxel = null;
if (opts.voxelLods) {
for (const resolution of voxelResolutions(opts.voxelResolution)) {
const proxy = voxelProxy(resolution);
if (!proxy) continue;
const proxyTarget = Math.min(target, proxy.indices.length);
const [simplified, proxyError] = MeshoptSimplifier2.simplify(proxy.indices, proxy.positions, 3, proxyTarget, 1e10, ["ErrorAbsolute"]);
if (simplified.length === 0 || simplified.length > keepLimit) continue;
const voxelError2 = Math.max(childError * (1 + 1e-5) + 1e-7, VOXEL_ERROR_CELLS * proxy.cellSize + proxyError);
if (voxelError2 < parentError) {
parentError = voxelError2;
voxel = { proxy, indices: simplified };
}
}
}
if (!Number.isFinite(parentError)) break;
if (voxel) {
const used = compact(voxel.indices, voxel.proxy.positions);
const pick = (src2, size = 3) => {
const out = new Float32Array(used.toGlobal.length * size);
used.toGlobal.forEach((v, i) => out.set(src2.subarray(v * size, v * size + size), i * size));
return out;
};
const { proxy } = voxel;
const first = appendVertices(used.positions, pick(proxy.normals), proxy.colors ? pick(proxy.colors) : null, proxy.uvs ? pick(proxy.uvs, 2) : null);
globalIndices = used.indices.map((i) => i + first);
voxelLevels++;
}
const lodSphere = enclosingSphere(nodes.map((m) => m.lodSphere));
for (const node of nodes) {
node.parentSphere = lodSphere;
node.parentError = parentError;
}
if (globalIndices.length === 0) return level + 1;
const outputs = splitIntoMeshlets(globalIndices, level, lodSphere, parentError);
for (const node of nodes) node.replacements = outputs;
nodes = outputs;
}
return level;
}
function splitIntoMeshlets(indices, lodLevel, lodSphere, error) {
const local = compact(indices, positions);
const built = MeshoptClusterizer.buildMeshlets(local.indices, local.positions, 3, MAX_MESHLET_VERTICES, MAX_MESHLET_TRIANGLES, 0);
const result = [];
for (let m = 0; m < built.meshletCount; m++) {
const vertexOffset = built.meshlets[m * 4 + 0];
const triangleOffset = built.meshlets[m * 4 + 1];
const vertexCount = built.meshlets[m * 4 + 2];
const triangleCount = built.meshlets[m * 4 + 3];
const vertices = new Uint32Array(vertexCount);
for (let v = 0; v < vertexCount; v++) vertices[v] = local.toGlobal[built.vertices[vertexOffset + v]];
const localTriangles = built.triangles.slice(triangleOffset, triangleOffset + triangleCount * 3);
const meshletIndices = new Uint32Array(triangleCount * 3);
for (let i = 0; i < triangleCount * 3; i++) meshletIndices[i] = vertices[localTriangles[i]];
const cullSphere = boundingSphere(meshletIndices, positions);
const meshlet = {
indices: meshletIndices,
lodLevel,
lodSphere: lodSphere ?? cullSphere,
error,
parentSphere: [0, 0, 0, 0],
parentError: ERROR_INFINITY,
cullSphere,
boundaryEdges: findBoundaryEdges(meshletIndices, weld2),
center: [cullSphere[0], cullSphere[1], cullSphere[2]],
replacements: [],
vertices,
localTriangles
};
result.push(meshlet);
all.push(meshlet);
}
return result;
}
}
function pack(src, unsorted, lodLevels, voxelLevels, buildMs, aggregateGroups) {
const all = [...unsorted].sort((a, b) => a.lodLevel - b.lodLevel);
const meshletCount = all.length;
const indices = new Uint32Array(all.reduce((acc, m) => acc + m.indices.length, 0));
const meshletBounds = new Float32Array(meshletCount * MESHLET_BOUNDS_STRIDE);
const meshletInfo = new Uint32Array(meshletCount * MESHLET_INFO_STRIDE);
const indexOf = /* @__PURE__ */ new Map();
all.forEach((m, i) => indexOf.set(m, i));
const replacementGroup = new Int32Array(meshletCount).fill(-1);
const groupOf = /* @__PURE__ */ new Map();
const groupStarts = [0];
let replacementTotal = 0;
all.forEach((m, i) => {
if (m.replacements.length === 0) return;
let g = groupOf.get(m.replacements);
if (g === void 0) {
g = groupOf.size;
groupOf.set(m.replacements, g);
replacementTotal += m.replacements.length;
groupStarts.push(replacementTotal);
}
replacementGroup[i] = g;
});
const replacementStart = Uint32Array.from(groupStarts);
const replacementIndices = new Uint32Array(replacementTotal);
for (const [list, g] of groupOf) list.forEach((r, k) => replacementIndices[replacementStart[g] + k] = indexOf.get(r));
const meshletVertices = new Uint32Array(all.reduce((acc, m) => acc + m.vertices.length, 0));
const meshletTriangles = new Uint32Array(indices.length / 3);
let firstIndex = 0;
let vertexOffset = 0;
const stats = { leafTriangles: 0, leafMeshlets: 0, rootTriangles: 0, rootMeshlets: 0, lodLevels, voxelLevels, aggregateGroups, buildMs };
all.forEach((m, i) => {
indices.set(m.indices, firstIndex);
meshletVertices.set(m.vertices, vertexOffset);
const t = m.localTriangles;
for (let k = 0, tri = firstIndex / 3; k < t.length; k += 3, tri++) {
meshletTriangles[tri] = t[k] | t[k + 1] << 8 | t[k + 2] << 16;
}
const b = i * MESHLET_BOUNDS_STRIDE;
meshletBounds.set(m.lodSphere, b);
meshletBounds.set(m.parentSphere, b + 4);
meshletBounds[b + 8] = m.error;
meshletBounds[b + 9] = m.parentError;
meshletBounds.set(m.cullSphere, b + 12);
const triangleCount = m.indices.length / 3;
const o = i * MESHLET_INFO_STRIDE;
meshletInfo[o + 0] = triangleCount;
meshletInfo[o + 1] = firstIndex;
meshletInfo[o + 2] = m.lodLevel;
meshletInfo[o + 3] = vertexOffset;
firstIndex += m.indices.length;
vertexOffset += m.vertices.length;
if (m.lodLevel === 0) {
stats.leafTriangles += triangleCount;
stats.leafMeshlets += 1;
}
if (m.parentError >= ERROR_INFINITY) {
stats.rootTriangles += triangleCount;
stats.rootMeshlets += 1;
}
});
const whole = enclosingSphere(all.filter((m) => m.lodLevel === 0).map((m) => m.cullSphere));
const levelCount = all.length ? all[all.length - 1].lodLevel + 1 : 0;
const levelRanges = new Uint32Array(levelCount * 2);
const levelErrors = new Float32Array(levelCount * 2);
const levelGuardErrors = new Float32Array(levelCount * 2);
for (let l = 0; l < levelCount; l++) {
levelErrors[l * 2] = Infinity;
levelErrors[l * 2 + 1] = 0;
levelGuardErrors[l * 2] = 0;
levelGuardErrors[l * 2 + 1] = Infinity;
}
all.forEach((m, i) => {
const l = m.lodLevel;
if (levelRanges[l * 2 + 1] === 0) levelRanges[l * 2] = i;
levelRanges[l * 2 + 1]++;
levelErrors[l * 2] = Math.min(levelErrors[l * 2], m.error);
levelErrors[l * 2 + 1] = Math.max(levelErrors[l * 2 + 1], m.parentError);
levelGuardErrors[l * 2] = Math.max(levelGuardErrors[l * 2], m.error);
levelGuardErrors[l * 2 + 1] = Math.min(levelGuardErrors[l * 2 + 1], m.parentError);
});
const lodBoundsSphere = enclosingSphere([
...all.map((m) => m.lodSphere),
...all.filter((m) => m.parentError < ERROR_INFINITY).map((m) => m.parentSphere)
]);
const cullBoundsSphere = enclosingSphere(all.map((m) => m.cullSphere));
return {
positions: src.positions,
normals: src.normals,
colors: src.colors ?? null,
uvs: src.uvs ?? null,
indices,
meshletCount,
meshletBounds,
meshletInfo,
replacementGroup,
replacementStart,
replacementIndices,
meshletVertices,
meshletTriangles,
levelRanges,
levelErrors,
levelGuardErrors,
lodBoundsSphere,
cullBoundsSphere,
boundingSphere: whole,
stats
};
}
function extentShrink(before, after, positions) {
const box = (indices) => {
const min = [Infinity, Infinity, Infinity];
const max = [-Infinity, -Infinity, -Infinity];
for (let i = 0; i < indices.length; i++) {
const o = indices[i] * 3;
for (let k = 0; k < 3; k++) {
const v = positions[o + k];
if (v < min[k]) min[k] = v;
if (v > max[k]) max[k] = v;
}
}
return { min, max };
};
const a = box(before);
if (after.length === 0) return Math.max(a.max[0] - a.min[0], a.max[1] - a.min[1], a.max[2] - a.min[2]) / 2;
const b = box(after);
let shrink = 0;
for (let k = 0; k < 3; k++) shrink = Math.max(shrink, b.min[k] - a.min[k], a.max[k] - b.max[k]);
return shrink;
}
var localOf = new Int32Array(0);
function compact(indices, positions) {
const vertexCount = positions.length / 3;
if (localOf.length < vertexCount) localOf = new Int32Array(vertexCount).fill(-1);
const toGlobalAll = new Uint32Array(indices.length);
const localIndices = new Uint32Array(indices.length);
let count = 0;
for (let i = 0; i < indices.length; i++) {
const g = indices[i];
let l = localOf[g];
if (l < 0) {
l = count++;
localOf[g] = l;
toGlobalAll[l] = g;
}
localIndices[i] = l;
}
const toGlobal = toGlobalAll.subarray(0, count);
const localPositions = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
const g = toGlobal[i];
localOf[g] = -1;
localPositions[i * 3 + 0] = positions[g * 3 + 0];
localPositions[i * 3 + 1] = positions[g * 3 + 1];
localPositions[i * 3 + 2] = positions[g * 3 + 2];
}
return { indices: localIndices, positions: localPositions, toGlobal };
}
var edgeTableKeys = new Float64Array(1024);
var edgeTableCounts = new Uint8Array(1024);
var edgeOrder = new Int32Array(512);
function findBoundaryEdges(indices, weld2) {
const edgeCount = indices.length;
let size = edgeTableKeys.length;
if (size < edgeCount * 2) {
while (size < edgeCount * 2) size <<= 1;
edgeTableKeys = new Float64Array(size);
edgeTableCounts = new Uint8Array(size);
edgeOrder = new Int32Array(size / 2);
}
const mask = size - 1;
let inserted = 0;
for (let t = 0; t < indices.length; t += 3) {
for (let k = 0; k < 3; k++) {
const a = weld2[indices[t + k]];
const b = weld2[indices[t + (k + 1) % 3]];
const key = edgeKey(a, b);
let h = (Math.imul(a < b ? a : b, 2654435761) ^ Math.imul(a < b ? b : a, 2246822507)) & mask;
while (edgeTableCounts[h] !== 0 && edgeTableKeys[h] !== key) h = h + 1 & mask;
if (edgeTableCounts[h] === 0) {
edgeTableKeys[h] = key;
edgeOrder[inserted++] = h;
}
if (edgeTableCounts[h] < 255) edgeTableCounts[h]++;
}
}
const result = [];
for (let i = 0; i < inserted; i++) {
const h = edgeOrder[i];
if (edgeTableCounts[h] === 1) result.push(edgeTableKeys[h]);
edgeTableCounts[h] = 0;
}
return result;
}
var edgeKey = (a, b) => a < b ? a * 67108864 + b : b * 67108864 + a;
function concatIndices(meshlets) {
const result = new Uint32Array(meshlets.reduce((acc, m) => acc + m.indices.length, 0));
let offset = 0;
for (const m of meshlets) {
result.set(m.indices, offset);
offset += m.indices.length;
}
return result;
}
var countTriangles = (meshlets) => meshlets.reduce((acc, m) => acc + m.indices.length / 3, 0);
function boundingSphere(indices, positions) {
const min = [Infinity, Infinity, Infinity];
const max = [-Infinity, -Infinity, -Infinity];
for (let i = 0; i < indices.length; i++) {
const o = indices[i] * 3;
for (let k = 0; k < 3; k++) {
const v = positions[o + k];
if (v < min[k]) min[k] = v;
if (v > max[k]) max[k] = v;
}
}
const cx = (min[0] + max[0]) / 2;
const cy = (min[1] + max[1]) / 2;
const cz = (min[2] + max[2]) / 2;
let r2 = 0;
for (let i = 0; i < indices.length; i++) {
const o = indices[i] * 3;
const dx = positions[o] - cx;
const dy = positions[o + 1] - cy;
const dz = positions[o + 2] - cz;
r2 = Math.max(r2, dx * dx + dy * dy + dz * dz);
}
return [cx, cy, cz, Math.sqrt(r2)];
}
function enclosingSphere(spheres) {
if (spheres.length === 1) return [...spheres[0]];
const centers = new Float32Array(spheres.length * 3);
const radii = new Float32Array(spheres.length);
spheres.forEach((s, i) => {
centers.set([s[0], s[1], s[2]], i * 3);
radii[i] = s[3];
});
const b = MeshoptClusterizer.computeSphereBounds(centers, 3, radii, 1);
let radius = b.radius;
for (const s of spheres) {
const d = Math.hypot(s[0] - b.centerX, s[1] - b.centerY, s[2] - b.centerZ) + s[3];
radius = Math.max(radius, d);
}
return [b.centerX, b.centerY, b.centerZ, radius * 1.0001];
}
// src/core/io/serialize.ts
import { MeshoptDecoder, MeshoptEncoder } from "meshoptimizer";
var VG_FORMAT_VERSION = 1;
var MAGIC = 1329940310;
var PREFIX_BYTES = 16;
var TYPED_ARRAYS = {
Int8Array,
Uint8Array,
Uint8ClampedArray,
Int16Array,
Uint16Array,
Int32Array,
Uint32Array,
Float32Array,
Float64Array
};
var FIELD_HINTS = {
positions: { strides: [12] },
normals: { strides: [12] },
colors: { strides: [12] },
uvs: { strides: [8] },
tangents: { strides: [16] },
meshletBounds: { strides: [64], deflateRaw: true },
meshletInfo: { strides: [16] }
};
var VirtualMeshFormatError = class extends Error {
constructor(message) {
super(`three-virtual-geometry: ${message}`);
this.name = "VirtualMeshFormatError";
}
};
async function encodeVirtualMesh(data, options = {}) {
const compress = options.compress ?? true;
if (compress) await MeshoptEncoder.ready;
const record = data;
const keys = Object.keys(record).filter((k) => record[k] !== void 0 && typeof record[k] !== "function");
const fields = [];
const blobs = [];
const values = {};
let offset = 0;
for (const name of keys) {
const value = record[name];
if (!ArrayBuffer.isView(value)) {
values[name] = value;
continue;
}
const type = value.constructor.name;
if (!(type in TYPED_ARRAYS)) throw new VirtualMeshFormatError(`cannot serialize field "${name}" of type ${type}`);
const array = value;
let blob;
let entry;
if (name === "indices" && !options.keepIndices && indicesMatchMeshlets(data)) {
entry = { name, type, length: array.length, encoding: "meshlet-indices" };
blob = new Uint8Array(0);
} else if (compress) {
({ entry, blob } = await encodeField(name, type, array, data, options.deflate ?? true));
} else {
entry = { name, type, length: array.length, encoding: "raw" };
blob = bytesOf(array);
}
fields.push({ ...entry, offset, byteLength: blob.length });
blobs.push(blob);
offset += align4(blob.length);
}
const headerJson = { generator: "three-virtual-geometry", keys, fields, values };
const headerBytes = new TextEncoder().encode(JSON.stringify(headerJson, jsonReplacer));
const headerPadded = align4(headerBytes.length);
const out = new Uint8Array(PREFIX_BYTES + headerPadded + offset);
out.set(headerBytes, PREFIX_BYTES);
out.fill(32, PREFIX_BYTES + headerBytes.length, PREFIX_BYTES + headerPadded);
const payloadStart = PREFIX_BYTES + headerPadded;
fields.forEach((f, i) => out.set(blobs[i], payloadStart + f.offset));
const view = new DataView(out.buffer);
view.setUint32(0, MAGIC, true);
view.setUint32(4, VG_FORMAT_VERSION, true);
view.setUint32(8, headerBytes.length, true);
view.setUint32(12, checksum(out, PREFIX_BYTES), true);
return out;
}
async function encodeField(name, type, array, data, useDeflate) {
const raw = bytesOf(array);
const base = { name, type, length: array.length };
const hint = FIELD_HINTS[name] ?? {};
const rawCandidate = { entry: { ...base, encoding: "raw" }, blob: raw };
const candidates = [rawCandidate];
const triangles = name === "meshletTriangles" && array instanceof Uint32Array ? encodeMeshletTriangles(array, data.meshletInfo) : null;
if (triangles) candidates.push({ entry: { ...base, encoding: "meshlet-triangles" }, blob: triangles });
else if (raw.length % 4 === 0 && raw.length > 0) {
const strides = (hint.strides ?? [Math.max(4, array.BYTES_PER_ELEMENT)]).filter((s) => s % 4 === 0 && s <= 256 && raw.length % s === 0);
for (const stride of strides) {
const blob = MeshoptEncoder.encodeVertexBufferLevel(raw, raw.length / stride, stride, 2, 1);
candidates.push({ entry: { ...base, encoding: "meshopt-vertex", stride }, blob });
}
}
candidates.sort((a, b) => a.blob.length - b.blob.length);
if (useDeflate && canDeflate() && raw.length >= 64) {
const toDeflate = hint.deflateRaw && candidates[0] !== rawCandidate ? [candidates[0], rawCandidate] : [candidates[0]];
for (const c of toDeflate) candidates.push({ entry: { ...c.entry, deflate: true }, blob: await deflate(c.blob) });
candidates.sort((a, b) => a.blob.length - b.blob.length);
}
for (const c of candidates) {
const decoded = await decodeField({ ...c.entry, offset: 0, byteLength: c.blob.length }, c.blob, { meshletInfo: data.meshletInfo });
if (sameBytes(bytesOf(decoded), raw)) return c;
}
return rawCandidate;
}
function encodeMeshletTriangles(triangles, meshletInfo) {
if (!(meshletInfo instanceof Uint32Array)) return null;
const slots = new Uint32Array(triangles.length * 3);
const covered = new Uint8Array(triangles.length);
for (let m = 0; m < meshletInfo.length; m += MESHLET_INFO_STRIDE) {
const count = meshletInfo[m];
const first = meshletInfo[m + 1] / 3;
const vertexOffset = meshletInfo[m + 3];
if (first + count > triangles.length) return null;
for (let t = first; t < first + count; t++) {
const packed = triangles[t];
if (covered[t] || packed >>> 24 !== 0) return null;
covered[t] = 1;
slots[t * 3] = vertexOffset + (packed & 255);
slots[t * 3 + 1] = vertexOffset + (packed >> 8 & 255);
slots[t * 3 + 2] = vertexOffset + (packed >> 16 & 255);
}
}
if (!covered.every((c) => c === 1)) return null;
const codec = MeshoptEncoder.encodeIndexBuffer(bytesOf(slots), slots.length, 4);
const decoded = new Uint32Array(slots.length);
MeshoptDecoder.decodeIndexBuffer(bytesOf(decoded), slots.length, 4, codec);
const rotations = new Uint8Array(Math.ceil(triangles.length / 4));
for (let t = 0; t < triangles.length; t++) {
const a = slots[t * 3], b = slots[t * 3 + 1], c = slots[t * 3 + 2];
const x = decoded[t * 3], y = decoded[t * 3 + 1], z = decoded[t * 3 + 2];
let r;
if (a === x && b === y && c === z) r = 0;
else if (a === y && b === z && c === x) r = 1;
else if (a === z && b === x && c === y) r = 2;
else return null;
rotations[t >> 2] |= r << (t & 3) * 2;
}
const out = new Uint8Array(4 + align4(codec.length) + rotations.length);
new DataView(out.buffer).setUint32(0, codec.length, true);
out.set(codec, 4);
out.set(rotations, 4 + align4(codec.length));
return out;
}
function indicesMatchMeshlets(data) {
const { indices, meshletInfo, meshletVertices, meshletTriangles } = data;
if (!(indices instanceof Uint32Array && meshletInfo instanceof Uint32Array && meshletVertices instanceof Uint32Array && meshletTriangles instanceof Uint32Array)) {
return false;
}
try {
return sameBytes(bytesOf(rebuildIndices(indices.length, meshletInfo, meshletVertices, meshletTriangles)), bytesOf(indices));
} catch {
return false;
}
}
async function decodeField(field, blob, arrays) {
const Ctor = TYPED_ARRAYS[field.type];
const out = new Ctor(field.length);
const target = bytesOf(out);
const src = field.deflate ? await inflate(blob) : blob;
switch (field.encoding) {
case "raw":
if (src.length !== target.length) throw new VirtualMeshFormatError(`geometry file is corrupt (field "${field.name}" has the wrong size)`);
target.set(src);
break;
case "meshopt-vertex": {
const stride = field.stride ?? 0;
if (stride <= 0 || stride % 4 !== 0 || target.length % stride !== 0) throw new VirtualMeshFormatError(`geometry file is corrupt (field "${field.name}" stride)`);
MeshoptDecoder.decodeVertexBuffer(target, target.length / stride, stride, src);
break;
}
case "meshlet-triangles":
decodeMeshletTriangles(out, src, requireArray(arrays, "meshletInfo", field.name));
break;
case "meshlet-indices":
out.set(
rebuildIndices(
field.length,
requireArray(arrays, "meshletInfo", field.name),
requireArray(arrays, "meshletVertices", field.name),
requireArray(arrays, "meshletTriangles", field.name)
)
);
break;
default:
throw new VirtualMeshFormatError(`unknown encoding "${field.encoding}" for field "${field.name}"`);
}
return out;
}
function requireArray(arrays, name, forField) {
const a = arrays[name];
if (!(a instanceof Uint32Array)) throw new VirtualMeshFormatError(`geometry file is corrupt ("${forField}" needs "${name}")`);
return a;
}
function decodeMeshletTriangles(out, blob, meshletInfo) {
const codecLength = new DataView(blob.buffer, blob.byteOffset, blob.byteLength).getUint32(0, true);
const rotationsStart = 4 + align4(codecLength);
if (rotationsStart + Math.ceil(out.length / 4) > blob.length) throw new Error("truncated triangle data");
const slots = new Uint32Array(out.length * 3);
MeshoptDecoder.decodeIndexBuffer(bytesOf(slots), slots.length, 4, blob.subarray(4, 4 + codecLength));
const rotations = blob.subarray(rotationsStart);
for (let m = 0; m < meshletInfo.length; m += MESHLET_INFO_STRIDE) {
const count = meshletInfo[m];
const first = meshletInfo[m + 1] / 3;
const vertexOffset = meshletInfo[m + 3];
if (first + count > out.length) throw new Error("meshlet range out of bounds");
for (let t = first; t < first + count; t++) {
const r = rotations[t >> 2] >> (t & 3) * 2 & 3;
const x = slots[t * 3] - vertexOffset, y = slots[t * 3 + 1] - vertexOffset, z = slots[t * 3 + 2] - vertexOffset;
const [a, b, c] = r === 0 ? [x, y, z] : r === 1 ? [y, z, x] : [z, x, y];
if ((a | b | c) >>> 8 !== 0) throw new Error("meshlet-local vertex id out of range");
out[t] = a | b << 8 | c << 16;
}
}
}
function rebuildIndices(length, meshletInfo, meshletVertices, meshletTriangles) {
const indices = new Uint32Array(length);
for (let m = 0; m < meshletInfo.length; m += MESHLET_INFO_STRIDE) {
const count = meshletInfo[m];
const first = meshletInfo[m + 1];
const vertexOffset = meshletInfo[m + 3];
if (first + count * 3 > length || first / 3 + count > meshletTriangles.length) throw new VirtualMeshFormatError("geometry file is corrupt (meshlet range out of bounds)");
for (let t = 0; t < count; t++) {
const packed = meshletTriangles[first / 3 + t];
for (let k = 0; k < 3; k++) {
const v = vertexOffset + (packed >> 8 * k & 255);
if (v >= meshletVertices.length) throw new VirtualMeshFormatError("geometry file is corrupt (meshlet vertex out of bounds)");
indices[first + t * 3 + k] = meshletVertices[v];
}
}
}
return indices;
}
var align4 = (n) => n + 3 & ~3;
function bytesOf(a) {
return new Uint8Array(a.buffer, a.byteOffset, a.byteLength);
}
function sameBytes(a, b) {
if (a.length !== b.length) return false;
if (a.byteOffset % 4 === 0 && b.byteOffset % 4 === 0 && a.length % 4 === 0) {
const x = new Uint32Array(a.buffer, a.byteOffset, a.length / 4);
const y = new Uint32Array(b.buffer, b.byteOffset, b.length / 4);
for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return false;
return true;
}
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
return true;
}
function checksum(bytes, start) {
const words = new Uint32Array(bytes.buffer, bytes.byteOffset + start, bytes.length - start >> 2);
let h = 2166136261 ^ words.length;
for (let i = 0; i < words.length; i++) {
h = Math.imul(h ^ words[i], 16777619);
h ^= h >>> 15;
}
return h >>> 0;
}
var canDeflate = () => typeof CompressionStream !== "undefined" && typeof DecompressionStream !== "undefined";
async function deflate(bytes) {
const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream("deflate-raw"));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
async function inflate(bytes) {
if (typeof DecompressionStream === "undefined") throw new VirtualMeshFormatError("this environment cannot decompress the geometry file (no DecompressionStream)");
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
function jsonReplacer(key, value) {
if (typeof value === "number" && !Number.isFinite(value)) return { $number: String(value) };
if (ArrayBuffer.isView(value)) throw new VirtualMeshFormatError(`typed array nested inside field "${key}" cannot be serialized; store it as a top-level field`);
return value;
}
// src/source.ts
import * as THREE from "three/webgpu";
var POSITION_TOLERANCE = 1e-6;
var ATTRIBUTE_TOLERANCE = 1e-4;
function fromBufferGeometry(geometry, options = {}) {
const position = geometry.attributes.position;
const uv = options.uvs ?? true ? geometry.attributes.uv : void 0;
const color = options.colors ? geometry.attributes.color : void 0;
const normal = options.normals === "keep" ? geometry.attributes.normal : void 0;
const index = geometry.index;
const total = index ? index.count : position.count;
const start = Math.max(0, Math.min(total, options.range?.start ?? 0));
const count = Math.floor((Math.min(total, start + (options.range?.count ?? total)) - start) / 3) * 3;
const vertexOf = (corner) => index ? index.getX(start + corner) : start + corner;
const byPosition = weld(count, vertexOf, [{ attribute: position, size: 3, tolerance: POSITION_TOLERANCE }]);
const positions = gather(byPosition.firstVertex, position, 3);
let smoothNormals = null;
if (!normal) {
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.BufferAttribute(positions, 3));
g.setIndex(new THREE.BufferAttribute(byPosition.indices, 1));
g.computeVertexNormals();
smoothNormals = g.attributes.normal.array;
}
if (!uv && !color && !normal) return { positions, normals: smoothNormals, indices: byPosition.indices };
const channels = [{ attribute: position, size: 3, tolerance: POSITION_TOLERANCE }];
if (uv) channels.push({ attribute: uv, size: 2, tolerance: POSITION_TOLERANCE });
if (normal) channels.push({ attribute: normal, size: 3, tolerance: ATTRIBUTE_TOLERANCE });
if (color) channels.push({ attribute: color, size: 3, tolerance: ATTRIBUTE_TOLERANCE });
const full = weld(count, vertexOf, channels);
const result = { positions: gather(full.firstVertex, position, 3), normals: new Float32Array(0), indices: full.indices };
if (normal) {
result.normals = gather(full.firstVertex, normal, 3);
for (let i = 0; i < result.normals.length; i += 3) {
const l = Math.hypot(result.normals[i], result.normals[i + 1], result.normals[i + 2]) || 1;
result.normals[i] /= l;
result.normals[i + 1] /= l;
result.normals[i + 2] /= l;
}
} else {
result.normals = new Float32Array(full.firstVertex.length * 3);
for (let w = 0; w < full.firstVertex.length; w++) {
const p = byPosition.indices[full.firstCorner[w]];
result.normals.set(smoothNormals.subarray(p * 3, p * 3 + 3), w * 3);
}
}
if (uv) result.uvs = gather(full.firstVertex, uv, 2);
if (color) result.colors = gather(full.firstVertex, color, 3);
return result;
}
function gather(vertices, attribute, size) {
const out = new Float32Array(vertices.length * size);
for (let i = 0; i < vertices.length; i++) {
for (let k = 0; k < size; k++) out[i * size + k] = attribute.getComponent(vertices[i], k);
}
return out;
}
function weld(count, vertexOf, channels) {
const width = channels.reduce((a, c) => a + c.size, 0);
const quantize = channels.map(({ tolerance }) => {
const multiplier = Math.pow(10, Math.log10(1 / tolerance));
return { multiplier, additive: tolerance * 0.5 * multiplier };
});
let size = 1;
while (size < count * 2) size <<= 1;
const mask = size - 1;
const table = new Int32Array(size).fill(-1);
const keys = new Float64Array(count * width);
const key = new Float64Array(width);
const firstVertex = new Uint32Array(count);
const firstCorner = new Uint32Array(count);
const indices = new Uint32Array(count);
let welded = 0;
for (let i = 0; i < count; i++) {
const v = vertexOf(i);
let h = 0;
let o = 0;
for (let c = 0; c < channels.length; c++) {
const { attribute, size: n } = channels[c];
const { multiplier, additive } = quantize[c];
for (let k = 0; k < n; k++, o++) {
key[o] = Math.trunc(attribute.getComponent(v, k) * multiplier + additive);
h = h ^ Math.imul(key[o] | 0, HASH_PRIMES[o % HASH_PRIMES.length]) | 0;
}
}
h &= mask;
for (; ; ) {
const w = table[h];
if (w < 0) {
table[h] = welded;
keys.set(key, welded * width);
firstVertex[welded] = v;
firstCorner[welded] = i;
indices[i] = welded++;
break;
}
let same = true;
for (let k = 0; k < width && same; k++) same = keys[w * width + k] === key[k];
if (same) {
indices[i] = w;
break;
}
h = h + 1 & mask;
}
}
return { indices, firstVertex: firstVertex.slice(0, welded), firstCorner: firstCorner.slice(0, welded) };
}
var HASH_PRIMES = [73856093, 19349663, 83492791, 2654435761, 2246822519, 3266489917, 668265263, 374761393, 1181783497, 3735928559, 2028178513];
// scripts/bake.ts
var USAGE = "usage: npx vg-bake <model.glb|model.gltf|model.obj|model.stl|model.ply> [-o outDir] [--prune] [--no-voxel] [--no-deflate]";
function parseArgs(argv) {
const args = { input: "", outDir: "", build: {}, deflate: true };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "-o" || a === "--out") args.outDir = argv[++i] ?? "";
else if (a === "--prune") args.build.prune = true;
else if (a === "--no-voxel") args.build.voxelLods = false;
else if (a === "--no-deflate") args.deflate = false;
else if (a === "-h" || a === "--help") {
console.log(USAGE);
process.exit(0);
} else if (a.startsWith("-")) throw new Error(`unknown option ${a}
${USAGE}`);
else args.input = a;
}
if (!args.input) throw new Error(USAGE);
args.outDir ||= `${basename(args.input, extname(args.input))}.vg`;
return args;
}
function serveFileUrls() {
const g = globalThis;
g.ProgressEvent ??= class extends Event {
constructor(type, init = {}) {
super(type);
Object.assign(this, init);
}
};
const nodeFetch = globalThis.fetch;
globalThis.fetch = (async (input, init) => {
const url = input instanceof Request ? input.url : String(input);
if (!url.startsWith("file:")) return nodeFetch(input, init);
return new Response(readFileSync(fileURLToPath(url)));
});
}
async function load(path) {
const ext = extname(path).toLowerCase();
if (ext === ".obj") return new OBJLoader().parse(readFileSync(path, "utf8"));
if (ext === ".stl" || ext === ".ply") {
const file2 = readFileSync(path);
const bytes = file2.buffer.slice(file2.byteOffset, file2.byteOffset + file2.byteLength);
const geometry = ext === ".stl" ? new STLLoader().parse(bytes) : new PLYLoader().parse(bytes);
return new THREE2.Mesh(geometry, new THREE2.MeshStandardMaterial());
}
if (ext !== ".glb" && ext !== ".gltf") throw new Error(`unsupported file type ${ext} (expected .glb, .gltf, .obj, .stl or .ply)`);
serveFileUrls();
const loader = new GLTFLoader();
loader.setMeshoptDecoder(MeshoptDecoder2);
loader.register(
(parser) => ({
name: "three-virtual-geometry-skip-textures",
beforeRoot: () => {
for (const material of parser.json.materials ?? []) stripTextures(material);
return null;
}
})
);
const file = readFileSync(path);
const data = ext === ".gltf" ? file.toString("utf8") : file.buffer.slice(file.byteOffset, file.byteOffset + file.byteLength);
const resourcePath = pathToFileURL(dirname(resolve(path))).href + "/";
const gltf = await new Promise((ok, fail) => loader.parse(data, resourcePath, ok, fail));
return gltf.scene;
}
function stripTextures(node) {
const record = node;
for (const key of Object.keys(record)) {
if (key.endsWith("Texture")) delete record[key];
else if (record[key] && typeof record[key] === "object") stripTextures(record[key]);
}
}
function materialInfo(material) {
const m = Array.isArray(material) ? material[0] : material;
if (!m) return null;
return {
name: m.name || void 0,
color: m.color ? `#${m.color.getHexString()}` : void 0,
roughness: m.roughness === void 0 ? void 0 : Number(m.roughness.toFixed(4)),
metalness: m.metalness === void 0 ? void 0 : Number(m.metalness.toFixed(4)),
doubleSided: m.side === THREE2.DoubleSide || void 0
};
}
var slug = (s) => s.replace(/[^a-zA-Z0-9_-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 48) || "mesh";
async function main() {
const args = parseArgs(process.argv.slice(2));
const t0 = performance.now();
const root = await load(args.input);
root.updateMatrixWorld(true);
const byGeometry = /* @__PURE__ */ new Map();
root.traverse((o) => {
const mesh = o;
if (!mesh.isMesh || !mesh.geometry?.attributes.position) return;
if (mesh.isPoints || mesh.isLine) return;
let entry = byGeometry.get(mesh.geometry);
if (!entry) byGeometry.set(mesh.geometry, entry = { name: mesh.name || mesh.geometry.name, material: mesh.material, instances: [] });
const instanced = mesh;
if (instanced.isInstancedMesh) {
const m = new THREE2.Matrix4();
for (let i = 0; i < instanced.count; i++) {
instanced.getMatrixAt(i, m);
entry.instances.push(m.premultiply(mesh.matrixWorld).toArray());
}
} else {
entry.instances.push(mesh.matrixWorld.toArray());
}
});
if (byGeometry.size === 0) throw new Error(`no triangle meshes found in ${args.input}`);
console.log(`${args.input}: ${byGeometry.size} unique geometries (loaded in ${(performance.now() - t0).toFixed(0)} ms)`);
mkdirSync(args.outDir, { recursive: true });
const meshes = [];
let index = 0;
for (const [geometry, entry] of byGeometry) {
const file = `${index++}-${slug(entry.name)}.vgeo`;
const source = fromBufferGeometry(geometry);
if (source.indices.length < 3) {
console.log(` ${file}: skipped (no triangles)`);
continue;
}
const data = await buildVirtualMesh(source, args.build);
const t = performance.now();
const bytes = await encodeVirtualMesh(data, { deflate: args.deflate });
const encodeMs = performance.now() - t;
writeFileSync(join(args.outDir, file), bytes);
const s = data.stats;
console.log(
` ${file}: ${s.leafTriangles} triangles, ${data.meshletCount} meshlets, ${s.lodLevels} LODs, built in ${s.buildMs.toFixed(0)} ms, encoded in ${encodeMs.toFixed(0)} ms, ${(bytes.length / 1024).toFixed(0)} KB, ${entry.instances.length} instance(s)`
);
meshes.push({
name: entry.name,
file,
bytes: bytes.length,
triangles: s.leafTriangles,
meshlets: data.meshletCount,
lodLevels: s.lodLevels,
material: materialInfo(entry.material),
instances: entry.instances
});
}
const manifest = { generator: "three-virtual-geometry", formatVersion: VG_FORMAT_VERSION, source: basename(args.input), build: args.build, meshes };
const json = JSON.stringify(manifest, (k, v) => k === "instances" ? v.map((m) => JSON.stringify(m)) : v, 2).replace(/"(\[[^"]*\])"/g, "$1");
writeFileSync(join(args.outDir, "manifest.json"), json);
console.log(`wrote ${meshes.length} .vgeo files and manifest.json to ${args.outDir} in ${((performance.now() - t0) / 1e3).toFixed(1)} s`);
}
main().catch((e) => {
console.error(e instanceof Error ? e.message : e);
process.exit(1);
});