Files
workinf_Blender_Wasm/web/tests/e2e/geometry-10m-performance.spec.ts
2026-08-15 01:01:23 -04:00

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);
});