Advance WebGPU volume and bounded workflows
This commit is contained in:
88
web/app/src/volume/incremental-sha256.ts
Normal file
88
web/app/src/volume/incremental-sha256.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
99
web/app/src/volume/nanovdb-float32.ts
Normal file
99
web/app/src/volume/nanovdb-float32.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
268
web/app/src/volume/nanovdb-opfs.ts
Normal file
268
web/app/src/volume/nanovdb-opfs.ts
Normal 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 };
|
||||
}
|
||||
190
web/app/src/volume/nanovdb-stream.ts
Normal file
190
web/app/src/volume/nanovdb-stream.ts
Normal 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}`);
|
||||
};
|
||||
}
|
||||
256
web/app/src/volume/nanovdb-viewport.ts
Normal file
256
web/app/src/volume/nanovdb-viewport.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
94
web/app/src/volume/volume-material-mapping.ts
Normal file
94
web/app/src/volume/volume-material-mapping.ts
Normal 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"],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user