Advance WebGPU volume and bounded workflows

This commit is contained in:
mes123456
2026-08-14 18:08:29 -04:00
parent 3da1dfc804
commit 68d50f810f
119 changed files with 9028 additions and 430 deletions

View File

@@ -0,0 +1,88 @@
const K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]);
function rotate(value: number, amount: number): number {
return (value >>> amount) | (value << (32 - amount));
}
export class IncrementalSha256 {
private readonly state = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
private readonly block = new Uint8Array(64);
private blockLength = 0;
private bytes = 0;
private finished = false;
update(value: ArrayBuffer | Uint8Array): this {
if (this.finished) throw new Error("SHA-256 digest is already finalized");
const data = value instanceof Uint8Array ? value : new Uint8Array(value);
this.bytes += data.byteLength;
let offset = 0;
while (offset < data.byteLength) {
const length = Math.min(64 - this.blockLength, data.byteLength - offset);
this.block.set(data.subarray(offset, offset + length), this.blockLength);
this.blockLength += length;
offset += length;
if (this.blockLength === 64) {
this.compress(this.block);
this.blockLength = 0;
}
}
return this;
}
hex(): string {
if (!this.finished) {
const bitLength = this.bytes * 8;
this.block[this.blockLength++] = 0x80;
if (this.blockLength > 56) {
this.block.fill(0, this.blockLength);
this.compress(this.block);
this.blockLength = 0;
}
this.block.fill(0, this.blockLength, 56);
const view = new DataView(this.block.buffer);
view.setUint32(56, Math.floor(bitLength / 0x1_0000_0000), false);
view.setUint32(60, bitLength >>> 0, false);
this.compress(this.block);
this.finished = true;
}
return Array.from(this.state, (word) => word.toString(16).padStart(8, "0")).join("");
}
private compress(block: Uint8Array): void {
const words = new Uint32Array(64);
const view = new DataView(block.buffer, block.byteOffset, 64);
for (let index = 0; index < 16; index++) words[index] = view.getUint32(index * 4, false);
for (let index = 16; index < 64; index++) {
const s0 = rotate(words[index - 15], 7) ^ rotate(words[index - 15], 18) ^ (words[index - 15] >>> 3);
const s1 = rotate(words[index - 2], 17) ^ rotate(words[index - 2], 19) ^ (words[index - 2] >>> 10);
words[index] = (words[index - 16] + s0 + words[index - 7] + s1) >>> 0;
}
let [a, b, c, d, e, f, g, h] = this.state;
for (let index = 0; index < 64; index++) {
const s1 = rotate(e, 6) ^ rotate(e, 11) ^ rotate(e, 25);
const choose = (e & f) ^ (~e & g);
const t1 = (h + s1 + choose + K[index] + words[index]) >>> 0;
const s0 = rotate(a, 2) ^ rotate(a, 13) ^ rotate(a, 22);
const majority = (a & b) ^ (a & c) ^ (b & c);
const t2 = (s0 + majority) >>> 0;
h = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0;
}
this.state[0] = (this.state[0] + a) >>> 0;
this.state[1] = (this.state[1] + b) >>> 0;
this.state[2] = (this.state[2] + c) >>> 0;
this.state[3] = (this.state[3] + d) >>> 0;
this.state[4] = (this.state[4] + e) >>> 0;
this.state[5] = (this.state[5] + f) >>> 0;
this.state[6] = (this.state[6] + g) >>> 0;
this.state[7] = (this.state[7] + h) >>> 0;
}
}

View File

@@ -0,0 +1,99 @@
import type { NanoVDBFloat32TreeLayoutIR, NanoVDBGridIR } from "../../../protocol/volume-vdb";
export interface NanoVDBSampleIR { value: number; active: boolean }
export class NanoVDBFloat32Sampler {
private readonly view: DataView;
private readonly layout: NanoVDBFloat32TreeLayoutIR;
private readonly root: number;
constructor(payload: ArrayBuffer, grid: NanoVDBGridIR, layout: NanoVDBFloat32TreeLayoutIR | undefined) {
if (grid.valueType !== "FLOAT32" || !layout) throw new Error("NANOVDB_GRID_UNSUPPORTED: Float32 tree layout is required");
if (payload.byteLength !== grid.byteLength || payload.byteLength < layout.gridDataBytes + layout.treeDataBytes) throw new Error("NANOVDB_STREAM_INCOMPLETE: Float32 grid payload length mismatch");
this.view = new DataView(payload);
this.layout = layout;
if (this.u32(0) !== 0x6f6e614e || this.u32(4) !== 0x31424456) throw new Error("NANOVDB_MANIFEST_INVALID: NanoVDB grid magic mismatch");
if ((this.u32(16) >>> 21) !== 32) throw new Error("NANOVDB_GRID_UNSUPPORTED: NanoVDB major version is unsupported");
if (this.u64(32) !== BigInt(payload.byteLength)) throw new Error("NANOVDB_STREAM_INCOMPLETE: NanoVDB GridData size mismatch");
const tree = layout.gridDataBytes;
const rootOffset = this.i64(tree + layout.treeRootOffsetOffset);
if (rootOffset <= 0n || rootOffset > BigInt(payload.byteLength - layout.rootDataBytes)) throw new Error("NANOVDB_MANIFEST_INVALID: NanoVDB root offset is outside the payload");
this.root = tree + Number(rootOffset);
const tableSize = this.u32(this.root + layout.rootTableSizeOffset);
this.range(this.root + layout.rootDataBytes, tableSize * layout.rootTileBytes);
}
nearest(coord: readonly [number, number, number]): NanoVDBSampleIR {
if (coord.some((value) => !Number.isSafeInteger(value) || value < -0x8000_0000 || value > 0x7fff_ffff)) throw new Error("NANOVDB_MANIFEST_INVALID: sample coordinate is outside int32");
const tableSize = this.u32(this.root + this.layout.rootTableSizeOffset);
const key = this.rootKey(coord);
let low = 0;
let high = tableSize - 1;
let tile = -1;
while (low <= high) {
const middle = (low + high) >>> 1;
const address = this.root + this.layout.rootDataBytes + middle * this.layout.rootTileBytes;
const candidate = this.u64(address + this.layout.rootTileKeyOffset);
if (candidate === key) { tile = address; break; }
// NanoVDB root tiles are serialized in descending key order.
if (candidate > key) low = middle + 1;
else high = middle - 1;
}
if (tile < 0) return { value: this.view.getFloat32(this.root + 28, true), active: false };
const child = this.i64(tile + this.layout.rootTileChildOffset);
if (child === 0n) return { value: this.f32(tile + this.layout.rootTileValueOffset), active: this.u32(tile + this.layout.rootTileStateOffset) !== 0 };
const upper = this.child(this.root, child, this.layout.upperNodeBytes);
const upperOffset = (((coord[0] >>> 0 & 4095) >>> 7) << 10) | (((coord[1] >>> 0 & 4095) >>> 7) << 5) | ((coord[2] >>> 0 & 4095) >>> 7);
const upperSample = this.internal(upper, upperOffset, this.layout.upperValueMaskOffset, this.layout.upperChildMaskOffset, this.layout.upperTableOffset, this.layout.lowerNodeBytes);
if ("sample" in upperSample) return upperSample.sample;
const lower = upperSample.child;
const lowerOffset = (((coord[0] >>> 0 & 127) >>> 3) << 8) | (((coord[1] >>> 0 & 127) >>> 3) << 4) | ((coord[2] >>> 0 & 127) >>> 3);
const lowerSample = this.internal(lower, lowerOffset, this.layout.lowerValueMaskOffset, this.layout.lowerChildMaskOffset, this.layout.lowerTableOffset, this.layout.leafNodeBytes);
if ("sample" in lowerSample) return lowerSample.sample;
const leaf = lowerSample.child;
const voxel = ((coord[0] >>> 0 & 7) << 6) | ((coord[1] >>> 0 & 7) << 3) | (coord[2] >>> 0 & 7);
return { value: this.f32(leaf + this.layout.leafValuesOffset + voxel * 4), active: this.mask(leaf + this.layout.leafValueMaskOffset, voxel) };
}
linear(coord: readonly [number, number, number]): NanoVDBSampleIR {
const base = coord.map(Math.floor) as [number, number, number];
const fraction = coord.map((value, index) => value - base[index]) as [number, number, number];
let value = 0;
let active = false;
for (let x = 0; x < 2; x++) for (let y = 0; y < 2; y++) for (let z = 0; z < 2; z++) {
const sample = this.nearest([base[0] + x, base[1] + y, base[2] + z]);
const weight = (x ? fraction[0] : 1 - fraction[0]) * (y ? fraction[1] : 1 - fraction[1]) * (z ? fraction[2] : 1 - fraction[2]);
value += sample.value * weight;
active ||= sample.active;
}
return { value, active };
}
private internal(node: number, index: number, valueMaskOffset: number, childMaskOffset: number, tableOffset: number, childBytes: number): { child: number } | { sample: NanoVDBSampleIR } {
if (!this.mask(node + childMaskOffset, index)) return { sample: { value: this.f32(node + tableOffset + index * 8), active: this.mask(node + valueMaskOffset, index) } };
return { child: this.child(node, this.i64(node + tableOffset + index * 8), childBytes) };
}
private child(parent: number, offset: bigint, bytes: number): number {
if (offset <= 0n || offset > BigInt(this.view.byteLength)) throw new Error("NANOVDB_MANIFEST_INVALID: NanoVDB child offset is invalid");
const child = parent + Number(offset);
this.range(child, bytes);
return child;
}
private rootKey(coord: readonly number[]): bigint {
const x = BigInt(coord[0] >>> 0) >> 12n;
const y = BigInt(coord[1] >>> 0) >> 12n;
const z = BigInt(coord[2] >>> 0) >> 12n;
return z | (y << 21n) | (x << 42n);
}
private mask(address: number, index: number): boolean { return (this.u32(address + (index >>> 5) * 4) & (1 << (index & 31))) !== 0; }
private u32(address: number): number { this.range(address, 4); return this.view.getUint32(address, true); }
private f32(address: number): number { this.range(address, 4); return this.view.getFloat32(address, true); }
private u64(address: number): bigint { this.range(address, 8); return this.view.getBigUint64(address, true); }
private i64(address: number): bigint { this.range(address, 8); return this.view.getBigInt64(address, true); }
private range(address: number, bytes: number): void {
if (!Number.isSafeInteger(address) || !Number.isSafeInteger(bytes) || address < 0 || bytes < 0 || address > this.view.byteLength - bytes) throw new Error("NANOVDB_MANIFEST_INVALID: NanoVDB address is outside the grid payload");
}
}

View File

@@ -0,0 +1,268 @@
import {
evaluateVDBProjectBinding,
validateNanoVDBBundleManifest,
validateVDBProjectBinding,
verifyNanoVDBChunk,
type NanoVDBBundleManifestIR,
type VDBProjectBindingIR,
type VDBProjectBindingStatusIR,
type VDBProjectReopenContextIR,
} from "../../../protocol/volume-vdb";
import { validateProjectId, validateSha256 } from "../storage/opfs-files";
import { IncrementalSha256 } from "./incremental-sha256";
import { streamNanoVDBChunks, type NanoVDBRangeSource } from "./nanovdb-stream";
type OpfsStorage = StorageManager & { getDirectory?: () => Promise<FileSystemDirectoryHandle> };
type MovableFile = FileSystemFileHandle & { move?: (name: string) => Promise<void> };
type DirectoryEntries = AsyncIterableIterator<[string, FileSystemHandle]>;
export interface NanoVDBOPFSCommitResult {
projectId: string;
bundleSha256: string;
bundleByteLength: number;
chunks: number;
deduplicated: boolean;
}
export interface NanoVDBOPFSOpenResult {
manifest: NanoVDBBundleManifestIR;
binding?: VDBProjectBindingIR;
bindingStatus?: VDBProjectBindingStatusIR;
source: NanoVDBRangeSource;
}
export async function listVDBProjectBindings(projectId: string, storage?: StorageManager): Promise<VDBProjectBindingIR[]> {
const cache = await rootFor(projectId, storage);
const bindings = await directory(cache, "bindings");
const result: VDBProjectBindingIR[] = [];
const entries = (bindings as unknown as { entries: () => DirectoryEntries }).entries();
for await (const [name, handle] of entries) {
if (handle.kind !== "file" || !/^[a-f0-9]{64}\.json$/.test(name)) continue;
try { result.push(validateVDBProjectBinding(await readJson<VDBProjectBindingIR>(bindings, name))); }
catch { /* Invalid binding records are ignored and cannot make a bundle discoverable. */ }
}
return result.sort((left, right) => right.committedAt.localeCompare(left.committedAt));
}
async function directory(parent: FileSystemDirectoryHandle, name: string, create = true): Promise<FileSystemDirectoryHandle> {
if (!/^[A-Za-z0-9._-]{1,128}$/.test(name) || name === "." || name === "..") throw new Error("NANOVDB_MANIFEST_INVALID: OPFS directory name");
return parent.getDirectoryHandle(name, { create });
}
async function rootFor(projectId: string, storage?: StorageManager): Promise<FileSystemDirectoryHandle> {
validateProjectId(projectId);
const manager = (storage ?? navigator.storage) as OpfsStorage;
if (!manager.getDirectory) throw new Error("NANOVDB_STREAM_INCOMPLETE: OPFS is unavailable");
let current = await manager.getDirectory();
for (const name of ["projects", projectId, "cache", "vdb"]) current = await directory(current, name);
await directory(current, "bindings");
return current;
}
async function writeFile(parent: FileSystemDirectoryHandle, name: string, value: ArrayBuffer | string): Promise<void> {
const handle = await parent.getFileHandle(name, { create: true });
const writer = await handle.createWritable();
await writer.write(value);
await writer.close();
}
async function atomicWrite(parent: FileSystemDirectoryHandle, name: string, value: ArrayBuffer | string): Promise<void> {
const stageName = `${name}.${crypto.randomUUID()}.stage`;
await writeFile(parent, stageName, value);
const stage = await parent.getFileHandle(stageName) as MovableFile;
if (stage.move) await stage.move(name);
else {
const bytes = await (await stage.getFile()).arrayBuffer();
await writeFile(parent, name, bytes);
await parent.removeEntry(stageName);
}
}
async function readJson<T>(parent: FileSystemDirectoryHandle, name: string): Promise<T> {
const bytes = await (await (await parent.getFileHandle(name)).getFile()).arrayBuffer();
return JSON.parse(new TextDecoder().decode(bytes)) as T;
}
async function remove(parent: FileSystemDirectoryHandle, name: string, recursive = false): Promise<void> {
try { await parent.removeEntry(name, { recursive }); }
catch (error) { if (!(error instanceof DOMException) || error.name !== "NotFoundError") throw error; }
}
function chunkName(index: number): string {
return `${String(index).padStart(5, "0")}.chunk`;
}
async function digestJson(value: unknown): Promise<string> {
const bytes = new TextEncoder().encode(JSON.stringify(value));
const hash = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
export async function createVDBProjectBinding(
manifestValue: NanoVDBBundleManifestIR,
sourceBlendSha256: string,
): Promise<VDBProjectBindingIR> {
const manifest = validateNanoVDBBundleManifest(manifestValue);
validateSha256(sourceBlendSha256);
const manifestSha256 = await digestJson(manifest);
return validateVDBProjectBinding({
schemaVersion: 1,
projectId: manifest.projectId,
sourceBlendSha256,
sourcePath: manifest.sourcePath,
sourceSha256: manifest.sourceSha256,
conversionRequestSha256: manifest.conversionRequestSha256,
bundleSha256: manifest.bundleSha256,
bundleByteLength: manifest.bundleByteLength,
manifestSha256,
converter: manifest.converter,
shaderSemanticVersion: manifest.gpu.shaderSemanticVersion,
material: manifest.material,
committedAt: new Date().toISOString(),
});
}
export async function commitNanoVDBToOPFS(
manifestValue: NanoVDBBundleManifestIR,
source: NanoVDBRangeSource,
signal: AbortSignal,
bindingValue?: VDBProjectBindingIR,
storage?: StorageManager,
): Promise<NanoVDBOPFSCommitResult> {
const manifest = validateNanoVDBBundleManifest(manifestValue);
const binding = bindingValue ? validateVDBProjectBinding(bindingValue) : undefined;
const manifestSha256 = await digestJson(manifest);
if (binding && (binding.projectId !== manifest.projectId || binding.bundleSha256 !== manifest.bundleSha256 || binding.conversionRequestSha256 !== manifest.conversionRequestSha256 || binding.manifestSha256 !== manifestSha256)) throw new Error("NANOVDB_HASH_MISMATCH: project binding does not match manifest");
const cache = await rootFor(manifest.projectId, storage);
const bundle = await directory(cache, manifest.bundleSha256);
try {
const existing = validateNanoVDBBundleManifest(await readJson<NanoVDBBundleManifestIR>(bundle, "manifest.json"));
if (existing.bundleSha256 === manifest.bundleSha256 && existing.conversionRequestSha256 === manifest.conversionRequestSha256 && await digestJson(existing) === manifestSha256) {
for (const chunk of existing.chunks) {
const data = await (await (await bundle.getFileHandle(chunkName(chunk.index))).getFile()).arrayBuffer();
await verifyNanoVDBChunk(chunk, data);
}
await atomicWrite(bundle, "access.json", JSON.stringify({ lastAccessAt: new Date().toISOString(), bytes: manifest.bundleByteLength }));
if (binding) {
const bindings = await directory(cache, "bindings");
await atomicWrite(bindings, `${binding.conversionRequestSha256}.json`, JSON.stringify(binding));
}
return { projectId: manifest.projectId, bundleSha256: manifest.bundleSha256, bundleByteLength: manifest.bundleByteLength, chunks: manifest.chunks.length, deduplicated: true };
}
}
catch { /* An incomplete directory is staging and remains undiscoverable until manifest commit. */ }
const hasher = new IncrementalSha256();
const staged: string[] = [];
try {
await streamNanoVDBChunks(manifest, source, async (range, data) => {
if (signal.aborted) throw new DOMException("NanoVDB OPFS commit cancelled", "AbortError");
hasher.update(data);
const name = `${chunkName(range.chunkIndex)}.${crypto.randomUUID()}.stage`;
staged.push(name);
await writeFile(bundle, name, data);
const written = await (await bundle.getFileHandle(name)).getFile();
await verifyNanoVDBChunk(manifest.chunks[range.chunkIndex], await written.arrayBuffer());
}, signal);
if (hasher.hex() !== manifest.bundleSha256) throw new Error("NANOVDB_HASH_MISMATCH: streamed bundle SHA-256 mismatch");
for (let index = 0; index < staged.length; index++) {
const handle = await bundle.getFileHandle(staged[index]) as MovableFile;
if (handle.move) await handle.move(chunkName(index));
else {
await writeFile(bundle, chunkName(index), await (await handle.getFile()).arrayBuffer());
await bundle.removeEntry(staged[index]);
}
}
await atomicWrite(bundle, "access.json", JSON.stringify({ lastAccessAt: new Date().toISOString(), bytes: manifest.bundleByteLength }));
await atomicWrite(bundle, "manifest.json", JSON.stringify(manifest));
if (binding) {
const bindings = await directory(cache, "bindings");
await atomicWrite(bindings, `${binding.conversionRequestSha256}.json`, JSON.stringify(binding));
}
return { projectId: manifest.projectId, bundleSha256: manifest.bundleSha256, bundleByteLength: manifest.bundleByteLength, chunks: manifest.chunks.length, deduplicated: false };
}
catch (error) {
await Promise.all(staged.map((name) => remove(bundle, name)));
await remove(bundle, "manifest.json");
throw error;
}
}
export async function openNanoVDBFromOPFS(
projectId: string,
bundleSha256: string,
conversionRequestSha256?: string,
reopenContext?: VDBProjectReopenContextIR,
storage?: StorageManager,
): Promise<NanoVDBOPFSOpenResult> {
validateSha256(bundleSha256);
if (conversionRequestSha256) validateSha256(conversionRequestSha256);
const cache = await rootFor(projectId, storage);
const bundle = await directory(cache, bundleSha256, false);
const manifest = validateNanoVDBBundleManifest(await readJson<NanoVDBBundleManifestIR>(bundle, "manifest.json"));
if (manifest.projectId !== projectId || manifest.bundleSha256 !== bundleSha256) throw new Error("NANOVDB_HASH_MISMATCH: OPFS bundle identity mismatch");
let binding: VDBProjectBindingIR | undefined;
let bindingStatus: VDBProjectBindingStatusIR | undefined;
if (conversionRequestSha256) {
const bindings = await directory(cache, "bindings");
try { binding = validateVDBProjectBinding(await readJson<VDBProjectBindingIR>(bindings, `${conversionRequestSha256}.json`)); }
catch { binding = undefined; }
if (binding && binding.manifestSha256 !== await digestJson(manifest)) throw new Error("NANOVDB_HASH_MISMATCH: OPFS manifest changed after project commit");
if (reopenContext) bindingStatus = evaluateVDBProjectBinding(binding, reopenContext);
}
await atomicWrite(bundle, "access.json", JSON.stringify({ lastAccessAt: new Date().toISOString(), bytes: manifest.bundleByteLength }));
const source: NanoVDBRangeSource = async (range, signal) => {
if (signal.aborted) throw new DOMException("NanoVDB OPFS read cancelled", "AbortError");
const declared = manifest.chunks[range.chunkIndex];
if (!declared || range.start !== declared.byteOffset || range.endExclusive !== declared.byteOffset + declared.byteLength) throw new Error("NANOVDB_STREAM_INCOMPLETE: OPFS range is not a declared chunk");
const file = await (await bundle.getFileHandle(chunkName(range.chunkIndex))).getFile();
if (file.size !== declared.byteLength) throw new Error("NANOVDB_STREAM_INCOMPLETE: OPFS chunk length mismatch");
const data = await file.arrayBuffer();
await verifyNanoVDBChunk(declared, data);
return data;
};
return { manifest, binding, bindingStatus, source };
}
export async function recoverNanoVDBOPFS(projectId: string, storage?: StorageManager): Promise<{ removedStaging: number; removedIncompleteBundles: number }> {
const cache = await rootFor(projectId, storage);
let removedStaging = 0;
let removedIncompleteBundles = 0;
const entries = (cache as unknown as { entries: () => DirectoryEntries }).entries();
for await (const [name, handle] of entries) {
if (handle.kind !== "directory" || name === "bindings") continue;
if (!/^[a-f0-9]{64}$/.test(name)) { await remove(cache, name, true); removedIncompleteBundles++; continue; }
const bundle = handle as FileSystemDirectoryHandle;
let validManifest: boolean;
try { validManifest = validateNanoVDBBundleManifest(await readJson<NanoVDBBundleManifestIR>(bundle, "manifest.json")).bundleSha256 === name; }
catch { validManifest = false; }
if (!validManifest) { await remove(cache, name, true); removedIncompleteBundles++; continue; }
const files = (bundle as unknown as { entries: () => DirectoryEntries }).entries();
for await (const [fileName] of files) if (fileName.endsWith(".stage")) { await remove(bundle, fileName); removedStaging++; }
}
return { removedStaging, removedIncompleteBundles };
}
export async function pruneNanoVDBOPFS(projectId: string, maxBytes: number, storage?: StorageManager): Promise<{ removed: string[]; retainedBytes: number }> {
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: invalid OPFS cache budget");
const cache = await rootFor(projectId, storage);
const bundles: Array<{ name: string; bytes: number; lastAccessAt: string }> = [];
const entries = (cache as unknown as { entries: () => DirectoryEntries }).entries();
for await (const [name, handle] of entries) {
if (handle.kind !== "directory" || !/^[a-f0-9]{64}$/.test(name)) continue;
try {
const access = await readJson<{ bytes: number; lastAccessAt: string }>(handle as FileSystemDirectoryHandle, "access.json");
if (Number.isSafeInteger(access.bytes) && access.bytes > 0 && Number.isFinite(Date.parse(access.lastAccessAt))) bundles.push({ name, ...access });
}
catch { /* Recovery owns incomplete entries. */ }
}
let total = bundles.reduce((sum, item) => sum + item.bytes, 0);
const removed: string[] = [];
for (const item of bundles.sort((left, right) => left.lastAccessAt.localeCompare(right.lastAccessAt))) {
if (total <= maxBytes) break;
await remove(cache, item.name, true);
total -= item.bytes;
removed.push(item.name);
}
return { removed, retainedBytes: total };
}

View File

@@ -0,0 +1,190 @@
import {
planNanoVDBRanges,
validateNanoVDBBundleManifest,
verifyNanoVDBChunk,
type NanoVDBBundleManifestIR,
type NanoVDBRangeIR,
} from "../../../protocol/volume-vdb";
export interface NanoVDBStreamProgressIR {
completedChunks: number;
totalChunks: number;
completedBytes: number;
totalBytes: number;
}
export interface NanoVDBStreamResultIR extends NanoVDBStreamProgressIR {
declaredBundleSha256: string;
}
export type NanoVDBRangeSource = (range: NanoVDBRangeIR, signal: AbortSignal) => Promise<ArrayBuffer>;
export type NanoVDBChunkConsumer = (range: NanoVDBRangeIR, data: ArrayBuffer, signal: AbortSignal) => Promise<void> | void;
function cancelled(signal: AbortSignal): void {
if (signal.aborted) throw new DOMException("NanoVDB stream cancelled", "AbortError");
}
export async function streamNanoVDBChunks(
manifestValue: NanoVDBBundleManifestIR,
source: NanoVDBRangeSource,
consume: NanoVDBChunkConsumer,
signal: AbortSignal,
onProgress?: (progress: NanoVDBStreamProgressIR) => void,
): Promise<NanoVDBStreamResultIR> {
const manifest = validateNanoVDBBundleManifest(manifestValue);
const ranges = planNanoVDBRanges(manifest);
let completedBytes = 0;
for (const range of ranges) {
cancelled(signal);
const data = await source(range, signal);
cancelled(signal);
await verifyNanoVDBChunk(manifest.chunks[range.chunkIndex], data);
cancelled(signal);
await consume(range, data, signal);
completedBytes += data.byteLength;
onProgress?.({
completedChunks: range.chunkIndex + 1,
totalChunks: ranges.length,
completedBytes,
totalBytes: manifest.bundleByteLength,
});
}
return {
completedChunks: ranges.length,
totalChunks: ranges.length,
completedBytes,
totalBytes: manifest.bundleByteLength,
declaredBundleSha256: manifest.bundleSha256,
};
}
function parseContentRange(value: string | null): { start: number; endInclusive: number; total: number } | undefined {
const match = value?.match(/^bytes (\d+)-(\d+)\/(\d+)$/);
if (!match) return undefined;
const start = Number(match[1]);
const endInclusive = Number(match[2]);
const total = Number(match[3]);
if (![start, endInclusive, total].every(Number.isSafeInteger)) return undefined;
return { start, endInclusive, total };
}
export function createHttpNanoVDBRangeSource(
url: string,
expectedBundleBytes: number,
fetcher: typeof fetch = fetch,
): NanoVDBRangeSource {
return createResumableHttpNanoVDBRangeSource(url, expectedBundleBytes, { fetcher, retries: 0, requireStableEtag: false });
}
export interface NanoVDBHttpRangeOptions {
fetcher?: typeof fetch;
retries?: number;
retryDelayMs?: number;
requireStableEtag?: boolean;
}
async function waitForHttpRetry(delayMs: number, attempt: number, signal: AbortSignal): Promise<void> {
if (delayMs === 0) return;
await new Promise<void>((resolve, reject) => {
const onAbort = (): void => {
clearTimeout(timer);
reject(new DOMException("NanoVDB HTTP range cancelled", "AbortError"));
};
const timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, delayMs * (attempt + 1));
signal.addEventListener("abort", onAbort, { once: true });
});
}
function protocolFailure(error: unknown): boolean {
return error instanceof Error && /^(?:NANOVDB_|VDB_)/.test(error.message);
}
export function createResumableHttpNanoVDBRangeSource(
url: string,
expectedBundleBytes: number,
options: NanoVDBHttpRangeOptions = {},
): NanoVDBRangeSource {
if (!url || !Number.isSafeInteger(expectedBundleBytes) || expectedBundleBytes <= 0) throw new Error("NANOVDB_MANIFEST_INVALID: HTTP range source is invalid");
const fetcher = options.fetcher ?? fetch;
const retries = options.retries ?? 2;
const retryDelayMs = options.retryDelayMs ?? 25;
if (!Number.isSafeInteger(retries) || retries < 0 || retries > 8 || !Number.isSafeInteger(retryDelayMs) || retryDelayMs < 0 || retryDelayMs > 10_000) {
throw new Error("NANOVDB_MANIFEST_INVALID: HTTP retry policy is invalid");
}
let etag: string | undefined;
return async (range, signal) => {
if (!Number.isSafeInteger(range.start) || !Number.isSafeInteger(range.endExclusive) || range.start < 0 || range.endExclusive <= range.start || range.endExclusive > expectedBundleBytes) {
throw new Error("NANOVDB_STREAM_INCOMPLETE: HTTP range is outside the NanoVDB bundle");
}
const output = new Uint8Array(range.endExclusive - range.start);
let written = 0;
let lastStatus = 0;
let lastFailure = "network interruption";
for (let attempt = 0; attempt <= retries; attempt++) {
if (signal.aborted) throw new DOMException("NanoVDB HTTP range cancelled", "AbortError");
const requestStart = range.start + written;
const headers: Record<string, string> = { Range: `bytes=${requestStart}-${range.endExclusive - 1}` };
if (etag) headers["If-Range"] = etag;
let response: Response;
try {
response = await fetcher(url, { method: "GET", headers, signal, cache: "no-store" });
}
catch (error) {
if (signal.aborted || error instanceof DOMException && error.name === "AbortError") throw new DOMException("NanoVDB HTTP range cancelled", "AbortError");
lastFailure = error instanceof Error ? error.message : String(error);
if (attempt === retries) break;
await waitForHttpRetry(retryDelayMs, attempt, signal);
continue;
}
lastStatus = response.status;
if (response.status === 408 || response.status === 425 || response.status === 429 || response.status >= 500) {
if (attempt === retries) break;
await waitForHttpRetry(retryDelayMs, attempt, signal);
continue;
}
if (response.status !== 206) throw new Error(`NANOVDB_STREAM_INCOMPLETE: HTTP range request returned ${response.status}, expected 206`);
const responseEtag = response.headers.get("ETag") ?? undefined;
if (etag && responseEtag !== etag) throw new Error("NANOVDB_HASH_MISMATCH: HTTP ETag changed during NanoVDB streaming");
if (!etag && responseEtag) etag = responseEtag;
if (options.requireStableEtag && !etag) throw new Error("NANOVDB_STREAM_INCOMPLETE: HTTP ETag is required for resumable streaming");
const contentRange = parseContentRange(response.headers.get("Content-Range"));
if (!contentRange || contentRange.start !== requestStart || contentRange.endInclusive !== range.endExclusive - 1 || contentRange.total !== expectedBundleBytes) {
throw new Error("NANOVDB_STREAM_INCOMPLETE: HTTP Content-Range does not match the NanoVDB manifest");
}
if (!response.body) throw new Error("NANOVDB_STREAM_INCOMPLETE: HTTP range response has no body");
const reader = response.body.getReader();
try {
while (true) {
const next = await reader.read();
if (next.done) break;
const value = next.value;
if (written + value.byteLength > output.byteLength) {
throw new Error("NANOVDB_STREAM_INCOMPLETE: HTTP range response exceeds the requested byte length");
}
output.set(value, written);
written += value.byteLength;
}
}
catch (error) {
if (signal.aborted || error instanceof DOMException && error.name === "AbortError") throw new DOMException("NanoVDB HTTP range cancelled", "AbortError");
if (protocolFailure(error)) throw error;
if (written === output.byteLength) return output.buffer;
lastFailure = error instanceof Error ? error.message : String(error);
if (attempt === retries) break;
await waitForHttpRetry(retryDelayMs, attempt, signal);
continue;
}
finally {
reader.releaseLock();
}
if (written !== output.byteLength) {
throw new Error("NANOVDB_STREAM_INCOMPLETE: HTTP range response has an unexpected byte length");
}
return output.buffer;
}
throw new Error(`NANOVDB_STREAM_INCOMPLETE: HTTP range retry budget exhausted after ${lastStatus ? `status ${lastStatus}` : lastFailure}`);
};
}

View File

@@ -0,0 +1,256 @@
import {
validateNanoVDBBundleManifest,
verifyNanoVDBChunk,
type NanoVDBBundleManifestIR,
type NanoVDBGridIR,
type NanoVDBMaterialIR,
type NanoVDBRangeIR,
} from "../../../protocol/volume-vdb";
import {
NanoVDBWebGPUDeviceSession,
probeNanoVDBWebGPU,
renderNanoVDBFloat32WebGPU,
uploadNanoVDBFloat32GridPaged,
type NanoVDBWebGPUCapabilityIR,
} from "../render/nanovdb-volume-renderer";
import { createResumableHttpNanoVDBRangeSource } from "./nanovdb-stream";
import type { NanoVDBRangeSource } from "./nanovdb-stream";
import {
commitNanoVDBToOPFS,
createVDBProjectBinding,
listVDBProjectBindings,
openNanoVDBFromOPFS,
} from "./nanovdb-opfs";
export interface NanoVDBViewportGridPayloadIR {
name: string;
data: ArrayBuffer;
}
export interface NanoVDBViewportAssetIR {
dataId: string;
manifest: NanoVDBBundleManifestIR;
grids: NanoVDBViewportGridPayloadIR[];
material?: NanoVDBMaterialIR;
}
export interface NanoVDBViewportRenderResultIR {
dataId: string;
grid: NanoVDBGridIR;
material: NanoVDBMaterialIR;
pixels: Uint8Array;
width: number;
height: number;
capability: NanoVDBWebGPUCapabilityIR;
}
export interface NanoVDBViewportProjectContextIR {
projectId: string;
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;
}
/** Keeps the WebGPU device alive for a production viewport and rebuilds it after loss. */
export class NanoVDBViewportRenderSession {
private readonly deviceSession = new NanoVDBWebGPUDeviceSession();
private readonly removeLossListener: () => void;
private disposed = false;
constructor(private readonly onDeviceLost?: () => void) {
this.removeLossListener = this.deviceSession.onDeviceLost(() => this.onDeviceLost?.());
}
async render(value: NanoVDBViewportAssetIR, width = 128, height = 128): Promise<NanoVDBViewportRenderResultIR> {
if (this.disposed) throw new Error("VOLUME_SHADER_UNAVAILABLE: viewport render session is disposed");
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);
let device = this.deviceSession.device;
if (!device || this.deviceSession.status !== "ready") device = await this.deviceSession.open(requiredBytes);
for (let attempt = 0; attempt < 2; attempt++) {
const uploaded = uploadNanoVDBFloat32GridPaged(device, payload, asset.manifest.gpu.pageByteLength, asset.manifest.gpu.maxResidentBytes);
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: {
available: true,
maxStorageBufferBindingSize: Number(device.limits.maxStorageBufferBindingSize),
maxBufferSize: Number(device.limits.maxBufferSize),
} };
}
catch (error) {
if (this.deviceSession.status !== "lost" || attempt !== 0) throw error;
device = await this.deviceSession.recover(requiredBytes);
}
finally { uploaded.dispose(); }
}
throw new Error("VOLUME_SHADER_UNAVAILABLE: WebGPU device recovery exhausted");
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.removeLossListener();
this.deviceSession.dispose();
}
}
function densityGrid(manifest: NanoVDBBundleManifestIR): NanoVDBGridIR {
const grid = manifest.grids.find((candidate) => candidate.name === manifest.material.densityGrid);
if (!grid || grid.semantic !== "DENSITY" || grid.valueType !== "FLOAT32") {
throw new Error("NANOVDB_GRID_UNSUPPORTED: production viewport requires a Float32 density grid");
}
return grid;
}
async function loadDensityPayload(
manifest: NanoVDBBundleManifestIR,
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) {
const chunkEnd = chunk.byteOffset + chunk.byteLength;
const gridEnd = grid.byteOffset + grid.byteLength;
const overlapStart = Math.max(chunk.byteOffset, grid.byteOffset);
const overlapEnd = Math.min(chunkEnd, gridEnd);
if (overlapEnd <= overlapStart) continue;
const range: NanoVDBRangeIR = { chunkIndex: chunk.index, start: chunk.byteOffset, endExclusive: chunkEnd, sha256: chunk.sha256 };
const data = await source(range, signal);
await verifyNanoVDBChunk(chunk, data);
const sourceOffset = overlapStart - chunk.byteOffset;
const targetOffset = overlapStart - grid.byteOffset;
const overlapLength = overlapEnd - overlapStart;
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");
return payload.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");
}
return { dataId: value.dataId, manifest, grids: value.grids, material: value.material ?? manifest.material };
}
export function cloneNanoVDBViewportAssets(assets: readonly NanoVDBViewportAssetIR[]): NanoVDBViewportAssetIR[] {
return assets.map((asset) => ({
...asset,
manifest: structuredClone(asset.manifest),
material: asset.material ? structuredClone(asset.material) : undefined,
grids: asset.grids.map((grid) => ({ name: grid.name, data: grid.data.slice(0) })),
}));
}
export function nanoVDBViewportAssetTransferables(assets: readonly NanoVDBViewportAssetIR[]): Transferable[] {
return assets.flatMap((asset) => asset.grids.map((grid) => grid.data));
}
export async function loadNanoVDBViewportAsset(
dataId: string,
manifestUrl: string,
bundleUrl: string,
signal: AbortSignal,
fetcher: typeof fetch = fetch,
): Promise<NanoVDBViewportAssetIR> {
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 source = createResumableHttpNanoVDBRangeSource(bundleUrl, manifest.bundleByteLength, { fetcher, retries: 2, requireStableEtag: true });
const payload = await loadDensityPayload(manifest, source, signal);
return validateNanoVDBViewportAsset({
dataId,
manifest,
grids: [{ name: grid.name, data: payload }],
});
}
export async function reopenNanoVDBViewportAssetFromOPFS(
dataId: string,
sourcePath: string,
context: NanoVDBViewportProjectContextIR,
signal: AbortSignal,
): Promise<NanoVDBViewportAssetIR> {
const bindings = await listVDBProjectBindings(context.projectId);
const candidates = bindings.filter((binding) => binding.sourcePath === sourcePath);
let lastBlockedCode = "VDB_BINDING_MISSING";
for (const binding of candidates) {
const opened = await openNanoVDBFromOPFS(context.projectId, binding.bundleSha256, binding.conversionRequestSha256, {
projectId: context.projectId,
sourceBlendSha256: context.sourceBlendSha256,
sourcePath,
sourceSha256: binding.sourceSha256,
converter: binding.converter,
shaderSemanticVersion: "volume-wgsl-v1",
});
if (opened.bindingStatus?.status !== "READY") {
lastBlockedCode = opened.bindingStatus?.code ?? lastBlockedCode;
continue;
}
const grid = densityGrid(opened.manifest);
return validateNanoVDBViewportAsset({
dataId,
manifest: opened.manifest,
grids: [{ name: grid.name, data: await loadDensityPayload(opened.manifest, opened.source, signal) }],
});
}
throw new Error(`${lastBlockedCode}: no current NanoVDB project binding is available`);
}
export async function loadAndCommitNanoVDBViewportAsset(
dataId: string,
sourcePath: string,
manifestUrl: string,
bundleUrl: string,
context: NanoVDBViewportProjectContextIR,
signal: AbortSignal,
fetcher: typeof fetch = fetch,
): Promise<NanoVDBViewportAssetIR> {
const response = await fetcher(manifestUrl, { signal, cache: "no-store" });
if (!response.ok) throw new Error(`NANOVDB_STREAM_INCOMPLETE: manifest request returned ${response.status}`);
const received = validateNanoVDBBundleManifest(await response.json() as NanoVDBBundleManifestIR);
if (received.sourcePath !== sourcePath) throw new Error("NANOVDB_HASH_MISMATCH: manifest source path does not match the Volume binding");
const manifest = validateNanoVDBBundleManifest({ ...received, projectId: context.projectId });
const source = createResumableHttpNanoVDBRangeSource(bundleUrl, manifest.bundleByteLength, { fetcher, retries: 2, requireStableEtag: true });
const binding = await createVDBProjectBinding(manifest, context.sourceBlendSha256);
await commitNanoVDBToOPFS(manifest, source, signal, binding);
return reopenNanoVDBViewportAssetFromOPFS(dataId, sourcePath, context, signal);
}
export async function renderNanoVDBViewportAsset(
value: NanoVDBViewportAssetIR,
width = 128,
height = 128,
session?: NanoVDBViewportRenderSession,
): Promise<NanoVDBViewportRenderResultIR> {
if (session) return session.render(value, width, height);
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));
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);
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 };
}
finally {
uploaded.dispose();
probe.device.destroy();
}
}

View File

@@ -0,0 +1,94 @@
import {
validateNanoVDBBundleManifest,
type NanoVDBBundleManifestIR,
type NanoVDBGridSemantic,
type NanoVDBMaterialIR,
} from "../../../protocol/volume-vdb";
export interface PrincipledVolumeMappingInputIR {
densityGrid: string;
densityScale: number;
color?: [number, number, number];
colorGrid?: string;
temperatureGrid?: string;
temperatureScale?: number;
blackbodyEnabled?: boolean;
emissionGrid?: string;
emissionColor?: [number, number, number];
emissionScale?: number;
velocityGrid?: string;
anisotropy?: number;
interpolation?: "NEAREST" | "LINEAR";
}
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";
fallback: string;
}
export interface VolumeMaterialMappingResultIR {
material: NanoVDBMaterialIR;
losses: VolumeMaterialMappingLossIR[];
supportedSemantics: Array<"DENSITY_GRID" | "CONSTANT_COLOR" | "CONSTANT_EMISSION" | "ANISOTROPY" | "INTERPOLATION">;
}
function finite(value: number, minimum: number, maximum: number, name: string): number {
if (!Number.isFinite(value) || value < minimum || value > maximum) throw new Error(`NANOVDB_MANIFEST_INVALID: ${name}`);
return value;
}
function color(value: [number, number, number] | undefined, fallback: [number, number, number], name: string): [number, number, number] {
const result = value ?? fallback;
if (!Array.isArray(result) || result.length !== 3 || result.some((channel) => !Number.isFinite(channel) || channel < 0 || channel > 1_000_000)) throw new Error(`NANOVDB_MANIFEST_INVALID: ${name}`);
return [...result];
}
function requireGrid(manifest: NanoVDBBundleManifestIR, name: string | undefined, semantic: NanoVDBGridSemantic, field: string): 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`);
return name;
}
export function mapPrincipledVolumeToNanoVDB(
sourceManifest: NanoVDBBundleManifestIR,
input: PrincipledVolumeMappingInputIR,
): VolumeMaterialMappingResultIR {
const manifest = validateNanoVDBBundleManifest(sourceManifest);
const densityGrid = requireGrid(manifest, input.densityGrid, "DENSITY", "densityGrid");
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 material: NanoVDBMaterialIR = {
densityGrid,
...(colorGrid ? { colorGrid } : {}),
...(temperatureGrid ? { temperatureGrid } : {}),
...(emissionGrid ? { emissionGrid } : {}),
...(velocityGrid ? { velocityGrid } : {}),
densityScale: finite(input.densityScale, 0, 1_000_000, "densityScale"),
emissionScale: finite(input.emissionScale ?? 0, 0, 1_000_000, "emissionScale"),
temperatureScale: finite(input.temperatureScale ?? 1, 0, 1_000_000, "temperatureScale"),
anisotropy: finite(input.anisotropy ?? 0, -0.99, 0.99, "anisotropy"),
interpolation: input.interpolation ?? "LINEAR",
color: color(input.color, [0.72, 0.78, 0.86], "color"),
emissionColor: color(input.emissionColor, [1, 1, 1], "emissionColor"),
};
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"],
};
}