383 lines
19 KiB
TypeScript
383 lines
19 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
|
|
test("streams ten-million-triangle SceneIR ranges into a bounded interactive LOD", async ({ page }) => {
|
|
test.setTimeout(180_000);
|
|
await page.goto("/");
|
|
const result = await page.evaluate(async () => {
|
|
const [largeGeometry, viewportModule, offscreenModule, storageModule, engineModule] = await Promise.all([
|
|
import("/src/performance/large-geometry.ts"),
|
|
import("/src/three-adapter/viewport.ts"),
|
|
import("/src/three-adapter/offscreen-viewport.ts"),
|
|
import("/src/storage/StorageClient.ts"),
|
|
import("/src/engine-client/WebEngineClient.ts"),
|
|
]);
|
|
const { MeshGeometryStreamValidator, parseMeshGeometryStreamRequest, buildLODCacheKey, encodeLODGeometry, decodeLODGeometry } = largeGeometry;
|
|
const { ViewportRenderer } = viewportModule;
|
|
const { OffscreenViewportRenderer } = offscreenModule;
|
|
const { StorageClient } = storageModule;
|
|
const { WebEngineClient } = engineModule;
|
|
const worker = new Worker("/src/workers/geometry-stream.worker.ts", { type: "module" });
|
|
const started = performance.now();
|
|
const heap = (): number => Number((performance as Performance & { memory?: { usedJSHeapSize?: number } }).memory?.usedJSHeapSize ?? 0);
|
|
const baselineHeapBytes = heap();
|
|
let peakHeapBytes = baselineHeapBytes;
|
|
|
|
const engine = new WebEngineClient({ timeoutMs: 60_000 });
|
|
const wasm = await engine.init();
|
|
|
|
const createCanvas = (): HTMLCanvasElement => {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = 320;
|
|
canvas.height = 240;
|
|
canvas.style.cssText = "position:fixed;left:0;top:0;width:320px;height:240px";
|
|
document.body.append(canvas);
|
|
return canvas;
|
|
};
|
|
const waitFor = async (condition: () => boolean, timeoutMs = 30_000): Promise<void> => {
|
|
const deadline = performance.now() + timeoutMs;
|
|
while (!condition()) {
|
|
if (performance.now() > deadline) throw new Error("GEOMETRY_STREAM_BUDGET_EXCEEDED: viewport did not become interactive");
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
}
|
|
};
|
|
const visiblePixels = (renderer: InstanceType<typeof ViewportRenderer>): number => {
|
|
const gl = renderer.renderer.getContext();
|
|
const pixels = new Uint8Array(64 * 64 * 4);
|
|
gl.readPixels(
|
|
Math.max(0, Math.floor((gl.drawingBufferWidth - 64) / 2)),
|
|
Math.max(0, Math.floor((gl.drawingBufferHeight - 64) / 2)),
|
|
64,
|
|
64,
|
|
gl.RGBA,
|
|
gl.UNSIGNED_BYTE,
|
|
pixels,
|
|
);
|
|
let visible = 0;
|
|
for (let index = 0; index < pixels.length; index += 4) {
|
|
if (pixels[index] + pixels[index + 1] + pixels[index + 2] > 80) visible++;
|
|
}
|
|
return visible;
|
|
};
|
|
const snapshot = (meshId: string, triangleCount: number) => ({
|
|
schemaVersion: 1 as const,
|
|
revision: 1,
|
|
sceneId: "scene:geometry-10m",
|
|
source: { kind: "mock" as const },
|
|
coordinateSystem: { upAxis: "Z" as const, forwardAxis: "-Y" as const, handedness: "RIGHT" as const, unitSystem: 0, unitScale: 1 },
|
|
activeObjectId: "object:geometry-10m",
|
|
frame: { current: 1, start: 1, end: 250 },
|
|
nodes: [{
|
|
id: "object:geometry-10m", name: "Ten Million Triangle Mesh", type: "MESH" as const, parentId: null, dataId: meshId,
|
|
visible: true, selectable: true,
|
|
localMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
|
|
worldMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
|
|
transform: { translation: [0, 0, 0] as [number, number, number], rotationEuler: [0, 0, 0] as [number, number, number], scale: [1, 1, 1] as [number, number, number], rotationMode: 1 },
|
|
}],
|
|
meshes: [{
|
|
id: meshId, name: "Ten Million Triangle Mesh", vertexCount: 5_100_000, edgeCount: 0, faceCount: triangleCount,
|
|
cornerCount: triangleCount * 3, triangleCount, geometryStatus: "binary" as const, geometryBufferId: meshId,
|
|
bounds: { min: [-2, -2, -0.1] as [number, number, number], max: [2, 2, 0.1] as [number, number, number] },
|
|
}],
|
|
materials: [], cameras: [], lights: [], worlds: [], images: [], animations: [], collections: [],
|
|
scenes: [{ id: "scene:geometry-10m", name: "Geometry 10M" }],
|
|
});
|
|
|
|
type Geometry = import("/src/performance/large-geometry.ts").MeshGeometryStreamChunkIR;
|
|
type Report = import("/src/performance/large-geometry.ts").MeshGeometryStreamReportIR;
|
|
interface SessionResult {
|
|
report: Report;
|
|
lod?: Geometry;
|
|
acceptedChunks: number;
|
|
detachedTransfers: number;
|
|
lateChunks: number;
|
|
validator?: Awaited<ReturnType<InstanceType<typeof MeshGeometryStreamValidator>["finish"]>>;
|
|
viewport?: Awaited<ReturnType<typeof renderLOD>>;
|
|
cancelLatencyMs?: number;
|
|
}
|
|
|
|
const renderLOD = async (geometry: Geometry, acceptedChunks: () => number) => {
|
|
const meshId = "mesh:geometry-10m";
|
|
const lodMeshId = `${meshId}:lod:0`;
|
|
const geometryBuffer = {
|
|
schemaVersion: 1 as const,
|
|
meshId: lodMeshId,
|
|
byteLength: geometry.byteLength,
|
|
positions: geometry.positions,
|
|
indices: geometry.indices,
|
|
};
|
|
const sourceBuffer = { ...geometryBuffer, meshId };
|
|
const level = {
|
|
level: 0,
|
|
meshId: lodMeshId,
|
|
triangleBudget: geometry.triangleCount,
|
|
outputTriangleCount: geometry.triangleCount,
|
|
outputVertexCount: geometry.vertexCount,
|
|
geometryBuffers: [geometryBuffer],
|
|
};
|
|
const lowColumns = 32;
|
|
const lowRows = 32;
|
|
const lowPositions = new Float32Array((lowColumns + 1) * (lowRows + 1) * 3);
|
|
for (let y = 0; y <= lowRows; y++) for (let x = 0; x <= lowColumns; x++) {
|
|
const offset = (y * (lowColumns + 1) + x) * 3;
|
|
lowPositions.set([x / lowColumns * 4 - 2, y / lowRows * 4 - 2, 0], offset);
|
|
}
|
|
const lowIndices = new Uint32Array(lowColumns * lowRows * 6);
|
|
for (let cell = 0; cell < lowColumns * lowRows; cell++) {
|
|
const x = cell % lowColumns;
|
|
const y = Math.floor(cell / lowColumns);
|
|
const a = y * (lowColumns + 1) + x;
|
|
lowIndices.set([a, a + 1, a + lowColumns + 2, a, a + lowColumns + 2, a + lowColumns + 1], cell * 6);
|
|
}
|
|
const lowGeometryBuffer = {
|
|
schemaVersion: 1 as const,
|
|
meshId: `${meshId}:lod:1`,
|
|
byteLength: lowPositions.byteLength + lowIndices.byteLength,
|
|
positions: lowPositions.buffer,
|
|
indices: lowIndices.buffer,
|
|
};
|
|
const lowLevel = {
|
|
level: 1,
|
|
meshId: lowGeometryBuffer.meshId,
|
|
triangleBudget: lowColumns * lowRows * 2,
|
|
outputTriangleCount: lowColumns * lowRows * 2,
|
|
outputVertexCount: lowPositions.length / 3,
|
|
geometryBuffers: [lowGeometryBuffer],
|
|
};
|
|
const cacheKey = buildLODCacheKey({ objectId: "object:geometry-10m", meshRevision: 1, modifierStackHash: "none", profileHash: "geometry-10m-lod-v1", poseOrRestState: "REST" });
|
|
const encoded = encodeLODGeometry([level, lowLevel]);
|
|
const encodedBytes = encoded.byteLength;
|
|
const projectId = `geometry-10m-${Date.now()}`;
|
|
const manifest = {
|
|
schemaVersion: 1 as const,
|
|
meshId,
|
|
sourceMeshRevision: 1,
|
|
levels: [
|
|
{ level: 0, sourceMeshRevision: 1, triangleBudget: geometry.triangleCount, meshId: lodMeshId },
|
|
{ level: 1, sourceMeshRevision: 1, triangleBudget: lowLevel.triangleBudget, meshId: lowLevel.meshId },
|
|
],
|
|
cacheKey,
|
|
objectId: "object:geometry-10m",
|
|
modifierStackHash: "none",
|
|
profileHash: "geometry-10m-lod-v1",
|
|
poseOrRestState: "REST" as const,
|
|
generatedAt: "2026-08-14T00:00:00.000Z",
|
|
byteLength: encodedBytes,
|
|
};
|
|
const writer = new StorageClient();
|
|
const saved = await writer.saveLOD(projectId, cacheKey, encoded);
|
|
await writer.putLODManifest(projectId, manifest);
|
|
writer.terminate();
|
|
const reader = new StorageClient();
|
|
const reopenedManifest = await reader.getLODManifest(projectId, cacheKey);
|
|
const reopened = await reader.readLOD(projectId, cacheKey);
|
|
const decoded = decodeLODGeometry(reopened.data);
|
|
|
|
let selectedObjectId = "";
|
|
const mainCanvas = createCanvas();
|
|
const main = new ViewportRenderer(mainCanvas, (objectId) => { selectedObjectId = objectId; });
|
|
main.setSnapshot(snapshot(meshId, 10_000_000), [sourceBuffer]);
|
|
main.installLODLevels(meshId, [level, lowLevel]);
|
|
main.camera.position.set(0.1, -0.1, 0.1);
|
|
const nearLOD = main.updateLODSelection().get(meshId)?.level;
|
|
main.camera.position.set(100, -100, 100);
|
|
const farLOD = main.updateLODSelection().get(meshId)?.level;
|
|
main.camera.position.set(4.5, -4.5, 3.5);
|
|
main.updateLODSelection();
|
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
const mainVisible = visiblePixels(main);
|
|
const bounds = mainCanvas.getBoundingClientRect();
|
|
mainCanvas.dispatchEvent(new MouseEvent("click", { clientX: bounds.left + bounds.width / 2, clientY: bounds.top + bounds.height / 2, bubbles: true }));
|
|
mainCanvas.dispatchEvent(new WheelEvent("wheel", { deltaY: -20, bubbles: true, cancelable: true }));
|
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
const mainGeometries = main.renderer.info.memory.geometries;
|
|
|
|
const offscreenCanvas = createCanvas();
|
|
const offscreen = new OffscreenViewportRenderer(offscreenCanvas);
|
|
offscreen.setSnapshot(snapshot(meshId, 10_000_000), [sourceBuffer]);
|
|
await waitFor(() => Number(offscreenCanvas.dataset.rendererPixels ?? 0) > 0);
|
|
const offscreenVisible = Number(offscreenCanvas.dataset.rendererPixels ?? 0);
|
|
offscreenCanvas.dispatchEvent(new WheelEvent("wheel", { deltaY: 20, bubbles: true, cancelable: true }));
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
const firstInteractiveMs = Math.round(performance.now() - started);
|
|
const chunksAtFirstInteractive = acceptedChunks();
|
|
const estimatedGpuBytes = geometry.byteLength * 3 + lowGeometryBuffer.byteLength;
|
|
main.dispose();
|
|
offscreen.dispose();
|
|
mainCanvas.remove();
|
|
offscreenCanvas.remove();
|
|
const deleted = await reader.deleteLOD(projectId, cacheKey);
|
|
const missing = await reader.getLODManifest(projectId, cacheKey);
|
|
reader.terminate();
|
|
|
|
return {
|
|
firstInteractiveMs,
|
|
chunksAtFirstInteractive,
|
|
mainVisible,
|
|
offscreenVisible,
|
|
selectedObjectId,
|
|
lodSelections: [nearLOD, farLOD],
|
|
mainGeometries,
|
|
estimatedGpuBytes,
|
|
cache: {
|
|
key: cacheKey,
|
|
savedBytes: saved.bytes,
|
|
encodedBytes,
|
|
reopenedBytes: reopened.bytes,
|
|
persisted: reopenedManifest.persisted,
|
|
decodedTriangles: decoded.map((item) => item.outputTriangleCount),
|
|
deleted: deleted.persisted && !missing.persisted,
|
|
},
|
|
};
|
|
};
|
|
|
|
const runSession = (
|
|
streamId: string,
|
|
cancelAfterChunks?: number,
|
|
onLOD?: (geometry: Geometry, acceptedChunks: () => number) => Promise<Awaited<ReturnType<typeof renderLOD>>>,
|
|
): Promise<SessionResult> => {
|
|
const request = parseMeshGeometryStreamRequest({
|
|
schemaVersion: 1,
|
|
streamId,
|
|
meshId: "mesh:geometry-10m",
|
|
triangleCount: 10_000_000,
|
|
chunkTriangleCount: 250_000,
|
|
lodTriangleCount: 20_000,
|
|
});
|
|
const validator = new MeshGeometryStreamValidator(request);
|
|
let acceptedChunks = 0;
|
|
let detachedTransfers = 0;
|
|
let lateChunks = 0;
|
|
let cancellationRequested = false;
|
|
let cancellationStarted = 0;
|
|
let lod: Geometry | undefined;
|
|
let viewport: Promise<Awaited<ReturnType<typeof renderLOD>>> | undefined;
|
|
return new Promise((resolve, reject) => {
|
|
worker.onerror = (event) => reject(new Error(event.message));
|
|
worker.onmessage = async (event: MessageEvent<import("/src/performance/large-geometry.ts").MeshGeometryStreamWorkerResponse>) => {
|
|
try {
|
|
const message = event.data;
|
|
if (message.type === "error") throw new Error(`${message.code}: ${message.message}`);
|
|
if (message.type === "detached") {
|
|
if (message.positionsByteLength !== 0 || message.indicesByteLength !== 0) throw new Error("GEOMETRY_STREAM_BUDGET_EXCEEDED: sender retained transferred buffers");
|
|
detachedTransfers++;
|
|
return;
|
|
}
|
|
if (message.type === "lod") {
|
|
lod = message.geometry;
|
|
if (onLOD) viewport = onLOD(lod, () => acceptedChunks);
|
|
worker.postMessage({ type: "ack", streamId, chunkIndex: -1 });
|
|
return;
|
|
}
|
|
if (message.type === "chunk") {
|
|
if (cancellationRequested) { lateChunks++; return; }
|
|
await validator.accept(message.chunk);
|
|
acceptedChunks++;
|
|
peakHeapBytes = Math.max(peakHeapBytes, heap());
|
|
if (cancelAfterChunks !== undefined && acceptedChunks >= cancelAfterChunks) {
|
|
cancellationRequested = true;
|
|
cancellationStarted = performance.now();
|
|
worker.postMessage({ type: "cancel", streamId });
|
|
}
|
|
else worker.postMessage({ type: "ack", streamId, chunkIndex: message.chunk.chunkIndex });
|
|
return;
|
|
}
|
|
if (message.type === "cancelled") {
|
|
await new Promise((done) => setTimeout(done, 50));
|
|
resolve({ report: message.report, lod, acceptedChunks, detachedTransfers, lateChunks, cancelLatencyMs: Math.round(performance.now() - cancellationStarted) });
|
|
return;
|
|
}
|
|
if (message.type === "complete") {
|
|
const validated = await validator.finish();
|
|
const rendered = await viewport;
|
|
resolve({ report: message.report, lod, acceptedChunks, detachedTransfers, lateChunks, validator: validated, viewport: rendered });
|
|
}
|
|
}
|
|
catch (error) { reject(error); }
|
|
};
|
|
worker.postMessage({ type: "start", request });
|
|
});
|
|
};
|
|
|
|
const cancelled = await runSession("geometry-10m-cancel", 2);
|
|
const completed = await runSession("geometry-10m-complete", undefined, renderLOD);
|
|
const invalidCode = await new Promise<string>((resolve, reject) => {
|
|
worker.onerror = (event) => reject(new Error(event.message));
|
|
worker.onmessage = (event: MessageEvent<import("/src/performance/large-geometry.ts").MeshGeometryStreamWorkerResponse>) => {
|
|
if (event.data.type === "error") resolve(event.data.code);
|
|
};
|
|
worker.postMessage({ type: "start", request: { schemaVersion: 1, streamId: "geometry-invalid", meshId: "mesh:invalid", triangleCount: 0, chunkTriangleCount: 1, lodTriangleCount: 1 } });
|
|
});
|
|
worker.terminate();
|
|
engine.terminate();
|
|
|
|
const recoveryCanvas = createCanvas();
|
|
const recovery = new ViewportRenderer(recoveryCanvas);
|
|
const recoveryPositions = new Float32Array([-1, -1, 0, 1, -1, 0, 0, 1, 0]).buffer;
|
|
const recoveryIndices = new Uint32Array([0, 1, 2]).buffer;
|
|
recovery.setSnapshot(snapshot("mesh:geometry-10m", 1), [{ schemaVersion: 1, meshId: "mesh:geometry-10m", byteLength: recoveryPositions.byteLength + recoveryIndices.byteLength, positions: recoveryPositions, indices: recoveryIndices }]);
|
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
const recoveryVisible = visiblePixels(recovery);
|
|
recovery.dispose();
|
|
recoveryCanvas.remove();
|
|
const afterHeapBytes = heap();
|
|
|
|
return {
|
|
elapsedMs: Math.round(performance.now() - started),
|
|
baselineHeapBytes,
|
|
peakHeapBytes,
|
|
afterHeapBytes,
|
|
wasmAllocatedBytes: wasm.allocatedBytes,
|
|
invalidCode,
|
|
cancelled,
|
|
completed,
|
|
recoveryVisible,
|
|
};
|
|
});
|
|
|
|
console.log("geometry-10m-performance", JSON.stringify({
|
|
triangleCount: result.completed.report.triangleCount,
|
|
chunkCount: result.completed.report.chunkCount,
|
|
transferredBytes: result.completed.report.transferredBytes,
|
|
peakWorkingSetBytes: result.completed.report.peakWorkingSetBytes,
|
|
manifestSha256: result.completed.report.manifestSha256,
|
|
firstInteractiveMs: result.completed.viewport?.firstInteractiveMs,
|
|
chunksAtFirstInteractive: result.completed.viewport?.chunksAtFirstInteractive,
|
|
estimatedGpuBytes: result.completed.viewport?.estimatedGpuBytes,
|
|
wasmAllocatedBytes: result.wasmAllocatedBytes,
|
|
baselineHeapBytes: result.baselineHeapBytes,
|
|
peakHeapBytes: result.peakHeapBytes,
|
|
afterHeapBytes: result.afterHeapBytes,
|
|
cacheDeleted: result.completed.viewport?.cache.deleted,
|
|
recoveryVisible: result.recoveryVisible,
|
|
cancelLatencyMs: result.cancelled.cancelLatencyMs,
|
|
elapsedMs: result.elapsedMs,
|
|
}));
|
|
|
|
expect(result.invalidCode).toBe("GEOMETRY_STREAM_INVALID");
|
|
expect(result.cancelled.report).toMatchObject({ status: "CANCELLED", chunkCount: 2, triangleCount: 500_000 });
|
|
expect(result.cancelled.acceptedChunks).toBe(2);
|
|
expect(result.cancelled.lateChunks).toBe(0);
|
|
expect(result.cancelled.cancelLatencyMs).toBeLessThan(2_000);
|
|
expect(result.completed.report).toMatchObject({ status: "COMPLETED", triangleCount: 10_000_000, chunkCount: 40 });
|
|
expect(result.completed.acceptedChunks).toBe(40);
|
|
expect(result.completed.detachedTransfers).toBe(41);
|
|
expect(result.completed.report.transferredBytes).toBe(result.completed.validator?.transferredBytes);
|
|
expect(result.completed.report.peakWorkingSetBytes).toBeLessThanOrEqual(8 * 1024 * 1024);
|
|
expect(result.completed.report.manifestSha256).toBe(result.completed.validator?.manifestSha256);
|
|
expect(result.completed.report.manifestSha256).toBe("224738e0c3aa28ae42167bf8974455a3d0f940f59f19eb51c5768f435c66b24f");
|
|
expect(result.completed.viewport?.firstInteractiveMs).toBeLessThan(60_000);
|
|
expect(result.completed.viewport?.chunksAtFirstInteractive).toBeLessThan(40);
|
|
expect(result.completed.viewport?.mainVisible).toBeGreaterThan(100);
|
|
expect(result.completed.viewport?.offscreenVisible).toBeGreaterThan(10);
|
|
expect(result.completed.viewport?.selectedObjectId).toBe("object:geometry-10m");
|
|
expect(result.completed.viewport?.lodSelections).toEqual([0, 1]);
|
|
expect(result.completed.viewport?.estimatedGpuBytes).toBeLessThan(16 * 1024 * 1024);
|
|
expect(result.completed.viewport?.cache).toMatchObject({ persisted: true, decodedTriangles: [20_000, 2_048], deleted: true });
|
|
expect(result.completed.viewport?.cache.savedBytes).toBe(result.completed.viewport?.cache.encodedBytes);
|
|
expect(result.completed.viewport?.cache.reopenedBytes).toBe(result.completed.viewport?.cache.encodedBytes);
|
|
expect(result.wasmAllocatedBytes).toBeLessThan(512 * 1024 * 1024);
|
|
if (result.baselineHeapBytes > 0) expect(result.peakHeapBytes - result.baselineHeapBytes).toBeLessThan(768 * 1024 * 1024);
|
|
expect(result.recoveryVisible).toBeGreaterThan(100);
|
|
expect(result.elapsedMs).toBeLessThan(150_000);
|
|
});
|