Complete V1 performance and OOM release gates
This commit is contained in:
375
web/tests/e2e/geometry-10m-performance.spec.ts
Normal file
375
web/tests/e2e/geometry-10m-performance.spec.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
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();
|
||||
|
||||
return {
|
||||
elapsedMs: Math.round(performance.now() - started),
|
||||
baselineHeapBytes,
|
||||
peakHeapBytes,
|
||||
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,
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user