Files
workinf_Blender_Wasm/web/protocol/volume-vdb.ts
2026-08-14 18:08:29 -04:00

538 lines
31 KiB
TypeScript

import { normalizeProjectAssetPath } from "./asset-path";
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
import type { ErrorCode } from "./error";
import type { VolumeGridMetadataIR } from "./scene-ir";
export const VDB_PIPELINE_SCHEMA = 1;
export const VDB_MAX_RESOURCE_BYTES = 512 * 1024 * 1024;
export const VDB_MAX_ACTIVE_VOXELS = 64_000_000;
export const VDB_MAX_GRIDS = 64;
export const NANOVDB_MAX_BUNDLE_BYTES = 1024 * 1024 * 1024;
export const NANOVDB_MAX_CHUNKS = 8192;
export const NANOVDB_MAX_CHUNK_BYTES = 16 * 1024 * 1024;
export const NANOVDB_MAX_GPU_RESIDENT_BYTES = 512 * 1024 * 1024;
const ID_PATTERN = /^[a-zA-Z0-9._-]+$/;
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
const SUPPORTED_GRID_TYPES = new Set<NanoVDBGridValueType>(["FLOAT32", "FLOAT16", "VEC3F32", "VEC4F32"]);
export type VDBExecutionTarget = "DESKTOP" | "SERVER";
export type NanoVDBGridValueType = "FLOAT32" | "FLOAT16" | "VEC3F32" | "VEC4F32";
export type NanoVDBGridClass = "FOG_VOLUME" | "LEVEL_SET" | "STAGGERED" | "UNKNOWN";
export type NanoVDBGridSemantic = "DENSITY" | "TEMPERATURE" | "COLOR" | "EMISSION" | "VELOCITY" | "CUSTOM";
export type NanoVDBPipelineStage =
| "RAW_VDB_BROWSER_DECODE"
| "DESKTOP_CONVERSION"
| "SERVER_CONVERSION"
| "NANOVDB_STREAM"
| "WEBGPU_VOLUME_RENDER";
export class VDBPipelineError extends Error {
constructor(public readonly code: ErrorCode, message: string) {
super(`${code}: ${message}`);
this.name = "VDBPipelineError";
}
}
export interface VDBResourceManifest {
projectId: string;
sourcePath: string;
byteLength: number;
sha256: string;
grids: VolumeGridMetadataIR[];
}
export interface VDBConversionInput extends VDBResourceManifest {
data: ArrayBuffer;
}
export interface PreparedVDBConversionInput {
metadata: VDBResourceManifest;
data: ArrayBuffer;
}
export interface VDBConverterIdentityIR {
target: VDBExecutionTarget;
blenderVersion: string;
openVDBVersion: string;
nanoVDBVersion: string;
executableSha256: string;
}
export interface VDBConversionRequestIR {
schemaVersion: typeof VDB_PIPELINE_SCHEMA;
jobId: string;
source: VDBResourceManifest;
sourceBlendSha256?: string;
outputPath: string;
selectedGrids: string[];
quantization: "LOSSLESS" | "FP16" | "FP8";
chunkByteLength: number;
converter: VDBConverterIdentityIR;
}
export interface NanoVDBChunkIR {
index: number;
byteOffset: number;
byteLength: number;
sha256: string;
}
export interface NanoVDBGridIR {
name: string;
valueType: NanoVDBGridValueType;
gridClass: NanoVDBGridClass;
semantic: NanoVDBGridSemantic;
activeVoxelCount: number;
segmentByteOffset: number;
segmentByteLength: number;
byteOffset: number;
byteLength: number;
indexBounds: { min: [number, number, number]; max: [number, number, number] };
worldBounds: { min: [number, number, number]; max: [number, number, number] };
voxelSize: [number, number, number];
indexToWorld: [number, number, number, number, number, number, number, number, number, number, number, number, number, number, number, number];
}
export interface NanoVDBMaterialIR {
densityGrid: string;
temperatureGrid?: string;
colorGrid?: string;
emissionGrid?: string;
velocityGrid?: string;
densityScale: number;
emissionScale: number;
temperatureScale: number;
anisotropy: number;
interpolation: "NEAREST" | "LINEAR";
color?: [number, number, number];
emissionColor?: [number, number, number];
}
export interface NanoVDBGpuLayoutIR {
representation: "NANOVDB_STORAGE_BUFFER";
byteAlignment: 32;
pageByteLength: number;
maxResidentBytes: number;
shaderSemanticVersion: "volume-wgsl-v1";
float32TreeLayout?: NanoVDBFloat32TreeLayoutIR;
vec3fTreeLayout?: NanoVDBFloat32TreeLayoutIR;
}
export interface NanoVDBFloat32TreeLayoutIR {
gridDataBytes: number;
treeDataBytes: number;
treeRootOffsetOffset: number;
rootDataBytes: number;
rootTableSizeOffset: number;
rootTileBytes: number;
rootTileKeyOffset: number;
rootTileChildOffset: number;
rootTileStateOffset: number;
rootTileValueOffset: number;
upperNodeBytes: number;
upperValueMaskOffset: number;
upperChildMaskOffset: number;
upperTableOffset: number;
lowerNodeBytes: number;
lowerValueMaskOffset: number;
lowerChildMaskOffset: number;
lowerTableOffset: number;
leafNodeBytes: number;
leafValueMaskOffset: number;
leafValuesOffset: number;
}
export interface NanoVDBBundleManifestIR {
schemaVersion: typeof VDB_PIPELINE_SCHEMA;
projectId: string;
sourcePath: string;
sourceSha256: string;
conversionRequestSha256: string;
bundlePath: string;
bundleByteLength: number;
bundleSha256: string;
converter: VDBConverterIdentityIR;
grids: NanoVDBGridIR[];
chunks: NanoVDBChunkIR[];
material: NanoVDBMaterialIR;
gpu: NanoVDBGpuLayoutIR;
}
export interface VDBProjectBindingIR {
schemaVersion: typeof VDB_PIPELINE_SCHEMA;
projectId: string;
sourceBlendSha256: string;
sourcePath: string;
sourceSha256: string;
conversionRequestSha256: string;
bundleSha256: string;
bundleByteLength: number;
manifestSha256: string;
converter: VDBConverterIdentityIR;
shaderSemanticVersion: NanoVDBGpuLayoutIR["shaderSemanticVersion"];
material: NanoVDBMaterialIR;
committedAt: string;
}
export interface VDBProjectReopenContextIR {
projectId: string;
sourceBlendSha256: string;
sourcePath: string;
sourceSha256: string;
converter: VDBConverterIdentityIR;
shaderSemanticVersion: NanoVDBGpuLayoutIR["shaderSemanticVersion"];
}
export interface VDBProjectBindingStatusIR {
status: "READY" | "BLOCKED";
code?: "VDB_BINDING_MISSING" | "VDB_SOURCE_CHANGED" | "VDB_CONVERTER_CHANGED" | "NANOVDB_HASH_MISMATCH" | "VOLUME_SHADER_UNAVAILABLE";
message?: string;
}
export interface NanoVDBRangeIR {
chunkIndex: number;
start: number;
endExclusive: number;
sha256: string;
}
export interface NanoVDBPipelineContext {
desktopConverterConfigured?: boolean;
serverConverterConfigured?: boolean;
manifestValidated?: boolean;
rangeReaderAvailable?: boolean;
webgpuAvailable?: boolean;
volumeRendererAvailable?: boolean;
}
function fail(code: ErrorCode, message: string): never {
throw new VDBPipelineError(code, message);
}
function safeInteger(value: number, name: string, min: number, max: number): number {
if (!Number.isSafeInteger(value) || value < min || value > max) fail("NANOVDB_MANIFEST_INVALID", `${name} is outside the bounded integer range`);
return value;
}
function finite(value: number, name: string): number {
if (!Number.isFinite(value)) fail("NANOVDB_MANIFEST_INVALID", `${name} must be finite`);
return value;
}
function projectPath(sourcePath: string, extension: string, label: string): string {
let normalized: string;
try {
normalized = normalizeProjectAssetPath(sourcePath);
}
catch {
fail("NON_MESH_RESOURCE_OUTSIDE_PROJECT", `${label} path is outside the project asset root`);
}
if (!normalized.toLowerCase().endsWith(extension)) fail("NON_MESH_BINARY_INVALID", `${label} must use the ${extension} extension`);
return normalized;
}
function validateIdentity(value: VDBConverterIdentityIR): VDBConverterIdentityIR {
if (value.target !== "DESKTOP" && value.target !== "SERVER") fail("VDB_CONVERSION_INVALID", "Converter target is invalid");
for (const [name, version] of Object.entries({ blenderVersion: value.blenderVersion, openVDBVersion: value.openVDBVersion, nanoVDBVersion: value.nanoVDBVersion })) {
if (typeof version !== "string" || version.length === 0 || version.length > 128) fail("VDB_CONVERSION_INVALID", `${name} is invalid`);
}
if (!SHA256_PATTERN.test(value.executableSha256)) fail("VDB_CONVERSION_INVALID", "Converter executable SHA-256 is invalid");
return { ...value };
}
function validateBounds(
bounds: { min: [number, number, number]; max: [number, number, number] },
name: string,
integer: boolean,
): void {
if (!bounds || bounds.min.length !== 3 || bounds.max.length !== 3) fail("NANOVDB_MANIFEST_INVALID", `${name} bounds are invalid`);
bounds.min.forEach((value, index) => {
if (!Number.isFinite(value) || value > bounds.max[index] || (integer && (!Number.isSafeInteger(value) || !Number.isSafeInteger(bounds.max[index])))) {
fail("NANOVDB_MANIFEST_INVALID", `${name} bounds are invalid`);
}
});
}
function validateColor(value: [number, number, number] | undefined, name: string): void {
if (value === undefined) return;
if (!Array.isArray(value) || value.length !== 3 || value.some((channel) => !Number.isFinite(channel) || channel < 0 || channel > 1000000)) {
fail("NANOVDB_MANIFEST_INVALID", `${name} must contain three finite non-negative channels`);
}
}
function hex(bytes: Uint8Array): string {
return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join("");
}
async function sha256(data: ArrayBuffer): Promise<string> {
if (!globalThis.crypto?.subtle) fail("NON_MESH_BINARY_INVALID", "SHA-256 is unavailable");
return hex(new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", data)));
}
export function validateVDBManifest(manifest: VDBResourceManifest): VDBResourceManifest {
if (!manifest.projectId || !ID_PATTERN.test(manifest.projectId)) fail("NON_MESH_BINARY_INVALID", "VDB projectId is invalid");
const sourcePath = projectPath(manifest.sourcePath, ".vdb", "VDB resource");
if (!Number.isSafeInteger(manifest.byteLength) || manifest.byteLength <= 0 || manifest.byteLength > VDB_MAX_RESOURCE_BYTES) fail("NON_MESH_VDB_BUDGET_EXCEEDED", "VDB resource size is outside the bounded range");
if (!SHA256_PATTERN.test(manifest.sha256)) fail("NON_MESH_BINARY_INVALID", "VDB SHA-256 is invalid");
if (!Array.isArray(manifest.grids) || manifest.grids.length === 0 || manifest.grids.length > VDB_MAX_GRIDS) fail("NON_MESH_VDB_BUDGET_EXCEEDED", "VDB grid count is outside the bounded range");
const names = new Set<string>();
let activeVoxels = 0;
for (const grid of manifest.grids) {
if (!grid.name || names.has(grid.name) || !grid.valueType) fail("NON_MESH_BINARY_INVALID", "VDB grid identity is missing or duplicated");
names.add(grid.name);
const count = grid.activeVoxelCount ?? grid.voxelCount;
if (!Number.isSafeInteger(count) || count < 0) fail("NON_MESH_BINARY_INVALID", `VDB grid ${grid.name} has an invalid active voxel count`);
activeVoxels += count;
if (!Number.isSafeInteger(activeVoxels) || activeVoxels > VDB_MAX_ACTIVE_VOXELS) fail("NON_MESH_VDB_BUDGET_EXCEEDED", "VDB active voxel budget exceeded");
if (grid.bounds) validateBounds(grid.bounds, `VDB grid ${grid.name}`, false);
}
return { ...manifest, sourcePath, grids: manifest.grids.map((grid) => ({ ...grid })) };
}
export async function prepareVDBConversionInput(request: VDBConversionInput, signal: AbortSignal): Promise<PreparedVDBConversionInput> {
const metadata = validateVDBManifest(request);
if (signal.aborted) throw new DOMException("VDB source validation cancelled", "AbortError");
if (!(request.data instanceof ArrayBuffer) || request.data.byteLength !== metadata.byteLength) fail("NON_MESH_BINARY_INVALID", "VDB byte length does not match its manifest");
if (await sha256(request.data) !== metadata.sha256) fail("NANOVDB_HASH_MISMATCH", "VDB bytes do not match the source manifest SHA-256");
if (signal.aborted) throw new DOMException("VDB source validation cancelled", "AbortError");
return { metadata, data: request.data };
}
export function validateVDBConversionRequest(request: VDBConversionRequestIR): VDBConversionRequestIR {
if (request.schemaVersion !== VDB_PIPELINE_SCHEMA || !ID_PATTERN.test(request.jobId)) fail("VDB_CONVERSION_INVALID", "Conversion request schema or job ID is invalid");
const source = validateVDBManifest(request.source);
const outputPath = projectPath(request.outputPath, ".nvdb", "NanoVDB output");
if (request.sourceBlendSha256 !== undefined && !SHA256_PATTERN.test(request.sourceBlendSha256)) fail("VDB_CONVERSION_INVALID", "Source blend SHA-256 is invalid");
if (!Array.isArray(request.selectedGrids) || request.selectedGrids.length === 0 || request.selectedGrids.length > VDB_MAX_GRIDS) fail("VDB_CONVERSION_INVALID", "Selected grid list is invalid");
const available = new Set(source.grids.map((grid) => grid.name));
const selected = new Set<string>();
request.selectedGrids.forEach((name) => {
if (!available.has(name) || selected.has(name)) fail("VDB_CONVERSION_INVALID", `Selected grid ${name} is missing or duplicated`);
selected.add(name);
});
if (!["LOSSLESS", "FP16", "FP8"].includes(request.quantization)) fail("VDB_CONVERSION_INVALID", "NanoVDB quantization is invalid");
if (!Number.isSafeInteger(request.chunkByteLength) || request.chunkByteLength < 64 * 1024 || request.chunkByteLength > NANOVDB_MAX_CHUNK_BYTES || request.chunkByteLength % 32 !== 0) fail("VDB_CONVERSION_INVALID", "Chunk size must be 32-byte aligned and within 64 KiB to 16 MiB");
return { ...request, source, outputPath, selectedGrids: [...request.selectedGrids], converter: validateIdentity(request.converter) };
}
export function serializeVDBConversionRequest(value: VDBConversionRequestIR): string {
const request = validateVDBConversionRequest(value);
return JSON.stringify({
schemaVersion: request.schemaVersion,
source: {
byteLength: request.source.byteLength,
sha256: request.source.sha256,
grids: request.source.grids.map((grid) => ({
name: grid.name,
valueType: grid.valueType,
voxelCount: grid.voxelCount,
...(grid.activeVoxelCount === undefined ? {} : { activeVoxelCount: grid.activeVoxelCount }),
...(grid.bounds === undefined ? {} : { bounds: grid.bounds }),
})),
},
...(request.sourceBlendSha256 === undefined ? {} : { sourceBlendSha256: request.sourceBlendSha256 }),
selectedGrids: request.selectedGrids,
quantization: request.quantization,
chunkByteLength: request.chunkByteLength,
converter: request.converter,
});
}
export async function hashVDBConversionRequest(value: VDBConversionRequestIR): Promise<string> {
const encoded = new TextEncoder().encode(serializeVDBConversionRequest(value));
return sha256(encoded.buffer.slice(encoded.byteOffset, encoded.byteOffset + encoded.byteLength) as ArrayBuffer);
}
export function validateNanoVDBBundleManifest(manifest: NanoVDBBundleManifestIR): NanoVDBBundleManifestIR {
if (manifest.schemaVersion !== VDB_PIPELINE_SCHEMA || !ID_PATTERN.test(manifest.projectId)) fail("NANOVDB_MANIFEST_INVALID", "NanoVDB schema or project ID is invalid");
const sourcePath = projectPath(manifest.sourcePath, ".vdb", "VDB source");
const bundlePath = projectPath(manifest.bundlePath, ".nvdb", "NanoVDB bundle");
if (!SHA256_PATTERN.test(manifest.sourceSha256) || !SHA256_PATTERN.test(manifest.conversionRequestSha256) || !SHA256_PATTERN.test(manifest.bundleSha256)) fail("NANOVDB_MANIFEST_INVALID", "NanoVDB source, conversion request, or bundle SHA-256 is invalid");
safeInteger(manifest.bundleByteLength, "bundleByteLength", 1, NANOVDB_MAX_BUNDLE_BYTES);
const converter = validateIdentity(manifest.converter);
if (!Array.isArray(manifest.chunks) || manifest.chunks.length === 0 || manifest.chunks.length > NANOVDB_MAX_CHUNKS) fail("NANOVDB_MANIFEST_INVALID", "NanoVDB chunk count is outside the bounded range");
let nextOffset = 0;
const chunks = manifest.chunks.map((chunk, position) => {
if (chunk.index !== position || chunk.byteOffset !== nextOffset || chunk.byteOffset % 32 !== 0) fail("NANOVDB_STREAM_INCOMPLETE", `NanoVDB chunk ${position} is not contiguous or aligned`);
safeInteger(chunk.byteLength, `chunks[${position}].byteLength`, 1, NANOVDB_MAX_CHUNK_BYTES);
if (position < manifest.chunks.length - 1 && chunk.byteLength % 32 !== 0) fail("NANOVDB_STREAM_INCOMPLETE", `NanoVDB chunk ${position} length is not aligned`);
if (!SHA256_PATTERN.test(chunk.sha256)) fail("NANOVDB_MANIFEST_INVALID", `NanoVDB chunk ${position} SHA-256 is invalid`);
nextOffset += chunk.byteLength;
if (!Number.isSafeInteger(nextOffset) || nextOffset > manifest.bundleByteLength) fail("NANOVDB_STREAM_INCOMPLETE", "NanoVDB chunk ranges exceed the bundle");
return { ...chunk };
});
if (nextOffset !== manifest.bundleByteLength) fail("NANOVDB_STREAM_INCOMPLETE", "NanoVDB chunks do not cover the complete bundle");
if (!Array.isArray(manifest.grids) || manifest.grids.length === 0 || manifest.grids.length > VDB_MAX_GRIDS) fail("NANOVDB_MANIFEST_INVALID", "NanoVDB grid count is outside the bounded range");
const names = new Set<string>();
let activeVoxels = 0;
const grids = manifest.grids.map((grid) => {
if (!grid.name || names.has(grid.name)) fail("NANOVDB_MANIFEST_INVALID", "NanoVDB grid identity is missing or duplicated");
names.add(grid.name);
if (!SUPPORTED_GRID_TYPES.has(grid.valueType)) fail("NANOVDB_GRID_UNSUPPORTED", `NanoVDB grid ${grid.name} uses unsupported value type ${grid.valueType}`);
if (!["FOG_VOLUME", "LEVEL_SET", "STAGGERED", "UNKNOWN"].includes(grid.gridClass) || !["DENSITY", "TEMPERATURE", "COLOR", "EMISSION", "VELOCITY", "CUSTOM"].includes(grid.semantic)) fail("NANOVDB_MANIFEST_INVALID", `NanoVDB grid ${grid.name} class or semantic is invalid`);
safeInteger(grid.activeVoxelCount, `${grid.name}.activeVoxelCount`, 0, VDB_MAX_ACTIVE_VOXELS);
activeVoxels += grid.activeVoxelCount;
if (!Number.isSafeInteger(activeVoxels) || activeVoxels > VDB_MAX_ACTIVE_VOXELS) fail("NON_MESH_VDB_BUDGET_EXCEEDED", "NanoVDB active voxel budget exceeded");
safeInteger(grid.segmentByteOffset, `${grid.name}.segmentByteOffset`, 0, manifest.bundleByteLength - 1);
safeInteger(grid.segmentByteLength, `${grid.name}.segmentByteLength`, 1, manifest.bundleByteLength);
safeInteger(grid.byteOffset, `${grid.name}.byteOffset`, 0, manifest.bundleByteLength - 1);
safeInteger(grid.byteLength, `${grid.name}.byteLength`, 1, manifest.bundleByteLength);
if (grid.segmentByteOffset + grid.segmentByteLength > manifest.bundleByteLength || grid.byteOffset < grid.segmentByteOffset || grid.byteOffset + grid.byteLength > grid.segmentByteOffset + grid.segmentByteLength) fail("NANOVDB_MANIFEST_INVALID", `NanoVDB grid ${grid.name} segment or payload range is invalid`);
validateBounds(grid.indexBounds, `NanoVDB grid ${grid.name} index`, true);
validateBounds(grid.worldBounds, `NanoVDB grid ${grid.name} world`, false);
if (grid.voxelSize.length !== 3 || grid.voxelSize.some((value) => !Number.isFinite(value) || value <= 0)) fail("NANOVDB_MANIFEST_INVALID", `NanoVDB grid ${grid.name} voxel size is invalid`);
if (grid.indexToWorld.length !== 16 || grid.indexToWorld.some((value) => !Number.isFinite(value))) fail("NANOVDB_MANIFEST_INVALID", `NanoVDB grid ${grid.name} transform is invalid`);
return { ...grid, indexBounds: { min: [...grid.indexBounds.min], max: [...grid.indexBounds.max] }, worldBounds: { min: [...grid.worldBounds.min], max: [...grid.worldBounds.max] }, voxelSize: [...grid.voxelSize], indexToWorld: [...grid.indexToWorld] } as NanoVDBGridIR;
});
const orderedRanges = [...grids].sort((left, right) => left.segmentByteOffset - right.segmentByteOffset);
for (let index = 1; index < orderedRanges.length; index += 1) {
if (orderedRanges[index - 1].segmentByteOffset + orderedRanges[index - 1].segmentByteLength > orderedRanges[index].segmentByteOffset) fail("NANOVDB_MANIFEST_INVALID", "NanoVDB grid segments overlap");
}
const material = { ...manifest.material };
const references: Array<[keyof NanoVDBMaterialIR, NanoVDBGridSemantic]> = [
["densityGrid", "DENSITY"], ["temperatureGrid", "TEMPERATURE"], ["colorGrid", "COLOR"],
["emissionGrid", "EMISSION"], ["velocityGrid", "VELOCITY"],
];
for (const [field, semantic] of references) {
const gridName = material[field];
if (typeof gridName !== "string") continue;
const grid = grids.find((candidate) => candidate.name === gridName);
if (!grid || grid.semantic !== semantic) fail("NANOVDB_MANIFEST_INVALID", `Material ${field} does not reference a ${semantic} grid`);
}
finite(material.densityScale, "material.densityScale");
finite(material.emissionScale, "material.emissionScale");
finite(material.temperatureScale, "material.temperatureScale");
validateColor(material.color, "material.color");
validateColor(material.emissionColor, "material.emissionColor");
if (material.densityScale < 0 || material.emissionScale < 0 || material.temperatureScale < 0 || !Number.isFinite(material.anisotropy) || material.anisotropy < -0.99 || material.anisotropy > 0.99 || !["NEAREST", "LINEAR"].includes(material.interpolation)) fail("NANOVDB_MANIFEST_INVALID", "NanoVDB material parameters are invalid");
const gpu = { ...manifest.gpu };
if (gpu.representation !== "NANOVDB_STORAGE_BUFFER" || gpu.byteAlignment !== 32 || gpu.shaderSemanticVersion !== "volume-wgsl-v1") fail("NANOVDB_MANIFEST_INVALID", "NanoVDB GPU representation is unsupported");
if (!Number.isSafeInteger(gpu.pageByteLength) || gpu.pageByteLength < 64 * 1024 || gpu.pageByteLength > NANOVDB_MAX_CHUNK_BYTES || gpu.pageByteLength % 32 !== 0) fail("NANOVDB_MANIFEST_INVALID", "NanoVDB GPU page size is invalid");
if (!Number.isSafeInteger(gpu.maxResidentBytes) || gpu.maxResidentBytes < gpu.pageByteLength || gpu.maxResidentBytes > NANOVDB_MAX_GPU_RESIDENT_BYTES) fail("NANOVDB_GPU_BUDGET_EXCEEDED", "NanoVDB GPU resident budget is invalid");
if (gpu.float32TreeLayout !== undefined) {
const layout = gpu.float32TreeLayout;
const expected: NanoVDBFloat32TreeLayoutIR = {
gridDataBytes: 672, treeDataBytes: 64, treeRootOffsetOffset: 24,
rootDataBytes: 64, rootTableSizeOffset: 24, rootTileBytes: 32, rootTileKeyOffset: 0, rootTileChildOffset: 8, rootTileStateOffset: 16, rootTileValueOffset: 20,
upperNodeBytes: 270400, upperValueMaskOffset: 32, upperChildMaskOffset: 4128, upperTableOffset: 8256,
lowerNodeBytes: 33856, lowerValueMaskOffset: 32, lowerChildMaskOffset: 544, lowerTableOffset: 1088,
leafNodeBytes: 2144, leafValueMaskOffset: 16, leafValuesOffset: 96,
};
for (const [name, expectedValue] of Object.entries(expected)) if (!Number.isSafeInteger(layout[name as keyof NanoVDBFloat32TreeLayoutIR]) || layout[name as keyof NanoVDBFloat32TreeLayoutIR] !== expectedValue) fail("NANOVDB_GRID_UNSUPPORTED", `NanoVDB Float32 layout ${name} is unsupported`);
}
if (gpu.vec3fTreeLayout !== undefined) {
const layout = gpu.vec3fTreeLayout;
const expected: NanoVDBFloat32TreeLayoutIR = {
gridDataBytes: 672, treeDataBytes: 64, treeRootOffsetOffset: 24,
rootDataBytes: 96, rootTableSizeOffset: 24, rootTileBytes: 32, rootTileKeyOffset: 0, rootTileChildOffset: 8, rootTileStateOffset: 16, rootTileValueOffset: 20,
upperNodeBytes: 532544, upperValueMaskOffset: 32, upperChildMaskOffset: 4128, upperTableOffset: 8256,
lowerNodeBytes: 66624, lowerValueMaskOffset: 32, lowerChildMaskOffset: 544, lowerTableOffset: 1088,
leafNodeBytes: 6272, leafValueMaskOffset: 16, leafValuesOffset: 128,
};
for (const [name, expectedValue] of Object.entries(expected)) if (!Number.isSafeInteger(layout[name as keyof NanoVDBFloat32TreeLayoutIR]) || layout[name as keyof NanoVDBFloat32TreeLayoutIR] !== expectedValue) fail("NANOVDB_GRID_UNSUPPORTED", `NanoVDB Vec3f layout ${name} is unsupported`);
}
return { ...manifest, sourcePath, bundlePath, converter, chunks, grids, material, gpu };
}
export function validateVDBProjectBinding(value: VDBProjectBindingIR): VDBProjectBindingIR {
if (value.schemaVersion !== VDB_PIPELINE_SCHEMA || !ID_PATTERN.test(value.projectId)) fail("NANOVDB_MANIFEST_INVALID", "VDB project binding schema or project id is invalid");
const sourcePath = projectPath(value.sourcePath, ".vdb", "VDB binding source");
for (const [name, digest] of Object.entries({
sourceBlendSha256: value.sourceBlendSha256,
sourceSha256: value.sourceSha256,
conversionRequestSha256: value.conversionRequestSha256,
bundleSha256: value.bundleSha256,
manifestSha256: value.manifestSha256,
})) if (!SHA256_PATTERN.test(digest)) fail("NANOVDB_MANIFEST_INVALID", `VDB binding ${name} is invalid`);
safeInteger(value.bundleByteLength, "binding.bundleByteLength", 1, NANOVDB_MAX_BUNDLE_BYTES);
if (value.shaderSemanticVersion !== "volume-wgsl-v1") fail("NANOVDB_MANIFEST_INVALID", "VDB binding shader semantic version is unsupported");
if (typeof value.committedAt !== "string" || !Number.isFinite(Date.parse(value.committedAt))) fail("NANOVDB_MANIFEST_INVALID", "VDB binding commit timestamp is invalid");
const converter = validateIdentity(value.converter);
const synthetic: NanoVDBBundleManifestIR = {
schemaVersion: VDB_PIPELINE_SCHEMA,
projectId: value.projectId,
sourcePath,
sourceSha256: value.sourceSha256,
conversionRequestSha256: value.conversionRequestSha256,
bundlePath: "//cache/binding.nvdb",
bundleByteLength: value.bundleByteLength,
bundleSha256: value.bundleSha256,
converter,
grids: [{ name: value.material.densityGrid, valueType: "FLOAT32", gridClass: "FOG_VOLUME", semantic: "DENSITY", activeVoxelCount: 0, segmentByteOffset: 0, segmentByteLength: 1, byteOffset: 0, byteLength: 1, indexBounds: { min: [0, 0, 0], max: [0, 0, 0] }, worldBounds: { min: [0, 0, 0], max: [0, 0, 0] }, voxelSize: [1, 1, 1], indexToWorld: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] }],
chunks: [{ index: 0, byteOffset: 0, byteLength: value.bundleByteLength, sha256: value.bundleSha256 }],
material: { ...value.material, temperatureGrid: undefined, colorGrid: undefined, emissionGrid: undefined, velocityGrid: undefined },
gpu: { representation: "NANOVDB_STORAGE_BUFFER", byteAlignment: 32, pageByteLength: Math.min(NANOVDB_MAX_CHUNK_BYTES, Math.max(64 * 1024, Math.ceil(Math.min(value.bundleByteLength, NANOVDB_MAX_CHUNK_BYTES) / 32) * 32)), maxResidentBytes: NANOVDB_MAX_GPU_RESIDENT_BYTES, shaderSemanticVersion: value.shaderSemanticVersion },
};
// Reuse bounded scalar material checks without requiring all referenced grids in this binding record.
finite(synthetic.material.densityScale, "binding.material.densityScale");
finite(synthetic.material.emissionScale, "binding.material.emissionScale");
finite(synthetic.material.temperatureScale, "binding.material.temperatureScale");
validateColor(synthetic.material.color, "binding.material.color");
validateColor(synthetic.material.emissionColor, "binding.material.emissionColor");
if (synthetic.material.densityScale < 0 || synthetic.material.emissionScale < 0 || synthetic.material.temperatureScale < 0 || !Number.isFinite(synthetic.material.anisotropy) || synthetic.material.anisotropy < -0.99 || synthetic.material.anisotropy > 0.99 || !["NEAREST", "LINEAR"].includes(synthetic.material.interpolation)) fail("NANOVDB_MANIFEST_INVALID", "VDB binding material is invalid");
return { ...value, sourcePath, converter, material: { ...value.material } };
}
export function evaluateVDBProjectBinding(value: VDBProjectBindingIR | undefined, context: VDBProjectReopenContextIR): VDBProjectBindingStatusIR {
if (!value) return { status: "BLOCKED", code: "VDB_BINDING_MISSING", message: "The project has no committed NanoVDB binding" };
const binding = validateVDBProjectBinding(value);
if (binding.projectId !== context.projectId || binding.sourcePath !== projectPath(context.sourcePath, ".vdb", "VDB reopen source") || binding.sourceBlendSha256 !== context.sourceBlendSha256 || binding.sourceSha256 !== context.sourceSha256) {
return { status: "BLOCKED", code: "VDB_SOURCE_CHANGED", message: "The blend or VDB source changed after conversion" };
}
const converter = validateIdentity(context.converter);
if (serializeIdentity(binding.converter) !== serializeIdentity(converter)) return { status: "BLOCKED", code: "VDB_CONVERTER_CHANGED", message: "The VDB converter identity changed" };
if (binding.shaderSemanticVersion !== context.shaderSemanticVersion) return { status: "BLOCKED", code: "VOLUME_SHADER_UNAVAILABLE", message: "The volume shader semantic version changed" };
return { status: "READY" };
}
function serializeIdentity(value: VDBConverterIdentityIR): string {
return `${value.target}\n${value.blenderVersion}\n${value.openVDBVersion}\n${value.nanoVDBVersion}\n${value.executableSha256}`;
}
export function planNanoVDBRanges(value: NanoVDBBundleManifestIR): NanoVDBRangeIR[] {
const manifest = validateNanoVDBBundleManifest(value);
return manifest.chunks.map((chunk) => ({ chunkIndex: chunk.index, start: chunk.byteOffset, endExclusive: chunk.byteOffset + chunk.byteLength, sha256: chunk.sha256 }));
}
export async function verifyNanoVDBChunk(chunk: NanoVDBChunkIR, data: ArrayBuffer): Promise<void> {
if (!(data instanceof ArrayBuffer) || data.byteLength !== chunk.byteLength) fail("NANOVDB_STREAM_INCOMPLETE", `NanoVDB chunk ${chunk.index} byte length is incomplete`);
if (await sha256(data) !== chunk.sha256) fail("NANOVDB_HASH_MISMATCH", `NanoVDB chunk ${chunk.index} SHA-256 mismatch`);
}
export async function verifyNanoVDBBundle(manifestValue: NanoVDBBundleManifestIR, data: ArrayBuffer): Promise<void> {
const manifest = validateNanoVDBBundleManifest(manifestValue);
if (!(data instanceof ArrayBuffer) || data.byteLength !== manifest.bundleByteLength) fail("NANOVDB_STREAM_INCOMPLETE", "NanoVDB bundle byte length is incomplete");
if (await sha256(data) !== manifest.bundleSha256) fail("NANOVDB_HASH_MISMATCH", "NanoVDB bundle SHA-256 mismatch");
}
export function gateNanoVDBPipeline(stage: NanoVDBPipelineStage, context: NanoVDBPipelineContext = {}): CapabilityGateResult {
if (stage === "RAW_VDB_BROWSER_DECODE") {
return blockedGate("N-015", stage, [capabilityIssue("VDB_CONVERSION_REQUIRED", "Raw OpenVDB must be converted by the desktop or server OpenVDB toolchain; browser decoding is intentionally unavailable")]);
}
if (stage === "DESKTOP_CONVERSION") {
return context.desktopConverterConfigured
? readyGate("N-015", stage)
: blockedGate("N-015", stage, [capabilityIssue("VDB_CONVERTER_UNAVAILABLE", "The desktop OpenVDB to NanoVDB converter is not configured")]);
}
if (stage === "SERVER_CONVERSION") {
return context.serverConverterConfigured
? readyGate("N-015", stage)
: blockedGate("N-015", stage, [capabilityIssue("VDB_CONVERTER_UNAVAILABLE", "The server OpenVDB to NanoVDB job endpoint is not configured")]);
}
if (stage === "NANOVDB_STREAM") {
return context.manifestValidated && context.rangeReaderAvailable
? readyGate("N-015", stage)
: blockedGate("N-015", stage, [capabilityIssue("NANOVDB_STREAM_INCOMPLETE", "A validated NanoVDB manifest and bounded range reader are required")]);
}
if (!context.webgpuAvailable) return blockedGate("N-015", stage, [capabilityIssue("WEBGPU_RENDERER_UNAVAILABLE", "WebGPU is unavailable in this browser or device")]);
if (!context.manifestValidated || !context.rangeReaderAvailable) return blockedGate("N-015", stage, [capabilityIssue("NANOVDB_STREAM_INCOMPLETE", "Volume rendering requires a validated and readable NanoVDB stream")]);
return context.volumeRendererAvailable
? readyGate("N-015", stage)
: blockedGate("N-015", stage, [capabilityIssue("VOLUME_SHADER_UNAVAILABLE", "The NanoVDB WGSL traversal and volume material renderer have not been installed")]);
}