Complete V1 performance and OOM release gates

This commit is contained in:
mes123456
2026-08-14 22:32:09 -04:00
parent 3ea9974eee
commit a3f3071c03
45 changed files with 4206 additions and 276 deletions

View File

@@ -11,9 +11,14 @@ import {
probeNanoVDBWebGPU,
renderNanoVDBFloat32WebGPU,
uploadNanoVDBFloat32GridPaged,
type NanoVDBMaterialGridUploadsIR,
type NanoVDBViewAxis,
type NanoVDBWebGPUCapabilityIR,
type NanoVDBWebGPUGrid,
} from "../render/nanovdb-volume-renderer";
import { createResumableHttpNanoVDBRangeSource } from "./nanovdb-stream";
export const NANOVDB_VIEWPORT_PREVIEW_SIZE = 64;
import type { NanoVDBRangeSource } from "./nanovdb-stream";
import {
commitNanoVDBToOPFS,
@@ -32,6 +37,7 @@ export interface NanoVDBViewportAssetIR {
manifest: NanoVDBBundleManifestIR;
grids: NanoVDBViewportGridPayloadIR[];
material?: NanoVDBMaterialIR;
viewAxis?: NanoVDBViewAxis;
}
export interface NanoVDBViewportRenderResultIR {
@@ -42,6 +48,7 @@ export interface NanoVDBViewportRenderResultIR {
width: number;
height: number;
capability: NanoVDBWebGPUCapabilityIR;
viewAxis: NanoVDBViewAxis;
}
export interface NanoVDBViewportProjectContextIR {
@@ -49,9 +56,75 @@ export interface NanoVDBViewportProjectContextIR {
sourceBlendSha256: string;
}
function residentBytes(payloadBytes: number, pageBytes: number, maxResidentBytes: number): number {
const pageCount = Math.ceil(payloadBytes / pageBytes);
return Math.min(pageCount, Math.max(1, Math.floor(maxResidentBytes / pageBytes))) * pageBytes;
export interface NanoVDBGridResidencyPlanIR {
totalResidentBytes: number;
maxResidentBytes: number;
gridResidentBytes: Readonly<Record<string, number>>;
}
export function planNanoVDBGridResidency(
manifestValue: NanoVDBBundleManifestIR,
materialValue?: NanoVDBMaterialIR,
): NanoVDBGridResidencyPlanIR {
const manifest = validateNanoVDBBundleManifest(manifestValue);
const grids = materialGridDefinitions(manifest, materialValue ?? manifest.material);
const pageBytes = manifest.gpu.pageByteLength;
const availableSlots = Math.floor(manifest.gpu.maxResidentBytes / pageBytes);
if (availableSlots < grids.length) throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: material grids require more resident slots than the manifest budget");
const requiredSlots = grids.map((grid) => Math.ceil(grid.byteLength / pageBytes));
const assignedSlots = grids.map(() => 1);
let remainingSlots = availableSlots - grids.length;
while (remainingSlots > 0) {
let assigned = false;
for (let index = 0; index < grids.length && remainingSlots > 0; index++) {
if (assignedSlots[index] >= requiredSlots[index]) continue;
assignedSlots[index]++;
remainingSlots--;
assigned = true;
}
if (!assigned) break;
}
const gridResidentBytes = Object.fromEntries(grids.map((grid, index) => [grid.name, assignedSlots[index] * pageBytes]));
return {
totalResidentBytes: Object.values(gridResidentBytes).reduce((sum, bytes) => sum + bytes, 0),
maxResidentBytes: manifest.gpu.maxResidentBytes,
gridResidentBytes,
};
}
function uploadMaterialGrids(
device: GPUDevice,
asset: NanoVDBViewportAssetIR,
material: NanoVDBMaterialIR,
residency: NanoVDBGridResidencyPlanIR,
uploads: NanoVDBMaterialGridUploadsIR,
): void {
const uploadedByName = new Map<string, NanoVDBWebGPUGrid>();
const upload = (field: "temperature" | "color" | "emission", name: string | undefined): void => {
if (!name) return;
const existing = uploadedByName.get(name);
if (existing) {
uploads[field] = existing;
return;
}
const gridPayload = asset.grids.find((candidate) => candidate.name === name);
if (!gridPayload) throw new Error(`NANOVDB_STREAM_INCOMPLETE: viewport ${field} payload is missing`);
const uploaded = uploadNanoVDBFloat32GridPaged(
device,
gridPayload.data,
asset.manifest.gpu.pageByteLength,
residency.gridResidentBytes[name],
);
uploadedByName.set(name, uploaded);
uploads[field] = uploaded;
};
upload("temperature", material.temperatureGrid);
upload("color", material.colorGrid);
upload("emission", material.emissionGrid);
}
function disposeMaterialGrids(uploads: NanoVDBMaterialGridUploadsIR): void {
for (const uploaded of new Set(Object.values(uploads))) uploaded?.dispose();
}
/** Keeps the WebGPU device alive for a production viewport and rebuilds it after loss. */
@@ -69,24 +142,34 @@ export class NanoVDBViewportRenderSession {
const asset = validateNanoVDBViewportAsset(value);
const grid = densityGrid(asset.manifest);
const payload = asset.grids.find((candidate) => candidate.name === grid.name)!.data;
const requiredBytes = residentBytes(payload.byteLength, asset.manifest.gpu.pageByteLength, asset.manifest.gpu.maxResidentBytes);
const material = asset.material ?? asset.manifest.material;
const residency = planNanoVDBGridResidency(asset.manifest, material);
const requiredBytes = Math.max(...Object.values(residency.gridResidentBytes));
const requiredStorageBuffers = 3 + 2 * [material.temperatureGrid, material.colorGrid, material.emissionGrid].filter(Boolean).length;
let device = this.deviceSession.device;
if (!device || this.deviceSession.status !== "ready") device = await this.deviceSession.open(requiredBytes);
if (!device || this.deviceSession.status !== "ready") device = await this.deviceSession.open(requiredBytes, requiredStorageBuffers);
for (let attempt = 0; attempt < 2; attempt++) {
const uploaded = uploadNanoVDBFloat32GridPaged(device, payload, asset.manifest.gpu.pageByteLength, asset.manifest.gpu.maxResidentBytes);
const activeDevice = device;
const uploaded = uploadNanoVDBFloat32GridPaged(activeDevice, payload, asset.manifest.gpu.pageByteLength, residency.gridResidentBytes[grid.name]);
const materialUploads: NanoVDBMaterialGridUploadsIR = {};
try {
const pixels = await renderNanoVDBFloat32WebGPU(device, uploaded, grid, asset.material ?? asset.manifest.material, width, height);
return { dataId: asset.dataId, grid, material: asset.material ?? asset.manifest.material, pixels, width, height, capability: {
uploadMaterialGrids(activeDevice, asset, material, residency, materialUploads);
const viewAxis = asset.viewAxis ?? "Z";
const pixels = await renderNanoVDBFloat32WebGPU(activeDevice, uploaded, grid, material, width, height, materialUploads, viewAxis);
return { dataId: asset.dataId, grid, material, pixels, width, height, viewAxis, capability: {
available: true,
maxStorageBufferBindingSize: Number(device.limits.maxStorageBufferBindingSize),
maxBufferSize: Number(device.limits.maxBufferSize),
maxStorageBufferBindingSize: Number(activeDevice.limits.maxStorageBufferBindingSize),
maxBufferSize: Number(activeDevice.limits.maxBufferSize),
} };
}
catch (error) {
if (this.deviceSession.status !== "lost" || attempt !== 0) throw error;
device = await this.deviceSession.recover(requiredBytes);
device = await this.deviceSession.recover(requiredBytes, requiredStorageBuffers);
}
finally {
uploaded.dispose();
disposeMaterialGrids(materialUploads);
}
finally { uploaded.dispose(); }
}
throw new Error("VOLUME_SHADER_UNAVAILABLE: WebGPU device recovery exhausted");
}
@@ -107,12 +190,25 @@ function densityGrid(manifest: NanoVDBBundleManifestIR): NanoVDBGridIR {
return grid;
}
async function loadDensityPayload(
function materialGridDefinitions(manifest: NanoVDBBundleManifestIR, material = manifest.material): NanoVDBGridIR[] {
const fields = [material.densityGrid, material.temperatureGrid, material.colorGrid, material.emissionGrid].filter((name): name is string => Boolean(name));
return [...new Set(fields)].map((name) => {
const grid = manifest.grids.find((candidate) => candidate.name === name);
if (!grid) throw new Error(`NANOVDB_MANIFEST_INVALID: material grid ${name} is missing`);
if ((grid.semantic === "DENSITY" || grid.semantic === "TEMPERATURE" || grid.semantic === "EMISSION") && grid.valueType !== "FLOAT32") {
throw new Error(`NANOVDB_GRID_UNSUPPORTED: ${grid.semantic} viewport grid must be Float32`);
}
if (grid.semantic === "COLOR" && grid.valueType !== "VEC3F32") throw new Error("NANOVDB_GRID_UNSUPPORTED: COLOR viewport grid must be Vec3f32");
return grid;
});
}
async function loadGridPayload(
manifest: NanoVDBBundleManifestIR,
grid: NanoVDBGridIR,
source: NanoVDBRangeSource,
signal: AbortSignal,
): Promise<ArrayBuffer> {
const grid = densityGrid(manifest);
const payload = new Uint8Array(grid.byteLength);
let copiedBytes = 0;
for (const chunk of manifest.chunks) {
@@ -130,19 +226,58 @@ async function loadDensityPayload(
payload.set(new Uint8Array(data, sourceOffset, overlapLength), targetOffset);
copiedBytes += overlapLength;
}
if (copiedBytes !== grid.byteLength) throw new Error("NANOVDB_STREAM_INCOMPLETE: density grid ranges are incomplete");
if (copiedBytes !== grid.byteLength) throw new Error(`NANOVDB_STREAM_INCOMPLETE: ${grid.name} grid ranges are incomplete`);
return payload.buffer;
}
export async function loadNanoVDBGridPage(
manifestValue: NanoVDBBundleManifestIR,
gridName: string,
pageIndex: number,
source: NanoVDBRangeSource,
signal: AbortSignal,
): Promise<ArrayBuffer> {
const manifest = validateNanoVDBBundleManifest(manifestValue);
const grid = manifest.grids.find((candidate) => candidate.name === gridName);
if (!grid) throw new Error(`NANOVDB_MANIFEST_INVALID: grid ${gridName} is missing`);
const pageBytes = manifest.gpu.pageByteLength;
const pageCount = Math.ceil(grid.byteLength / pageBytes);
if (!Number.isSafeInteger(pageIndex) || pageIndex < 0 || pageIndex >= pageCount) {
throw new Error("NANOVDB_STREAM_INCOMPLETE: requested GPU page is outside the grid");
}
const localStart = pageIndex * pageBytes;
const localEnd = Math.min(grid.byteLength, localStart + pageBytes);
const globalStart = grid.byteOffset + localStart;
const globalEnd = grid.byteOffset + localEnd;
const output = new Uint8Array(localEnd - localStart);
let copiedBytes = 0;
for (const chunk of manifest.chunks) {
const chunkEnd = chunk.byteOffset + chunk.byteLength;
const overlapStart = Math.max(globalStart, chunk.byteOffset);
const overlapEnd = Math.min(globalEnd, chunkEnd);
if (overlapEnd <= overlapStart) continue;
if (signal.aborted) throw new DOMException("NanoVDB page load cancelled", "AbortError");
const range: NanoVDBRangeIR = { chunkIndex: chunk.index, start: chunk.byteOffset, endExclusive: chunkEnd, sha256: chunk.sha256 };
const data = await source(range, signal);
await verifyNanoVDBChunk(chunk, data);
output.set(new Uint8Array(data, overlapStart - chunk.byteOffset, overlapEnd - overlapStart), overlapStart - globalStart);
copiedBytes += overlapEnd - overlapStart;
}
if (copiedBytes !== output.byteLength) throw new Error(`NANOVDB_STREAM_INCOMPLETE: ${grid.name} GPU page is incomplete`);
return output.buffer;
}
export function validateNanoVDBViewportAsset(value: NanoVDBViewportAssetIR): NanoVDBViewportAssetIR {
if (!value.dataId || value.dataId.length > 256) throw new Error("NANOVDB_MANIFEST_INVALID: viewport dataId");
const manifest = validateNanoVDBBundleManifest(value.manifest);
const grid = densityGrid(manifest);
const payload = value.grids.find((candidate) => candidate.name === grid.name);
if (!payload || payload.data.byteLength !== grid.byteLength) {
throw new Error("NANOVDB_STREAM_INCOMPLETE: viewport density payload does not match the manifest");
densityGrid(manifest);
for (const grid of materialGridDefinitions(manifest, value.material ?? manifest.material)) {
const payload = value.grids.find((candidate) => candidate.name === grid.name);
if (!payload || payload.data.byteLength !== grid.byteLength) {
throw new Error(`NANOVDB_STREAM_INCOMPLETE: viewport ${grid.semantic.toLowerCase()} payload does not match the manifest`);
}
}
return { dataId: value.dataId, manifest, grids: value.grids, material: value.material ?? manifest.material };
return { dataId: value.dataId, manifest, grids: value.grids, material: value.material ?? manifest.material, viewAxis: value.viewAxis ?? "Z" };
}
export function cloneNanoVDBViewportAssets(assets: readonly NanoVDBViewportAssetIR[]): NanoVDBViewportAssetIR[] {
@@ -168,13 +303,14 @@ export async function loadNanoVDBViewportAsset(
const response = await fetcher(manifestUrl, { signal, cache: "no-store" });
if (!response.ok) throw new Error(`NANOVDB_STREAM_INCOMPLETE: manifest request returned ${response.status}`);
const manifest = validateNanoVDBBundleManifest(await response.json() as NanoVDBBundleManifestIR);
const grid = densityGrid(manifest);
const grids = materialGridDefinitions(manifest);
const source = createResumableHttpNanoVDBRangeSource(bundleUrl, manifest.bundleByteLength, { fetcher, retries: 2, requireStableEtag: true });
const payload = await loadDensityPayload(manifest, source, signal);
const payloads: NanoVDBViewportGridPayloadIR[] = [];
for (const grid of grids) payloads.push({ name: grid.name, data: await loadGridPayload(manifest, grid, source, signal) });
return validateNanoVDBViewportAsset({
dataId,
manifest,
grids: [{ name: grid.name, data: payload }],
grids: payloads,
});
}
@@ -200,11 +336,13 @@ export async function reopenNanoVDBViewportAssetFromOPFS(
lastBlockedCode = opened.bindingStatus?.code ?? lastBlockedCode;
continue;
}
const grid = densityGrid(opened.manifest);
const grids = materialGridDefinitions(opened.manifest);
const payloads: NanoVDBViewportGridPayloadIR[] = [];
for (const grid of grids) payloads.push({ name: grid.name, data: await loadGridPayload(opened.manifest, grid, opened.source, signal) });
return validateNanoVDBViewportAsset({
dataId,
manifest: opened.manifest,
grids: [{ name: grid.name, data: await loadDensityPayload(opened.manifest, opened.source, signal) }],
grids: payloads,
});
}
throw new Error(`${lastBlockedCode}: no current NanoVDB project binding is available`);
@@ -240,17 +378,27 @@ export async function renderNanoVDBViewportAsset(
const asset = validateNanoVDBViewportAsset(value);
const grid = densityGrid(asset.manifest);
const payload = asset.grids.find((candidate) => candidate.name === grid.name)!.data;
const probe = await probeNanoVDBWebGPU(residentBytes(payload.byteLength, asset.manifest.gpu.pageByteLength, asset.manifest.gpu.maxResidentBytes));
const material = asset.material ?? asset.manifest.material;
const residency = planNanoVDBGridResidency(asset.manifest, material);
const requiredStorageBuffers = 3 + 2 * [material.temperatureGrid, material.colorGrid, material.emissionGrid].filter(Boolean).length;
const probe = await probeNanoVDBWebGPU(
Math.max(...Object.values(residency.gridResidentBytes)),
requiredStorageBuffers,
);
if (!probe.capability.available || !probe.device) {
throw new Error(`VOLUME_SHADER_UNAVAILABLE: ${probe.capability.reason ?? "WebGPU device unavailable"}`);
}
const uploaded = uploadNanoVDBFloat32GridPaged(probe.device, payload, asset.manifest.gpu.pageByteLength, asset.manifest.gpu.maxResidentBytes);
const uploaded = uploadNanoVDBFloat32GridPaged(probe.device, payload, asset.manifest.gpu.pageByteLength, residency.gridResidentBytes[grid.name]);
const materialUploads: NanoVDBMaterialGridUploadsIR = {};
try {
const pixels = await renderNanoVDBFloat32WebGPU(probe.device, uploaded, grid, asset.material ?? asset.manifest.material, width, height);
return { dataId: asset.dataId, grid, material: asset.material ?? asset.manifest.material, pixels, width, height, capability: probe.capability };
uploadMaterialGrids(probe.device, asset, material, residency, materialUploads);
const viewAxis = asset.viewAxis ?? "Z";
const pixels = await renderNanoVDBFloat32WebGPU(probe.device, uploaded, grid, material, width, height, materialUploads, viewAxis);
return { dataId: asset.dataId, grid, material, pixels, width, height, viewAxis, capability: probe.capability };
}
finally {
uploaded.dispose();
disposeMaterialGrids(materialUploads);
probe.device.destroy();
}
}

View File

@@ -23,18 +23,15 @@ export interface PrincipledVolumeMappingInputIR {
export interface VolumeMaterialMappingLossIR {
code:
| "VOLUME_COLOR_GRID_UNSUPPORTED"
| "VOLUME_TEMPERATURE_BLACKBODY_UNSUPPORTED"
| "VOLUME_EMISSION_GRID_UNSUPPORTED"
| "VOLUME_VELOCITY_RENDER_UNSUPPORTED";
field: "colorGrid" | "temperatureGrid" | "emissionGrid" | "velocityGrid";
field: "velocityGrid";
fallback: string;
}
export interface VolumeMaterialMappingResultIR {
material: NanoVDBMaterialIR;
losses: VolumeMaterialMappingLossIR[];
supportedSemantics: Array<"DENSITY_GRID" | "CONSTANT_COLOR" | "CONSTANT_EMISSION" | "ANISOTROPY" | "INTERPOLATION">;
supportedSemantics: Array<"DENSITY_GRID" | "COLOR_GRID" | "TEMPERATURE_GRID_BLACKBODY" | "EMISSION_GRID" | "CONSTANT_COLOR" | "CONSTANT_EMISSION" | "ANISOTROPY" | "INTERPOLATION">;
}
function finite(value: number, minimum: number, maximum: number, name: string): number {
@@ -48,10 +45,11 @@ function color(value: [number, number, number] | undefined, fallback: [number, n
return [...result];
}
function requireGrid(manifest: NanoVDBBundleManifestIR, name: string | undefined, semantic: NanoVDBGridSemantic, field: string): string | undefined {
function requireGrid(manifest: NanoVDBBundleManifestIR, name: string | undefined, semantic: NanoVDBGridSemantic, field: string, valueType: "FLOAT32" | "VEC3F32"): string | undefined {
if (name === undefined) return undefined;
const grid = manifest.grids.find((candidate) => candidate.name === name);
if (!grid || grid.semantic !== semantic) throw new Error(`NANOVDB_MANIFEST_INVALID: ${field} must reference a ${semantic} grid`);
if (grid.valueType !== valueType) throw new Error(`NANOVDB_GRID_UNSUPPORTED: ${field} requires ${valueType}`);
return name;
}
@@ -60,16 +58,16 @@ export function mapPrincipledVolumeToNanoVDB(
input: PrincipledVolumeMappingInputIR,
): VolumeMaterialMappingResultIR {
const manifest = validateNanoVDBBundleManifest(sourceManifest);
const densityGrid = requireGrid(manifest, input.densityGrid, "DENSITY", "densityGrid");
const densityGrid = requireGrid(manifest, input.densityGrid, "DENSITY", "densityGrid", "FLOAT32");
if (!densityGrid) throw new Error("NANOVDB_MANIFEST_INVALID: a density grid is required");
const colorGrid = requireGrid(manifest, input.colorGrid, "COLOR", "colorGrid");
const temperatureGrid = requireGrid(manifest, input.temperatureGrid, "TEMPERATURE", "temperatureGrid");
const emissionGrid = requireGrid(manifest, input.emissionGrid, "EMISSION", "emissionGrid");
const velocityGrid = requireGrid(manifest, input.velocityGrid, "VELOCITY", "velocityGrid");
const colorGrid = requireGrid(manifest, input.colorGrid, "COLOR", "colorGrid", "VEC3F32");
const temperatureGrid = requireGrid(manifest, input.temperatureGrid, "TEMPERATURE", "temperatureGrid", "FLOAT32");
const emissionGrid = requireGrid(manifest, input.emissionGrid, "EMISSION", "emissionGrid", "FLOAT32");
const velocityGrid = requireGrid(manifest, input.velocityGrid, "VELOCITY", "velocityGrid", "VEC3F32");
const material: NanoVDBMaterialIR = {
densityGrid,
...(colorGrid ? { colorGrid } : {}),
...(temperatureGrid ? { temperatureGrid } : {}),
...(temperatureGrid && input.blackbodyEnabled ? { temperatureGrid } : {}),
...(emissionGrid ? { emissionGrid } : {}),
...(velocityGrid ? { velocityGrid } : {}),
densityScale: finite(input.densityScale, 0, 1_000_000, "densityScale"),
@@ -82,13 +80,10 @@ export function mapPrincipledVolumeToNanoVDB(
};
if (material.interpolation !== "NEAREST" && material.interpolation !== "LINEAR") throw new Error("NANOVDB_MANIFEST_INVALID: interpolation");
const losses: VolumeMaterialMappingLossIR[] = [];
if (colorGrid) losses.push({ code: "VOLUME_COLOR_GRID_UNSUPPORTED", field: "colorGrid", fallback: "constant color" });
if (temperatureGrid && input.blackbodyEnabled) losses.push({ code: "VOLUME_TEMPERATURE_BLACKBODY_UNSUPPORTED", field: "temperatureGrid", fallback: "constant emission color" });
if (emissionGrid) losses.push({ code: "VOLUME_EMISSION_GRID_UNSUPPORTED", field: "emissionGrid", fallback: "constant emission color and scale" });
if (velocityGrid) losses.push({ code: "VOLUME_VELOCITY_RENDER_UNSUPPORTED", field: "velocityGrid", fallback: "velocity metadata retained without motion rendering" });
return {
material,
losses,
supportedSemantics: ["DENSITY_GRID", "CONSTANT_COLOR", "CONSTANT_EMISSION", "ANISOTROPY", "INTERPOLATION"],
supportedSemantics: ["DENSITY_GRID", "COLOR_GRID", "TEMPERATURE_GRID_BLACKBODY", "EMISSION_GRID", "CONSTANT_COLOR", "CONSTANT_EMISSION", "ANISOTROPY", "INTERPOLATION"],
};
}