Complete V1 performance and OOM release gates
This commit is contained in:
179
web/app/src/workers/geometry-stream.worker.ts
Normal file
179
web/app/src/workers/geometry-stream.worker.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
MeshGeometryStreamError,
|
||||
meshGeometryStreamChunkSha256,
|
||||
meshGeometryStreamManifestSha256,
|
||||
parseMeshGeometryStreamRequest,
|
||||
type MeshGeometryStreamChunkIR,
|
||||
type MeshGeometryStreamChunkRecordIR,
|
||||
type MeshGeometryStreamRequestIR,
|
||||
type MeshGeometryStreamWorkerRequest,
|
||||
type MeshGeometryStreamWorkerResponse,
|
||||
} from "../../../protocol/geometry-stream";
|
||||
|
||||
const workerScope = self as unknown as {
|
||||
onmessage: ((event: MessageEvent<MeshGeometryStreamWorkerRequest>) => void) | null;
|
||||
postMessage: (message: MeshGeometryStreamWorkerResponse, transfer?: Transferable[]) => void;
|
||||
};
|
||||
type Advance = "ack" | "cancel";
|
||||
interface ActiveStream {
|
||||
request: MeshGeometryStreamRequestIR;
|
||||
cancelled: boolean;
|
||||
resolveAdvance?: (advance: Advance) => void;
|
||||
}
|
||||
|
||||
let active: ActiveStream | null = null;
|
||||
|
||||
function post(message: MeshGeometryStreamWorkerResponse, transfer: Transferable[] = []): void {
|
||||
workerScope.postMessage(message, transfer);
|
||||
}
|
||||
|
||||
function createGridGeometry(
|
||||
request: MeshGeometryStreamRequestIR,
|
||||
chunkIndex: number,
|
||||
triangleOffset: number,
|
||||
triangleCount: number,
|
||||
meshId = request.meshId,
|
||||
): Omit<MeshGeometryStreamChunkIR, "sha256"> {
|
||||
const columns = Math.ceil(Math.sqrt(triangleCount / 2));
|
||||
const rows = Math.ceil(triangleCount / (columns * 2));
|
||||
const positions = new Float32Array((columns + 1) * (rows + 1) * 3);
|
||||
for (let y = 0; y <= rows; y++) {
|
||||
for (let x = 0; x <= columns; x++) {
|
||||
const offset = (y * (columns + 1) + x) * 3;
|
||||
positions[offset] = x / Math.max(1, columns) * 4 - 2;
|
||||
positions[offset + 1] = y / Math.max(1, rows) * 4 - 2;
|
||||
positions[offset + 2] = Math.sin((triangleOffset + x) * 0.0003) * Math.cos(y * 0.03) * 0.08;
|
||||
}
|
||||
}
|
||||
const indices = new Uint32Array(triangleCount * 3);
|
||||
for (let triangle = 0; triangle < triangleCount; triangle++) {
|
||||
const cell = Math.floor(triangle / 2);
|
||||
const x = cell % columns;
|
||||
const y = Math.floor(cell / columns);
|
||||
const a = y * (columns + 1) + x;
|
||||
const offset = triangle * 3;
|
||||
if (triangle % 2 === 0) indices.set([a, a + 1, a + columns + 2], offset);
|
||||
else indices.set([a, a + columns + 2, a + columns + 1], offset);
|
||||
}
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
streamId: request.streamId,
|
||||
meshId,
|
||||
chunkIndex,
|
||||
chunkCount: Math.ceil(request.triangleCount / request.chunkTriangleCount),
|
||||
triangleOffset,
|
||||
triangleCount,
|
||||
vertexCount: positions.length / 3,
|
||||
byteLength: positions.byteLength + indices.byteLength,
|
||||
positions: positions.buffer,
|
||||
indices: indices.buffer,
|
||||
};
|
||||
}
|
||||
|
||||
function waitForAdvance(stream: ActiveStream): Promise<Advance> {
|
||||
if (stream.cancelled) return Promise.resolve("cancel");
|
||||
return new Promise((resolve) => { stream.resolveAdvance = resolve; });
|
||||
}
|
||||
|
||||
function releaseAdvance(stream: ActiveStream, advance: Advance): void {
|
||||
const resolve = stream.resolveAdvance;
|
||||
stream.resolveAdvance = undefined;
|
||||
resolve?.(advance);
|
||||
}
|
||||
|
||||
async function sendGeometry(
|
||||
stream: ActiveStream,
|
||||
type: "lod" | "chunk",
|
||||
unsigned: Omit<MeshGeometryStreamChunkIR, "sha256">,
|
||||
): Promise<Advance> {
|
||||
const geometry: MeshGeometryStreamChunkIR = { ...unsigned, sha256: await meshGeometryStreamChunkSha256(unsigned) };
|
||||
const advance = waitForAdvance(stream);
|
||||
const positions = geometry.positions;
|
||||
const indices = geometry.indices;
|
||||
post(type === "lod" ? { type, streamId: stream.request.streamId, geometry } : { type, streamId: stream.request.streamId, chunk: geometry }, [positions, indices]);
|
||||
post({ type: "detached", streamId: stream.request.streamId, chunkIndex: geometry.chunkIndex, positionsByteLength: positions.byteLength, indicesByteLength: indices.byteLength });
|
||||
return advance;
|
||||
}
|
||||
|
||||
async function run(value: unknown): Promise<void> {
|
||||
let request: MeshGeometryStreamRequestIR | undefined;
|
||||
try {
|
||||
request = parseMeshGeometryStreamRequest(value);
|
||||
if (active) throw new MeshGeometryStreamError("GEOMETRY_STREAM_INVALID", "another geometry stream is active");
|
||||
const stream: ActiveStream = { request, cancelled: false };
|
||||
active = stream;
|
||||
let transferredBytes = 0;
|
||||
let peakWorkingSetBytes = 0;
|
||||
const chunks: MeshGeometryStreamChunkRecordIR[] = [];
|
||||
|
||||
const lod = createGridGeometry(request, -1, 0, request.lodTriangleCount, `${request.meshId}:lod:0`);
|
||||
if (await sendGeometry(stream, "lod", lod) === "cancel") {
|
||||
post({ type: "cancelled", report: { streamId: request.streamId, meshId: request.meshId, status: "CANCELLED", triangleCount: 0, chunkCount: 0, transferredBytes: 0, peakWorkingSetBytes: lod.byteLength } });
|
||||
return;
|
||||
}
|
||||
|
||||
const chunkCount = Math.ceil(request.triangleCount / request.chunkTriangleCount);
|
||||
let triangleOffset = 0;
|
||||
for (let chunkIndex = 0; chunkIndex < chunkCount; chunkIndex++) {
|
||||
const triangleCount = Math.min(request.chunkTriangleCount, request.triangleCount - triangleOffset);
|
||||
const unsigned = createGridGeometry(request, chunkIndex, triangleOffset, triangleCount);
|
||||
const sha256 = await meshGeometryStreamChunkSha256(unsigned);
|
||||
const record = { chunkIndex, triangleOffset, triangleCount, vertexCount: unsigned.vertexCount, byteLength: unsigned.byteLength, sha256 };
|
||||
const advance = waitForAdvance(stream);
|
||||
const chunk: MeshGeometryStreamChunkIR = { ...unsigned, sha256 };
|
||||
const positions = chunk.positions;
|
||||
const indices = chunk.indices;
|
||||
post({ type: "chunk", streamId: request.streamId, chunk }, [positions, indices]);
|
||||
post({ type: "detached", streamId: request.streamId, chunkIndex, positionsByteLength: positions.byteLength, indicesByteLength: indices.byteLength });
|
||||
transferredBytes += chunk.byteLength;
|
||||
peakWorkingSetBytes = Math.max(peakWorkingSetBytes, chunk.byteLength, lod.byteLength);
|
||||
chunks.push(record);
|
||||
if (await advance === "cancel") {
|
||||
post({ type: "cancelled", report: { streamId: request.streamId, meshId: request.meshId, status: "CANCELLED", triangleCount: triangleOffset + triangleCount, chunkCount: chunks.length, transferredBytes, peakWorkingSetBytes } });
|
||||
return;
|
||||
}
|
||||
triangleOffset += triangleCount;
|
||||
}
|
||||
post({
|
||||
type: "complete",
|
||||
report: {
|
||||
streamId: request.streamId,
|
||||
meshId: request.meshId,
|
||||
status: "COMPLETED",
|
||||
triangleCount: request.triangleCount,
|
||||
chunkCount,
|
||||
transferredBytes,
|
||||
peakWorkingSetBytes,
|
||||
manifestSha256: await meshGeometryStreamManifestSha256(request, chunks),
|
||||
},
|
||||
chunks,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
post({
|
||||
type: "error",
|
||||
streamId: request?.streamId,
|
||||
code: error instanceof MeshGeometryStreamError ? error.code : "GEOMETRY_STREAM_INVALID",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
finally {
|
||||
active = null;
|
||||
}
|
||||
}
|
||||
|
||||
workerScope.onmessage = (event: MessageEvent<MeshGeometryStreamWorkerRequest>): void => {
|
||||
const message = event.data;
|
||||
if (message.type === "start") {
|
||||
void run(message.request);
|
||||
return;
|
||||
}
|
||||
if (!active || active.request.streamId !== message.streamId) return;
|
||||
if (message.type === "cancel") {
|
||||
active.cancelled = true;
|
||||
releaseAdvance(active, "cancel");
|
||||
}
|
||||
else if (message.type === "ack") {
|
||||
releaseAdvance(active, "ack");
|
||||
}
|
||||
};
|
||||
79
web/app/src/workers/long-media.worker.ts
Normal file
79
web/app/src/workers/long-media.worker.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
LongMediaTimelineSession,
|
||||
buildLongMediaTimelineIndex,
|
||||
type LongMediaSeekResultIR,
|
||||
} from "../sequencer/LongMediaTimeline";
|
||||
|
||||
interface MediaAssetMessage {
|
||||
sourceId: string;
|
||||
data: ArrayBuffer;
|
||||
}
|
||||
|
||||
type Request =
|
||||
| { type: "init"; timeline: unknown; assets: MediaAssetMessage[]; cacheMaxBytes: number }
|
||||
| { type: "seek"; requestId: string; frame: number; decodeDelayMs?: number }
|
||||
| { type: "cancel" }
|
||||
| { type: "dispose" };
|
||||
|
||||
type Response =
|
||||
| { type: "ready"; index: { stripCount: number; bucketCount: number; referenceCount: number; estimatedBytes: number } }
|
||||
| { type: "seekResult"; requestId: string; result: LongMediaSeekResultIR }
|
||||
| { type: "disposed"; cacheBytes: number }
|
||||
| { type: "error"; requestId?: string; message: string };
|
||||
|
||||
const scope = self as unknown as {
|
||||
onmessage: ((event: MessageEvent<Request>) => void) | null;
|
||||
postMessage: (message: Response) => void;
|
||||
};
|
||||
|
||||
let session: LongMediaTimelineSession | null = null;
|
||||
let assets = new Map<string, ArrayBuffer>();
|
||||
let decodeDelayMs = 0;
|
||||
|
||||
function abortableDelay(delayMs: number, signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) return Promise.reject(new DOMException("Long media decode cancelled", "AbortError"));
|
||||
if (delayMs <= 0) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, delayMs);
|
||||
signal.addEventListener("abort", () => { clearTimeout(timer); reject(new DOMException("Long media decode cancelled", "AbortError")); }, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
scope.onmessage = (event): void => {
|
||||
const message = event.data;
|
||||
if (message.type === "init") {
|
||||
void (async () => {
|
||||
try {
|
||||
session?.dispose();
|
||||
const index = await buildLongMediaTimelineIndex(message.timeline, new AbortController().signal);
|
||||
assets = new Map(message.assets.map((asset) => [asset.sourceId, asset.data]));
|
||||
session = new LongMediaTimelineSession(index, {
|
||||
load: async (strip, _sourceFrame, signal) => {
|
||||
await abortableDelay(decodeDelayMs, signal);
|
||||
const data = strip.sourceId ? assets.get(strip.sourceId) : undefined;
|
||||
if (!data) throw new Error(`SEQUENCER_RESOURCE_MISSING: ${strip.id} source bytes are unavailable`);
|
||||
return data.slice(0);
|
||||
},
|
||||
}, message.cacheMaxBytes);
|
||||
scope.postMessage({ type: "ready", index: index.stats });
|
||||
}
|
||||
catch (error) { scope.postMessage({ type: "error", message: error instanceof Error ? error.message : String(error) }); }
|
||||
})();
|
||||
return;
|
||||
}
|
||||
if (message.type === "seek") {
|
||||
const active = session;
|
||||
if (!active) { scope.postMessage({ type: "error", requestId: message.requestId, message: "SEQUENCER_RESOURCE_MISSING: long media session is not initialized" }); return; }
|
||||
decodeDelayMs = message.decodeDelayMs ?? 0;
|
||||
void active.seek(message.frame)
|
||||
.then((result) => scope.postMessage({ type: "seekResult", requestId: message.requestId, result }))
|
||||
.catch((error) => scope.postMessage({ type: "error", requestId: message.requestId, message: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
if (message.type === "cancel") { session?.cancel(); return; }
|
||||
const cacheBytes = session?.cache.stats().bytes ?? 0;
|
||||
session?.dispose();
|
||||
session = null;
|
||||
assets.clear();
|
||||
scope.postMessage({ type: "disposed", cacheBytes });
|
||||
};
|
||||
@@ -1,19 +1,33 @@
|
||||
import { gateRelease, parseReleaseManifest, serializeReleaseManifest } from "../../../protocol/release-gate";
|
||||
|
||||
const family = (id: string, dependencies: string[] = []) => ({ id, name: id, status: "BLOCKED", roadmapStatus: "planned", completedSlices: ["schema"], blockedSlices: ["A", "B"], excludedSlices: [], acceptance: [], dependencies });
|
||||
const family = (id: string, dependencies: string[] = []) => ({
|
||||
id,
|
||||
name: id,
|
||||
parityStatus: "BLOCKED",
|
||||
releaseClass: "LOCAL_BOUNDED",
|
||||
releaseStatus: "BLOCKED",
|
||||
roadmapStatus: "planned",
|
||||
completedSlices: ["schema"],
|
||||
blockedSlices: ["A", "B"],
|
||||
excludedSlices: [],
|
||||
v1RequiredSlices: ["A"],
|
||||
v1ExcludedSlices: ["B"],
|
||||
acceptance: [],
|
||||
dependencies,
|
||||
});
|
||||
const evidenceRecord = { id: "fixture", fields: ["runtime.offline", "performance.geometry1M", "faults.malformedBlend", "faults.zipBomb", "provenance.license"], command: "fixture", exitCode: 0, durationMs: 1, output: "fixture passed", artifactSha256: ["a".repeat(64)] };
|
||||
const evidence = { browser: { chromium: false }, runtime: { offline: true, workerRestart: false, opfsRecovery: false }, performance: { geometry1M: true, geometry10M: false, texture4K: false, texture8K: false, longMedia: false, simulationCache: false }, faults: { oom: false, deviceLoss: false, networkInterrupt: false, malformedBlend: true, zipBomb: true }, provenance: { license: true, sbom: false, sourceOffer: false, deterministicPackage: false }, records: [evidenceRecord] };
|
||||
const base = { schemaVersion: 3, source: "docs/status/parity-ledger.json", sourceSha256: "b".repeat(64), generatedAt: "2026-08-11T00:00:00.000Z", families: [family("N-015"), family("N-016", ["N-015"])], evidence };
|
||||
const base = { schemaVersion: 4, source: "docs/status/parity-ledger.json", sourceSha256: "b".repeat(64), generatedAt: "2026-08-11T00:00:00.000Z", families: [family("N-015"), family("N-016", ["N-015"])], evidence };
|
||||
|
||||
self.onmessage = () => {
|
||||
const result: Record<string, unknown> = {};
|
||||
try { const parsed = parseReleaseManifest(base); result.valid = [parsed.families.length, serializeReleaseManifest(base) === serializeReleaseManifest({ ...base, families: [...base.families].reverse() })]; } catch (error) { result.valid = error instanceof Error ? error.message : String(error); }
|
||||
const gate = gateRelease(base); result.gate = [gate.status, gate.issues.map((issue) => issue.code)];
|
||||
try { parseReleaseManifest({ ...base, schemaVersion: 2 }); } catch (error) { result.oldSchema = error instanceof Error ? error.message : String(error); }
|
||||
try { parseReleaseManifest({ ...base, schemaVersion: 3 }); } catch (error) { result.oldSchema = error instanceof Error ? error.message : String(error); }
|
||||
try { parseReleaseManifest({ ...base, families: [family("N-015", ["N-016"]), family("N-016", ["N-015"])] }); } catch (error) { result.cycle = error instanceof Error ? error.message : String(error); }
|
||||
try { parseReleaseManifest({ ...base, families: [{ ...family("N-015"), status: "LOCAL_EXACT", completedSlices: [] }] }); } catch (error) { result.status = error instanceof Error ? error.message : String(error); }
|
||||
try { parseReleaseManifest({ ...base, families: [{ ...family("N-015"), releaseStatus: "READY" }] }); } catch (error) { result.status = error instanceof Error ? error.message : String(error); }
|
||||
try { parseReleaseManifest({ ...base, evidence: { ...evidence, browser: { chromium: true } } }); } catch (error) { result.unbound = error instanceof Error ? error.message : String(error); }
|
||||
try { parseReleaseManifest({ ...base, families: [{ ...family("N-015"), excludedSlices: ["A"], blockedSlices: ["A", "B"] }, family("N-016", ["N-015"])] }); } catch (error) { result.excludedOverlap = error instanceof Error ? error.message : String(error); }
|
||||
try { parseReleaseManifest({ ...base, families: [{ ...family("N-015"), v1ExcludedSlices: ["A"] }, family("N-016", ["N-015"])] }); } catch (error) { result.excludedOverlap = error instanceof Error ? error.message : String(error); }
|
||||
try { parseReleaseManifest({ ...base, evidence: { ...evidence, records: [{ ...evidenceRecord, fields: ["performance.geometry10M"] }] } }); } catch (error) { result.disabledEvidence = error instanceof Error ? error.message : String(error); }
|
||||
try { parseReleaseManifest({ ...base, evidence: { ...evidence, records: [{ ...evidenceRecord, artifactSha256: [] }] } }); } catch (error) { result.emptyArtifact = error instanceof Error ? error.message : String(error); }
|
||||
try { parseReleaseManifest({ ...base, generatedAt: "2026-02-31T00:00:00.000Z" }); } catch (error) { result.generatedAt = error instanceof Error ? error.message : String(error); }
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { verifyNanoVDBChunk, type NanoVDBBundleManifestIR } from "../../../protocol/volume-vdb";
|
||||
import {
|
||||
createNanoVDBFloat32GridPaged,
|
||||
NanoVDBGpuPageAllocator,
|
||||
NanoVDBWebGPUDeviceSession,
|
||||
readNanoVDBWordsWebGPU,
|
||||
sampleNanoVDBFloat32WebGPU,
|
||||
uploadNanoVDBFloat32GridPaged,
|
||||
} from "../render/nanovdb-volume-renderer";
|
||||
import { createResumableHttpNanoVDBRangeSource } from "../volume/nanovdb-stream";
|
||||
import { loadNanoVDBViewportAsset } from "../volume/nanovdb-viewport";
|
||||
import { loadNanoVDBGridPage, loadNanoVDBViewportAsset, planNanoVDBGridResidency } from "../volume/nanovdb-viewport";
|
||||
|
||||
const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void };
|
||||
|
||||
@@ -128,12 +130,64 @@ scope.onmessage = (): void => {
|
||||
const lru = allocator.stats();
|
||||
allocator.dispose();
|
||||
|
||||
const pageByteLength = 256 * 1024;
|
||||
const pagingManifest: NanoVDBBundleManifestIR = {
|
||||
...manifest,
|
||||
gpu: { ...manifest.gpu, pageByteLength, maxResidentBytes: 2 * pageByteLength },
|
||||
};
|
||||
const globalResidency = planNanoVDBGridResidency({
|
||||
...pagingManifest,
|
||||
gpu: { ...pagingManifest.gpu, maxResidentBytes: 3 * pageByteLength },
|
||||
});
|
||||
const pageRanges: string[] = [];
|
||||
const pageSource = createResumableHttpNanoVDBRangeSource("/__vdb_fixture__/bundle", manifest.bundleByteLength, {
|
||||
fetcher: async (input, init) => {
|
||||
pageRanges.push(new Headers(init?.headers).get("Range") ?? "");
|
||||
return fetch(input, init);
|
||||
},
|
||||
retries: 0,
|
||||
requireStableEtag: true,
|
||||
});
|
||||
const constrained = createNanoVDBFloat32GridPaged(device, payload.byteLength, pageByteLength, 2 * pageByteLength);
|
||||
const initialVirtualPages = [...constrained.residentVirtualPages];
|
||||
const page0 = await loadNanoVDBGridPage(pagingManifest, density.name, 0, pageSource, new AbortController().signal);
|
||||
const page1 = await loadNanoVDBGridPage(pagingManifest, density.name, 1, pageSource, new AbortController().signal);
|
||||
const lastPageIndex = constrained.pageCount - 1;
|
||||
const lastPage = await loadNanoVDBGridPage(pagingManifest, density.name, lastPageIndex, pageSource, new AbortController().signal);
|
||||
constrained.uploadPage(0, page0);
|
||||
constrained.uploadPage(1, page1);
|
||||
constrained.touchPage(0);
|
||||
constrained.uploadPage(lastPageIndex, lastPage);
|
||||
const lastWords = new Uint32Array(lastPage);
|
||||
const lastWordIndex = lastWords.findIndex((value) => value !== 0);
|
||||
if (lastWordIndex < 0) throw new Error("VDB demand page fixture has no observable word");
|
||||
const wordOffsets = [0, lastPageIndex * pageByteLength + lastWordIndex * 4, pageByteLength];
|
||||
const expectedWords = [new Uint32Array(page0)[0], lastWords[lastWordIndex], 0];
|
||||
const demandWords = await readNanoVDBWordsWebGPU(device, constrained, wordOffsets);
|
||||
let incompletePage = "";
|
||||
try { constrained.uploadPage(2, new ArrayBuffer(4)); }
|
||||
catch (error) { incompletePage = error instanceof Error ? error.message : String(error); }
|
||||
const demandPaging = {
|
||||
pageCount: constrained.pageCount,
|
||||
residentPageCapacity: constrained.residentPageCapacity,
|
||||
residentBytes: constrained.residentBytes,
|
||||
maxResidentBytes: constrained.maxResidentBytes,
|
||||
evictions: constrained.evictionCount,
|
||||
initialVirtualPages,
|
||||
residentVirtualPages: [...constrained.residentVirtualPages],
|
||||
requestedPageResident: constrained.hasResidentPage(lastPageIndex),
|
||||
touchedPageResident: constrained.hasResidentPage(0),
|
||||
evictedPageResident: constrained.hasResidentPage(1),
|
||||
pageRanges,
|
||||
wordOffsets,
|
||||
expectedWords,
|
||||
demandWords,
|
||||
incompletePage,
|
||||
};
|
||||
constrained.dispose();
|
||||
|
||||
let oom = "";
|
||||
try {
|
||||
const constrained = uploadNanoVDBFloat32GridPaged(device, payload, 256 * 1024, 256 * 1024);
|
||||
if (constrained.residentPageCount < constrained.pageCount) oom = "NANOVDB_GPU_BUDGET_EXCEEDED: resident paging active";
|
||||
constrained.dispose();
|
||||
}
|
||||
try { uploadNanoVDBFloat32GridPaged(device, payload, 256 * 1024, 128 * 1024); }
|
||||
catch (error) { oom = error instanceof Error ? error.message : String(error); }
|
||||
|
||||
const residentBudget = Math.ceil(payload.byteLength / (256 * 1024)) * 256 * 1024;
|
||||
@@ -145,6 +199,11 @@ scope.onmessage = (): void => {
|
||||
device.destroy();
|
||||
const loss = await session.waitForLoss();
|
||||
const recoveredDevice = await session.recover(payload.byteLength + 256 * 1024);
|
||||
const recoveredDemand = createNanoVDBFloat32GridPaged(recoveredDevice, payload.byteLength, pageByteLength, 2 * pageByteLength);
|
||||
recoveredDemand.uploadPage(0, await loadNanoVDBGridPage(pagingManifest, density.name, 0, pageSource, new AbortController().signal));
|
||||
recoveredDemand.uploadPage(lastPageIndex, await loadNanoVDBGridPage(pagingManifest, density.name, lastPageIndex, pageSource, new AbortController().signal));
|
||||
const recoveredDemandWords = await readNanoVDBWordsWebGPU(recoveredDevice, recoveredDemand, wordOffsets.slice(0, 2));
|
||||
recoveredDemand.dispose();
|
||||
const recovered = uploadNanoVDBFloat32GridPaged(recoveredDevice, payload, 256 * 1024, residentBudget);
|
||||
const after = await sampleNanoVDBFloat32WebGPU(recoveredDevice, recovered, native.map((sample) => sample.coord));
|
||||
recovered.dispose();
|
||||
@@ -165,8 +224,10 @@ scope.onmessage = (): void => {
|
||||
},
|
||||
lru,
|
||||
oom,
|
||||
demandPaging,
|
||||
globalResidency,
|
||||
paging,
|
||||
deviceLoss: { reason: loss.reason, firstGeneration, recoveredGeneration },
|
||||
deviceLoss: { reason: loss.reason, firstGeneration, recoveredGeneration, recoveredDemandWords },
|
||||
samplesStable: JSON.stringify(before) === JSON.stringify(after),
|
||||
};
|
||||
})().then((result) => scope.postMessage(result)).catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) }));
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { validateNanoVDBBundleManifest, type NanoVDBBundleManifestIR } from "../../../protocol/volume-vdb";
|
||||
import { NanoVDBFloat32Sampler } from "../volume/nanovdb-float32";
|
||||
import { mapPrincipledVolumeToNanoVDB } from "../volume/volume-material-mapping";
|
||||
import { probeNanoVDBWebGPU, renderNanoVDBFloat32WebGPU, sampleNanoVDBFloat32WebGPU, uploadNanoVDBFloat32Grid } from "../render/nanovdb-volume-renderer";
|
||||
import {
|
||||
probeNanoVDBWebGPU,
|
||||
renderNanoVDBFloat32WebGPU,
|
||||
sampleNanoVDBFloat32WebGPU,
|
||||
uploadNanoVDBFloat32Grid,
|
||||
uploadNanoVDBFloat32GridPaged,
|
||||
type NanoVDBMaterialGridUploadsIR,
|
||||
type NanoVDBViewAxis,
|
||||
} from "../render/nanovdb-volume-renderer";
|
||||
|
||||
const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void };
|
||||
|
||||
@@ -12,12 +20,17 @@ scope.onmessage = (): void => {
|
||||
const density = manifest.grids.find((grid) => grid.name === manifest.material.densityGrid);
|
||||
const nativeDensity = report.grids.find((grid) => grid.name === manifest.material.densityGrid);
|
||||
if (!density || !nativeDensity?.scalarSamples || !manifest.gpu.float32TreeLayout) throw new Error("VDB WebGPU fixture is incomplete");
|
||||
const response = await fetch("/__vdb_fixture__/bundle", { headers: { Range: `bytes=${density.byteOffset}-${density.byteOffset + density.byteLength - 1}` }, cache: "no-store" });
|
||||
if (response.status !== 206) throw new Error(`VDB payload range returned ${response.status}`);
|
||||
const payload = await response.arrayBuffer();
|
||||
const fetchGrid = async (name: string): Promise<ArrayBuffer> => {
|
||||
const grid = manifest.grids.find((candidate) => candidate.name === name);
|
||||
if (!grid) throw new Error(`VDB grid ${name} is missing`);
|
||||
const response = await fetch("/__vdb_fixture__/bundle", { headers: { Range: `bytes=${grid.byteOffset}-${grid.byteOffset + grid.byteLength - 1}` }, cache: "no-store" });
|
||||
if (response.status !== 206) throw new Error(`VDB payload range returned ${response.status}`);
|
||||
return response.arrayBuffer();
|
||||
};
|
||||
const payload = await fetchGrid(density.name);
|
||||
const cpu = new NanoVDBFloat32Sampler(payload, density, manifest.gpu.float32TreeLayout);
|
||||
const cpuSamples = nativeDensity.scalarSamples.map((sample) => ({ coord: sample.coord, ...cpu.nearest(sample.coord), expectedValue: sample.value, expectedActive: sample.active }));
|
||||
const probe = await probeNanoVDBWebGPU(payload.byteLength);
|
||||
const probe = await probeNanoVDBWebGPU(payload.byteLength, 9);
|
||||
if (!probe.capability.available || !probe.device) throw new Error(probe.capability.reason ?? "WebGPU adapter is unavailable");
|
||||
const device = probe.device;
|
||||
device.pushErrorScope("validation");
|
||||
@@ -37,7 +50,30 @@ scope.onmessage = (): void => {
|
||||
anisotropy: 0.2,
|
||||
interpolation: "LINEAR",
|
||||
});
|
||||
const pixels = await renderNanoVDBFloat32WebGPU(device, uploaded, density, materialMapping.material, 96, 96);
|
||||
const materialUploads: NanoVDBMaterialGridUploadsIR = {};
|
||||
if (materialMapping.material.temperatureGrid) {
|
||||
materialUploads.temperature = uploadNanoVDBFloat32GridPaged(device, await fetchGrid(materialMapping.material.temperatureGrid), 256 * 1024, 16 * 1024 * 1024);
|
||||
}
|
||||
if (materialMapping.material.colorGrid) {
|
||||
materialUploads.color = uploadNanoVDBFloat32GridPaged(device, await fetchGrid(materialMapping.material.colorGrid), 256 * 1024, 16 * 1024 * 1024);
|
||||
}
|
||||
const emissionManifest = validateNanoVDBBundleManifest({
|
||||
...manifest,
|
||||
grids: manifest.grids.map((grid) => grid.name === manifest.material.temperatureGrid ? { ...grid, semantic: "EMISSION" as const } : grid),
|
||||
material: { ...manifest.material, temperatureGrid: undefined, emissionGrid: manifest.material.temperatureGrid },
|
||||
});
|
||||
const emissionMapping = mapPrincipledVolumeToNanoVDB(emissionManifest, {
|
||||
densityGrid: emissionManifest.material.densityGrid,
|
||||
densityScale: 1,
|
||||
emissionGrid: emissionManifest.material.emissionGrid,
|
||||
emissionColor: [1, 0.2, 0.05],
|
||||
emissionScale: 0.0005,
|
||||
});
|
||||
materialUploads.emission = materialUploads.temperature;
|
||||
const combinedMaterial = { ...materialMapping.material, emissionGrid: emissionMapping.material.emissionGrid };
|
||||
const viewAxis: NanoVDBViewAxis = "X";
|
||||
const pixels = await renderNanoVDBFloat32WebGPU(device, uploaded, density, combinedMaterial, 64, 64, materialUploads, viewAxis);
|
||||
const emissionVisiblePixels = Array.from({ length: pixels.length / 4 }, (_, index) => pixels[index * 4 + 3] > 0).filter(Boolean).length;
|
||||
const validationError = await device.popErrorScope();
|
||||
if (validationError) throw new Error(`WebGPU validation failed: ${validationError.message}`);
|
||||
let visiblePixels = 0;
|
||||
@@ -47,6 +83,8 @@ scope.onmessage = (): void => {
|
||||
if (pixels[index] > 0) visiblePixels++;
|
||||
}
|
||||
const imageHash = await crypto.subtle.digest("SHA-256", new Uint8Array(pixels).buffer);
|
||||
materialUploads.temperature?.dispose();
|
||||
materialUploads.color?.dispose();
|
||||
uploaded.dispose();
|
||||
device.destroy();
|
||||
return {
|
||||
@@ -58,6 +96,9 @@ scope.onmessage = (): void => {
|
||||
visiblePixels,
|
||||
alphaSum,
|
||||
imageSha256: Array.from(new Uint8Array(imageHash), (byte) => byte.toString(16).padStart(2, "0")).join(""),
|
||||
viewAxis,
|
||||
emissionVisiblePixels,
|
||||
emissionMapping,
|
||||
materialMapping,
|
||||
};
|
||||
})().then((result) => scope.postMessage(result)).catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) }));
|
||||
|
||||
@@ -30,7 +30,7 @@ import type { OffscreenViewportRequest, OffscreenViewportResponse } from "../thr
|
||||
import type { NonMeshElementKind } from "../three-adapter/nonmesh";
|
||||
import type { CurveGizmoFrameIR, CurveGizmoScreenFrameIR } from "../../../protocol/nonmesh-interaction";
|
||||
import type { NanoVDBViewportAssetIR, NanoVDBViewportRenderResultIR } from "../volume/nanovdb-viewport";
|
||||
import { NanoVDBViewportRenderSession, renderNanoVDBViewportAsset } from "../volume/nanovdb-viewport";
|
||||
import { NANOVDB_VIEWPORT_PREVIEW_SIZE, NanoVDBViewportRenderSession, renderNanoVDBViewportAsset } from "../volume/nanovdb-viewport";
|
||||
import { createNanoVDBViewportObject } from "../three-adapter/volume";
|
||||
import {
|
||||
configurePBRLight,
|
||||
@@ -74,6 +74,7 @@ raycaster.params.Points.threshold = 0.14;
|
||||
const objectById = new Map<string, Object3D>();
|
||||
let curveGizmoFrame: { dataId: string; frame: CurveGizmoFrameIR } | null = null;
|
||||
let volumeAssets: NanoVDBViewportAssetIR[] = [];
|
||||
let volumeAssetsInitialized = false;
|
||||
const volumeRenderCache = new Map<string, NanoVDBViewportRenderResultIR>();
|
||||
let volumeRenderGeneration = 0;
|
||||
const volumeRenderSession = new NanoVDBViewportRenderSession(() => {
|
||||
@@ -249,6 +250,7 @@ async function refreshVolumes(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
post({ type: "volumeStatus", status: "loading", count: 0 });
|
||||
if (!volumeAssetsInitialized) return;
|
||||
try {
|
||||
let count = 0;
|
||||
for (const node of nodes) {
|
||||
@@ -257,7 +259,7 @@ async function refreshVolumes(): Promise<void> {
|
||||
const cacheKey = `${asset.dataId}:${asset.manifest.bundleSha256}:${JSON.stringify(asset.material ?? asset.manifest.material)}`;
|
||||
let result = volumeRenderCache.get(cacheKey);
|
||||
if (!result) {
|
||||
result = await renderNanoVDBViewportAsset(asset, 128, 128, volumeRenderSession);
|
||||
result = await renderNanoVDBViewportAsset(asset, NANOVDB_VIEWPORT_PREVIEW_SIZE, NANOVDB_VIEWPORT_PREVIEW_SIZE, volumeRenderSession);
|
||||
volumeRenderCache.set(cacheKey, result);
|
||||
}
|
||||
if (generation !== volumeRenderGeneration || currentSnapshot !== snapshot || !root) return;
|
||||
@@ -546,6 +548,7 @@ workerScope.onmessage = (event): void => {
|
||||
else if (message.type === "textureAssets") applyTextureAssets(message.assets);
|
||||
else if (message.type === "volumeAssets") {
|
||||
volumeAssets = message.assets;
|
||||
volumeAssetsInitialized = true;
|
||||
void refreshVolumes();
|
||||
}
|
||||
else if (message.type === "resize") resize(message.width, message.height, message.pixelRatio);
|
||||
|
||||
Reference in New Issue
Block a user