102 lines
6.1 KiB
TypeScript
102 lines
6.1 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
|
|
test("M8-11 main-thread WebGPU loads a missing page and renders deterministic pixels", async ({ page }) => {
|
|
test.setTimeout(90_000);
|
|
await page.goto("/");
|
|
const result = await page.evaluate(async () => {
|
|
const [viewport, renderer, { NanoVDBFloat32Sampler }] = await Promise.all([
|
|
import("/src/volume/nanovdb-viewport.ts"),
|
|
import("/src/render/nanovdb-volume-renderer.ts"),
|
|
import("/src/volume/nanovdb-float32.ts"),
|
|
]);
|
|
const manifest = await viewport.loadNanoVDBViewportAsset("main-thread-fixture", "/__vdb_fixture__/manifest", "/__vdb_fixture__/bundle", new AbortController().signal);
|
|
const density = manifest.manifest.grids.find((grid) => grid.name === manifest.manifest.material.densityGrid);
|
|
if (!density || !manifest.manifest.gpu.float32TreeLayout) throw new Error("NANOVDB_MANIFEST_INVALID: main-thread fixture is incomplete");
|
|
const response = await fetch("/__vdb_fixture__/bundle", {
|
|
cache: "no-store",
|
|
headers: { Range: `bytes=${density.byteOffset}-${density.byteOffset + density.byteLength - 1}` },
|
|
});
|
|
if (response.status !== 206) throw new Error("NANOVDB_STREAM_INCOMPLETE: main-thread fixture range was not served");
|
|
const payload = await response.arrayBuffer();
|
|
const sampler = new NanoVDBFloat32Sampler(payload, density, manifest.manifest.gpu.float32TreeLayout);
|
|
const coordinate = [0, 0, 0] as const;
|
|
const leafByteOffset = sampler.leafByteOffset(coordinate);
|
|
if (leafByteOffset === null) throw new Error("NANOVDB_GRID_UNSUPPORTED: main-thread fixture has no leaf");
|
|
|
|
const pageByteLength = 256 * 1024;
|
|
const pageCount = Math.ceil(payload.byteLength / pageByteLength);
|
|
const leafPageId = Math.floor(leafByteOffset / pageByteLength);
|
|
if (leafPageId <= 0 || leafPageId >= pageCount) throw new Error("NANOVDB_GRID_UNSUPPORTED: main-thread leaf page is not pageable");
|
|
const session = new renderer.NanoVDBWebGPUDeviceSession();
|
|
const device = await session.open(pageCount * pageByteLength, 6);
|
|
const grid = renderer.createNanoVDBFloat32GridPaged(device, payload.byteLength, pageByteLength, pageCount * pageByteLength);
|
|
for (let pageId = 0; pageId < pageCount; pageId++) {
|
|
if (pageId === leafPageId) continue;
|
|
grid.uploadPage(pageId, payload.slice(pageId * pageByteLength, Math.min(payload.byteLength, (pageId + 1) * pageByteLength)));
|
|
}
|
|
|
|
const feedbackBuffer = renderer.createNanoVDBPageFeedbackGPUBuffer(device, 4);
|
|
const beforeLoad = await renderer.sampleNanoVDBFloat32WebGPU(device, grid, [coordinate], feedbackBuffer);
|
|
const missing = await renderer.readNanoVDBPageFeedbackGPUBuffer(device, feedbackBuffer, 4, pageCount, 17);
|
|
const pageSource = async (pageId: number, signal: AbortSignal): Promise<ArrayBuffer> => {
|
|
if (signal.aborted) throw new DOMException("main-thread page request cancelled", "AbortError");
|
|
const start = density.byteOffset + pageId * pageByteLength;
|
|
const end = Math.min(density.byteOffset + density.byteLength, start + pageByteLength) - 1;
|
|
const pageResponse = await fetch("/__vdb_fixture__/bundle", { cache: "no-store", headers: { Range: `bytes=${start}-${end}` }, signal });
|
|
if (pageResponse.status !== 206) throw new Error(`NANOVDB_STREAM_INCOMPLETE: page range returned ${pageResponse.status}`);
|
|
return pageResponse.arrayBuffer();
|
|
};
|
|
const loadedPages: number[] = [];
|
|
const page = await pageSource(missing.pageIds[0], new AbortController().signal);
|
|
const frames: Array<() => void> = [];
|
|
let redraws = 0;
|
|
const scheduler = new renderer.NanoVDBProgressiveRedrawScheduler((callback) => frames.push(callback), () => { redraws++; });
|
|
const uploader = new renderer.NanoVDBProgressivePageUploader(grid, scheduler);
|
|
loadedPages.push(missing.pageIds[0]);
|
|
const upload = uploader.upload(missing.pageIds[0], page.slice(0));
|
|
const queuedBeforeRedraw = frames.length;
|
|
frames.shift()?.();
|
|
|
|
const afterLoad = await renderer.sampleNanoVDBFloat32WebGPU(device, grid, [coordinate]);
|
|
const material = { ...manifest.manifest.material, interpolation: "LINEAR" as const };
|
|
const pixelsA = await renderer.renderNanoVDBFloat32WebGPU(device, grid, density, material, 64, 64);
|
|
const pixelsB = await renderer.renderNanoVDBFloat32WebGPU(device, grid, density, material, 64, 64);
|
|
const digest = async (pixels: Uint8Array): Promise<string> => Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", pixels))).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
const visible = (pixels: Uint8Array): number => { let count = 0; for (let index = 0; index < pixels.length; index += 4) if (pixels[index] + pixels[index + 1] + pixels[index + 2] > 24) count++; return count; };
|
|
const output = {
|
|
pageCount,
|
|
leafPageId,
|
|
beforeLoad,
|
|
missing,
|
|
loadedPages,
|
|
upload,
|
|
queuedBeforeRedraw,
|
|
redraws,
|
|
afterLoad,
|
|
pixels: { width: 64, height: 64, bytes: pixelsA.byteLength, visible: visible(pixelsA), sha256A: await digest(pixelsA), sha256B: await digest(pixelsB) },
|
|
scheduler: scheduler.stats(),
|
|
};
|
|
scheduler.dispose();
|
|
grid.dispose();
|
|
feedbackBuffer.destroy();
|
|
session.dispose();
|
|
return output;
|
|
});
|
|
|
|
expect(result.pageCount).toBeGreaterThan(1);
|
|
expect(result.leafPageId).toBeGreaterThan(0);
|
|
expect(result.beforeLoad[0].valid).toBe(true);
|
|
expect(result.missing.status).toBe("READY");
|
|
expect(result.missing.pageIds).toEqual([result.leafPageId]);
|
|
expect(result.loadedPages).toEqual([result.leafPageId]);
|
|
expect(result.upload).toEqual({ pageId: result.leafPageId, redrawScheduled: true });
|
|
expect(result.queuedBeforeRedraw).toBe(1);
|
|
expect(result.redraws).toBe(1);
|
|
expect(result.afterLoad[0].valid).toBe(true);
|
|
expect(result.pixels).toMatchObject({ width: 64, height: 64, bytes: 64 * 64 * 4 });
|
|
expect(result.pixels.visible).toBeGreaterThan(0);
|
|
expect(result.pixels.sha256A).toBe("87d08a77a644f37ed59609ebdd18555ab1924f9a3b30d8d084db35e7776cfb9c");
|
|
expect(result.pixels.sha256A).toBe(result.pixels.sha256B);
|
|
expect(result.scheduler).toMatchObject({ pending: false, scheduledCount: 1, redrawCount: 1, capped: false, errorCode: null });
|
|
});
|