Complete V1 performance and OOM release gates

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

View File

@@ -0,0 +1,3 @@
export * from "../../../protocol/geometry-stream";
export { buildLODCacheKey } from "../../../protocol/lod";
export { decodeLODGeometry, encodeLODGeometry } from "../../../protocol/mesh-cache";

View File

@@ -5,6 +5,7 @@ export interface NanoVDBWebGPUCapabilityIR {
reason?: string;
maxStorageBufferBindingSize?: number;
maxBufferSize?: number;
maxStorageBuffersPerShaderStage?: number;
}
export interface NanoVDBWebGPUGrid {
@@ -15,14 +16,20 @@ export interface NanoVDBWebGPUGrid {
pageCount: number;
residentPageCount: number;
residentPageCapacity: number;
residentBytes: number;
maxResidentBytes: number;
evictionCount: number;
paged: boolean;
residentVirtualPages: readonly number[];
uploadPage(pageIndex: number, data?: ArrayBuffer): void;
touchPage(pageIndex: number): boolean;
evictPage(pageIndex: number): void;
hasResidentPage(pageIndex: number): boolean;
dispose(): void;
}
export type NanoVDBViewAxis = "X" | "Y" | "Z";
export interface NanoVDBMaterialGridUploadsIR {
temperature?: NanoVDBWebGPUGrid;
color?: NanoVDBWebGPUGrid;
@@ -156,18 +163,20 @@ function specializeFloatTraversal(prefix: string, gridName: string, pageTableNam
return source;
}
const temperatureTraversalWGSL = specializeFloatTraversal("temperature", "temperature_grid", "", "temperature");
const emissionTraversalWGSL = specializeFloatTraversal("emission", "emission_grid", "", "emission");
const temperatureTraversalWGSL = specializeFloatTraversal("temperature", "temperature_grid", "temperature_page_table", "temperature");
const emissionTraversalWGSL = specializeFloatTraversal("emission", "emission_grid", "emission_page_table", "emission");
const vec3TraversalWGSL = /* wgsl */`
fn color_in_range(byte_offset: u32, byte_length: u32) -> bool {
return byte_offset <= params.color_data_bytes && byte_length <= params.color_data_bytes - byte_offset;
}
fn color_word(byte_offset: u32) -> u32 {
if ((byte_offset & 3u) != 0u || !color_in_range(byte_offset, 4u) || params.color_page_bytes == 0u) { return 0u; }
if ((byte_offset & 3u) != 0u || !color_in_range(byte_offset, 4u)) { return 0u; }
if (params.color_paged == 0u) { return color_grid[byte_offset >> 2u]; }
if (params.color_page_bytes == 0u) { return 0u; }
let page = byte_offset / params.color_page_bytes;
if (page >= params.color_page_count) { return 0u; }
let slot = page;
let slot = color_page_table[page];
if (slot == 0xffffffffu || slot >= params.color_resident_pages) { return 0u; }
let physical = slot * params.color_page_bytes + (byte_offset % params.color_page_bytes);
if (physical > params.color_resident_pages * params.color_page_bytes - 4u) { return 0u; }
@@ -227,15 +236,18 @@ fn sample_color_linear(position: vec3<f32>) -> vec4<f32> {
}
`;
export async function probeNanoVDBWebGPU(requiredBytes = 1): Promise<{ capability: NanoVDBWebGPUCapabilityIR; adapter?: GPUAdapter; device?: GPUDevice }> {
export async function probeNanoVDBWebGPU(requiredBytes = 1, requiredStorageBuffers = 5): Promise<{ capability: NanoVDBWebGPUCapabilityIR; adapter?: GPUAdapter; device?: GPUDevice }> {
if (!navigator.gpu) return { capability: { available: false, reason: "WebGPU is unavailable" } };
const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" });
if (!adapter) return { capability: { available: false, reason: "No WebGPU adapter is available" } };
const maxStorageBufferBindingSize = Number(adapter.limits.maxStorageBufferBindingSize);
const maxBufferSize = Number(adapter.limits.maxBufferSize);
if (requiredBytes > maxStorageBufferBindingSize || requiredBytes > maxBufferSize) return { capability: { available: false, reason: "NanoVDB grid exceeds WebGPU adapter limits", maxStorageBufferBindingSize, maxBufferSize } };
const device = await adapter.requestDevice({ requiredLimits: { maxStorageBufferBindingSize: requiredBytes, maxBufferSize: requiredBytes } });
return { capability: { available: true, maxStorageBufferBindingSize, maxBufferSize }, adapter, device };
const maxStorageBuffersPerShaderStage = Number(adapter.limits.maxStorageBuffersPerShaderStage);
if (requiredBytes > maxStorageBufferBindingSize || requiredBytes > maxBufferSize || requiredStorageBuffers > maxStorageBuffersPerShaderStage) {
return { capability: { available: false, reason: "NanoVDB grid or material bindings exceed WebGPU adapter limits", maxStorageBufferBindingSize, maxBufferSize, maxStorageBuffersPerShaderStage } };
}
const device = await adapter.requestDevice({ requiredLimits: { maxStorageBufferBindingSize: requiredBytes, maxBufferSize: requiredBytes, maxStorageBuffersPerShaderStage: requiredStorageBuffers } });
return { capability: { available: true, maxStorageBufferBindingSize, maxBufferSize, maxStorageBuffersPerShaderStage }, adapter, device };
}
export function uploadNanoVDBFloat32Grid(device: GPUDevice, payload: ArrayBuffer): NanoVDBWebGPUGrid {
@@ -254,11 +266,15 @@ export function uploadNanoVDBFloat32Grid(device: GPUDevice, payload: ArrayBuffer
pageCount: 1,
residentPageCount: 1,
residentPageCapacity: 1,
residentBytes: payload.byteLength,
maxResidentBytes: payload.byteLength,
evictionCount: 0,
paged: false,
residentVirtualPages: [0],
uploadPage: (pageIndex, data) => {
if (pageIndex !== 0 || (data && data.byteLength !== payload.byteLength)) throw new Error("NANOVDB_STREAM_INCOMPLETE: direct NanoVDB grid has one immutable page");
},
touchPage: (pageIndex) => pageIndex === 0,
evictPage: () => { throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: direct NanoVDB grid cannot evict its only page"); },
hasResidentPage: (pageIndex) => pageIndex === 0,
dispose: () => { buffer.destroy(); pageTable.destroy(); },
@@ -271,61 +287,108 @@ export function uploadNanoVDBFloat32GridPaged(
pageByteLength: number,
maxResidentBytes: number,
): NanoVDBWebGPUGrid {
if (payload.byteLength === 0 || payload.byteLength % 32 !== 0 || !Number.isSafeInteger(pageByteLength) || pageByteLength < 64 * 1024 || pageByteLength % 32 !== 0) {
if (payload.byteLength === 0 || payload.byteLength % 32 !== 0) {
throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: invalid paged grid layout");
}
const grid = createNanoVDBFloat32GridPaged(device, payload.byteLength, pageByteLength, maxResidentBytes);
const initialPages = Math.min(grid.pageCount, grid.residentPageCapacity);
for (let page = 0; page < initialPages; page++) {
grid.uploadPage(page, payload.slice(page * pageByteLength, Math.min(payload.byteLength, (page + 1) * pageByteLength)));
}
return grid;
}
export function createNanoVDBFloat32GridPaged(
device: GPUDevice,
byteLength: number,
pageByteLength: number,
maxResidentBytes: number,
): NanoVDBWebGPUGrid {
if (!Number.isSafeInteger(byteLength) || byteLength <= 0 || byteLength % 32 !== 0 ||
!Number.isSafeInteger(pageByteLength) || pageByteLength < 64 * 1024 || pageByteLength % 32 !== 0) {
throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: invalid paged grid layout");
}
if (!Number.isSafeInteger(maxResidentBytes) || maxResidentBytes < pageByteLength) {
throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: invalid paged resident budget");
}
const pageCount = Math.ceil(payload.byteLength / pageByteLength);
const pageCount = Math.ceil(byteLength / pageByteLength);
const residentPageCount = Math.min(pageCount, Math.max(1, Math.floor(maxResidentBytes / pageByteLength)));
const physicalBytes = residentPageCount * pageByteLength;
if (pageCount > 8192 || physicalBytes > device.limits.maxStorageBufferBindingSize || physicalBytes > device.limits.maxBufferSize) {
throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: paged grid exceeds the resident or adapter budget");
}
const buffer = device.createBuffer({ label: "NanoVDB paged Float32 grid", size: physicalBytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
let buffer: GPUBuffer | undefined;
let pageTable: GPUBuffer | undefined;
const pageTableBytes = Math.max(4, pageCount * 4);
const pageTable = device.createBuffer({ label: "NanoVDB page table", size: pageTableBytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, mappedAtCreation: true });
const table = new Uint32Array(pageTable.getMappedRange());
table.fill(0xffffffff);
pageTable.unmap();
try {
buffer = device.createBuffer({ label: "NanoVDB paged Float32 grid", size: physicalBytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
pageTable = device.createBuffer({ label: "NanoVDB page table", size: pageTableBytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, mappedAtCreation: true });
new Uint32Array(pageTable.getMappedRange()).fill(0xffffffff);
pageTable.unmap();
}
catch (error) {
pageTable?.destroy();
buffer?.destroy();
throw error;
}
const resident = new Map<number, number>();
const upload = (pageIndex: number, data = payload.slice(pageIndex * pageByteLength, Math.min(payload.byteLength, (pageIndex + 1) * pageByteLength))): void => {
if (!Number.isSafeInteger(pageIndex) || pageIndex < 0 || pageIndex >= pageCount || data.byteLength === 0 || data.byteLength > pageByteLength) {
throw new Error("NANOVDB_STREAM_INCOMPLETE: NanoVDB page is outside the virtual grid");
}
const existingSlot = resident.get(pageIndex);
const slot = existingSlot ?? [...Array(residentPageCount).keys()].find((candidate) => !residentHasSlot(candidate));
if (slot === undefined) throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: no resident NanoVDB page slot is available");
device.queue.writeBuffer(buffer, slot * pageByteLength, data);
table[pageIndex] = slot;
const lastUsed = new Map<number, number>();
let clock = 0;
let evictions = 0;
const writePageTable = (pageIndex: number, slot: number): void => {
device.queue.writeBuffer(pageTable, pageIndex * 4, new Uint32Array([slot]));
resident.set(pageIndex, slot);
};
const residentHasSlot = (slot: number): boolean => {
for (const current of resident.values()) if (current === slot) return true;
return false;
};
const initialPages = Math.min(pageCount, residentPageCount);
for (let page = 0; page < initialPages; page++) upload(page);
const evict = (pageIndex: number, replacement = false): void => {
if (!resident.delete(pageIndex)) return;
lastUsed.delete(pageIndex);
writePageTable(pageIndex, 0xffffffff);
if (replacement) evictions++;
};
const upload = (pageIndex: number, data?: ArrayBuffer): void => {
const expectedBytes = pageIndex === pageCount - 1 ? byteLength - pageIndex * pageByteLength : pageByteLength;
if (!Number.isSafeInteger(pageIndex) || pageIndex < 0 || pageIndex >= pageCount || !(data instanceof ArrayBuffer) || data.byteLength !== expectedBytes) {
throw new Error("NANOVDB_STREAM_INCOMPLETE: NanoVDB page is outside the virtual grid");
}
const existingSlot = resident.get(pageIndex);
let slot = existingSlot ?? [...Array(residentPageCount).keys()].find((candidate) => !residentHasSlot(candidate));
if (slot === undefined) {
const oldest = [...lastUsed].sort((left, right) => left[1] - right[1] || left[0] - right[0])[0];
if (!oldest) throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: no resident NanoVDB page slot is available");
slot = resident.get(oldest[0]);
evict(oldest[0], true);
}
if (slot === undefined) throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: evicted NanoVDB page lost its physical slot");
device.queue.writeBuffer(buffer, slot * pageByteLength, data);
writePageTable(pageIndex, slot);
resident.set(pageIndex, slot);
lastUsed.set(pageIndex, ++clock);
};
return {
buffer,
pageTable,
byteLength: payload.byteLength,
byteLength,
pageByteLength,
pageCount,
residentPageCount: resident.size,
get residentPageCount() { return resident.size; },
residentPageCapacity: residentPageCount,
get residentBytes() { return resident.size * pageByteLength; },
maxResidentBytes: residentPageCount * pageByteLength,
get evictionCount() { return evictions; },
paged: true,
get residentVirtualPages() { return [...resident.keys()].sort((a, b) => a - b); },
uploadPage: upload,
evictPage: (pageIndex) => {
if (!resident.delete(pageIndex)) return;
table[pageIndex] = 0xffffffff;
device.queue.writeBuffer(pageTable, pageIndex * 4, new Uint32Array([0xffffffff]));
touchPage: (pageIndex) => {
if (!resident.has(pageIndex)) return false;
lastUsed.set(pageIndex, ++clock);
return true;
},
evictPage: evict,
hasResidentPage: (pageIndex) => resident.has(pageIndex),
dispose: () => { resident.clear(); buffer.destroy(); pageTable.destroy(); },
dispose: () => { resident.clear(); lastUsed.clear(); buffer.destroy(); pageTable.destroy(); },
};
}
@@ -398,9 +461,9 @@ export class NanoVDBWebGPUDeviceSession {
return () => this.lossListeners.delete(listener);
}
async open(requiredBytes: number): Promise<GPUDevice> {
async open(requiredBytes: number, requiredStorageBuffers = 5): Promise<GPUDevice> {
if (this.status === "disposed") throw new Error("VOLUME_SHADER_UNAVAILABLE: WebGPU session is disposed");
const probe = await probeNanoVDBWebGPU(requiredBytes);
const probe = await probeNanoVDBWebGPU(requiredBytes, requiredStorageBuffers);
if (!probe.capability.available || !probe.device) throw new Error(`VOLUME_SHADER_UNAVAILABLE: ${probe.capability.reason ?? "WebGPU unavailable"}`);
this.device = probe.device;
this.generation++;
@@ -418,9 +481,9 @@ export class NanoVDBWebGPUDeviceSession {
return this.loss;
}
async recover(requiredBytes: number): Promise<GPUDevice> {
async recover(requiredBytes: number, requiredStorageBuffers = 5): Promise<GPUDevice> {
this.device?.destroy();
return this.open(requiredBytes);
return this.open(requiredBytes, requiredStorageBuffers);
}
dispose(): void {
@@ -436,6 +499,47 @@ function paramsBuffer(device: GPUDevice, values: Uint32Array): GPUBuffer {
return buffer;
}
export async function readNanoVDBWordsWebGPU(device: GPUDevice, uploaded: NanoVDBWebGPUGrid, byteOffsets: readonly number[]): Promise<number[]> {
if (byteOffsets.length < 1 || byteOffsets.length > 4096 || byteOffsets.some((offset) => !Number.isSafeInteger(offset) || offset < 0 || offset > uploaded.byteLength - 4 || offset % 4 !== 0)) {
throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: paged word read offsets");
}
const offsets = new Uint32Array(byteOffsets);
const offsetBuffer = device.createBuffer({ size: offsets.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
device.queue.writeBuffer(offsetBuffer, 0, offsets);
const resultBytes = byteOffsets.length * 4;
const resultBuffer = device.createBuffer({ size: resultBytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC });
const readback = device.createBuffer({ size: resultBytes, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
const params = paramsBuffer(device, new Uint32Array([uploaded.byteLength, byteOffsets.length, 0, 0, uploaded.pageByteLength, uploaded.pageCount, uploaded.residentPageCapacity, uploaded.paged ? 1 : 0]));
const module = device.createShaderModule({ label: "NanoVDB paged word reader", code: /* wgsl */`
struct Params { data_bytes: u32, count: u32, width: u32, height: u32, page_bytes: u32, page_count: u32, resident_pages: u32, paged: u32 }
@group(0) @binding(0) var<storage, read> grid: array<u32>;
@group(0) @binding(1) var<storage, read> offsets: array<u32>;
@group(0) @binding(2) var<storage, read_write> results: array<u32>;
@group(0) @binding(3) var<uniform> params: Params;
@group(0) @binding(4) var<storage, read> page_table: array<u32>;
${traversalWGSL}
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
if (id.x < params.count) { results[id.x] = word(offsets[id.x]); }
}` });
const pipeline = device.createComputePipeline({ layout: "auto", compute: { module, entryPoint: "main" } });
const bindGroup = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [
{ binding: 0, resource: { buffer: uploaded.buffer } }, { binding: 1, resource: { buffer: offsetBuffer } },
{ binding: 2, resource: { buffer: resultBuffer } }, { binding: 3, resource: { buffer: params } },
{ binding: 4, resource: { buffer: uploaded.pageTable } },
] });
const encoder = device.createCommandEncoder();
const pass = encoder.beginComputePass();
pass.setPipeline(pipeline); pass.setBindGroup(0, bindGroup); pass.dispatchWorkgroups(Math.ceil(byteOffsets.length / 64)); pass.end();
encoder.copyBufferToBuffer(resultBuffer, 0, readback, 0, resultBytes);
device.queue.submit([encoder.finish()]);
await readback.mapAsync(GPUMapMode.READ);
const result = [...new Uint32Array(readback.getMappedRange().slice(0))];
readback.unmap();
offsetBuffer.destroy(); resultBuffer.destroy(); readback.destroy(); params.destroy();
return result;
}
export async function sampleNanoVDBFloat32WebGPU(device: GPUDevice, uploaded: NanoVDBWebGPUGrid, coordinates: Array<readonly [number, number, number]>): Promise<Array<{ value: number; active: boolean; valid: boolean }>> {
if (coordinates.length < 1 || coordinates.length > 4096) throw new Error("NANOVDB_GPU_BUDGET_EXCEEDED: sample count");
const coordinateData = new Int32Array(coordinates.length * 4);
@@ -486,6 +590,7 @@ export async function renderNanoVDBFloat32WebGPU(
width = 96,
height = 96,
materialGrids: NanoVDBMaterialGridUploadsIR = {},
viewAxis: NanoVDBViewAxis = "Z",
): Promise<Uint8Array> {
if (gridDefinition.valueType !== "FLOAT32" || width < 1 || height < 1 || width > 2048 || height > 2048) throw new Error("NANOVDB_GRID_UNSUPPORTED: bounded Float32 render input required");
const outputBytes = width * height * 4;
@@ -501,11 +606,13 @@ export async function renderNanoVDBFloat32WebGPU(
? [grid.byteLength, grid.pageByteLength, grid.pageCount, grid.residentPageCapacity, grid.paged ? 1 : 0, 0, 0, 0]
: [0, 0, 0, 0, 0, 0, 0, 0];
u32.set(uploadFields(materialGrids.temperature), 8);
u32[13] = viewAxis === "X" ? 0 : viewAxis === "Y" ? 1 : 2;
u32.set(uploadFields(materialGrids.color), 16);
u32.set(uploadFields(materialGrids.emission), 24);
i32.set([...gridDefinition.indexBounds.min, 0], 32);
i32.set([...gridDefinition.indexBounds.max, 0], 36);
f32.set([material.densityScale, material.emissionScale, material.anisotropy, Math.max(0.01, gridDefinition.voxelSize[2])], 40);
const rayAxis = viewAxis === "X" ? 0 : viewAxis === "Y" ? 1 : 2;
f32.set([material.densityScale, material.emissionScale, material.anisotropy, Math.max(0.01, gridDefinition.voxelSize[rayAxis])], 40);
f32.set([...(material.color ?? [0.72, 0.78, 0.86]), 1], 44);
f32.set([...(material.emissionColor ?? [1, 1, 1]), 1], 48);
f32.set([materialGrids.temperature ? 1 : 0, materialGrids.color ? 1 : 0, materialGrids.emission ? 1 : 0, material.temperatureScale], 52);
@@ -528,7 +635,7 @@ struct Params {
data_bytes: u32, interpolation: u32, width: u32, height: u32,
page_bytes: u32, page_count: u32, resident_pages: u32, paged: u32,
temperature_data_bytes: u32, temperature_page_bytes: u32, temperature_page_count: u32, temperature_resident_pages: u32,
temperature_paged: u32, temperature_pad0: u32, temperature_pad1: u32, temperature_pad2: u32,
temperature_paged: u32, view_axis: u32, temperature_pad1: u32, temperature_pad2: u32,
color_data_bytes: u32, color_page_bytes: u32, color_page_count: u32, color_resident_pages: u32,
color_paged: u32, color_pad0: u32, color_pad1: u32, color_pad2: u32,
emission_data_bytes: u32, emission_page_bytes: u32, emission_page_count: u32, emission_resident_pages: u32,
@@ -539,9 +646,12 @@ struct Params {
@group(0) @binding(1) var<storage, read_write> pixels: array<u32>;
@group(0) @binding(2) var<uniform> params: Params;
@group(0) @binding(3) var<storage, read> page_table: array<u32>;
@group(0) @binding(4) var<storage, read> temperature_grid: array<u32>;
@group(0) @binding(5) var<storage, read> color_grid: array<u32>;
@group(0) @binding(6) var<storage, read> emission_grid: array<u32>;
@group(0) @binding(4) var<storage, read> temperature_grid: array<u32>;
@group(0) @binding(5) var<storage, read> color_grid: array<u32>;
@group(0) @binding(6) var<storage, read> emission_grid: array<u32>;
@group(0) @binding(7) var<storage, read> temperature_page_table: array<u32>;
@group(0) @binding(8) var<storage, read> color_page_table: array<u32>;
@group(0) @binding(9) var<storage, read> emission_page_table: array<u32>;
${traversalWGSL}
${temperatureSource}
${colorSource}
@@ -553,39 +663,59 @@ fn blackbody_color(kelvin: f32) -> vec3<f32> {
@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
if (id.x >= params.width || id.y >= params.height) { return; }
let extent = vec2<f32>(params.index_max.xy - params.index_min.xy + vec2<i32>(1));
var plane_min = params.index_min.xy;
var plane_max = params.index_max.xy;
var ray_min = params.index_min.z;
var ray_max = params.index_max.z;
if (params.view_axis == 0u) {
plane_min = params.index_min.yz; plane_max = params.index_max.yz;
ray_min = params.index_min.x; ray_max = params.index_max.x;
} else if (params.view_axis == 1u) {
plane_min = params.index_min.xz; plane_max = params.index_max.xz;
ray_min = params.index_min.y; ray_max = params.index_max.y;
}
let extent = vec2<f32>(plane_max - plane_min + vec2<i32>(1));
let uv = (vec2<f32>(id.xy) + vec2<f32>(0.5)) / vec2<f32>(f32(params.width), f32(params.height));
let xy_position = vec2<f32>(params.index_min.xy) + uv * extent - vec2<f32>(0.5);
let xy = vec2<i32>(round(xy_position));
let z_count = max(1, params.index_max.z - params.index_min.z + 1);
let stride = max(1, (z_count + 255) / 256);
let plane_position = vec2<f32>(plane_min) + uv * extent - vec2<f32>(0.5);
let plane = vec2<i32>(round(plane_position));
let ray_count = max(1, ray_max - ray_min + 1);
let stride = max(1, (ray_count + 255) / 256);
let g = clamp(params.material.z, -0.99, 0.99);
let phase = (1.0 - g * g) / (12.5663706 * pow(max(0.0001, 1.0 + g * g), 1.5));
var transmittance = 1.0;
var radiance = vec3<f32>(0.0);
for (var z = params.index_min.z; z <= params.index_max.z; z += stride) {
var sample = sample_density(vec3<i32>(xy, z));
for (var ray = ray_min; ray <= ray_max; ray += stride) {
var coord = vec3<i32>(plane, ray);
var linear_coord = vec3<f32>(plane_position, f32(ray) + 0.5);
if (params.view_axis == 0u) {
coord = vec3<i32>(ray, plane.x, plane.y);
linear_coord = vec3<f32>(f32(ray) + 0.5, plane_position.x, plane_position.y);
} else if (params.view_axis == 1u) {
coord = vec3<i32>(plane.x, ray, plane.y);
linear_coord = vec3<f32>(plane_position.x, f32(ray) + 0.5, plane_position.y);
}
var sample = sample_density(coord);
if (params.interpolation == 1u) {
sample = sample_density_linear(vec3<f32>(xy_position, f32(z) + 0.5));
sample = sample_density_linear(linear_coord);
}
if (sample.y < 0.0) { radiance = vec3<f32>(1.0, 0.0, 1.0); transmittance = 0.0; break; }
let density = max(0.0, sample.x) * params.material.x;
let alpha = 1.0 - exp(-density * params.material.w * f32(stride));
var scattering_color = params.color.rgb;
if (params.material_grids.y > 0.5) {
var color_sample = sample_color(vec3<i32>(xy, z));
if (params.interpolation == 1u) { color_sample = sample_color_linear(vec3<f32>(xy_position, f32(z) + 0.5)); }
var color_sample = sample_color(coord);
if (params.interpolation == 1u) { color_sample = sample_color_linear(linear_coord); }
if (color_sample.w >= 0.0 && color_sample.w > 0.5) { scattering_color = max(vec3<f32>(0.0), color_sample.xyz); }
}
var emitted = params.emission_color.rgb * params.material.y;
if (params.material_grids.x > 0.5 && params.material.y > 0.0) {
var temperature_sample = temperature_sample_density(vec3<i32>(xy, z));
if (params.interpolation == 1u) { temperature_sample = temperature_sample_density_linear(vec3<f32>(xy_position, f32(z) + 0.5)); }
var temperature_sample = temperature_sample_density(coord);
if (params.interpolation == 1u) { temperature_sample = temperature_sample_density_linear(linear_coord); }
if (temperature_sample.y > 0.5) { emitted += blackbody_color(temperature_sample.x * params.material_grids.w) * params.material.y; }
}
if (params.material_grids.z > 0.5 && params.material.y > 0.0) {
var emission_sample = emission_sample_density(vec3<i32>(xy, z));
if (params.interpolation == 1u) { emission_sample = emission_sample_density_linear(vec3<f32>(xy_position, f32(z) + 0.5)); }
var emission_sample = emission_sample_density(coord);
if (params.interpolation == 1u) { emission_sample = emission_sample_density_linear(linear_coord); }
if (emission_sample.y > 0.5) { emitted += params.emission_color.rgb * max(0.0, emission_sample.x) * params.material.y; }
}
let source = scattering_color * (0.5 + 8.0 * phase) + emitted;
@@ -600,9 +730,18 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
{ binding: 0, resource: { buffer: uploaded.buffer } }, { binding: 1, resource: { buffer: output } }, { binding: 2, resource: { buffer: params } },
{ binding: 3, resource: { buffer: uploaded.pageTable } },
];
if (materialGrids.temperature) entries.push({ binding: 4, resource: { buffer: materialGrids.temperature.buffer } });
if (materialGrids.color) entries.push({ binding: 5, resource: { buffer: materialGrids.color.buffer } });
if (materialGrids.emission) entries.push({ binding: 6, resource: { buffer: materialGrids.emission.buffer } });
if (materialGrids.temperature) entries.push(
{ binding: 4, resource: { buffer: materialGrids.temperature.buffer } },
{ binding: 7, resource: { buffer: materialGrids.temperature.pageTable } },
);
if (materialGrids.color) entries.push(
{ binding: 5, resource: { buffer: materialGrids.color.buffer } },
{ binding: 8, resource: { buffer: materialGrids.color.pageTable } },
);
if (materialGrids.emission) entries.push(
{ binding: 6, resource: { buffer: materialGrids.emission.buffer } },
{ binding: 9, resource: { buffer: materialGrids.emission.pageTable } },
);
const bindGroup = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries });
const encoder = device.createCommandEncoder();
const pass = encoder.beginComputePass(); pass.setPipeline(pipeline); pass.setBindGroup(0, bindGroup); pass.dispatchWorkgroups(Math.ceil(width / 8), Math.ceil(height / 8)); pass.end();

View File

@@ -0,0 +1,323 @@
import {
SequencerValidationError,
parseSequencerTimeline,
sequencerSourceFrame,
type SequencerFrameStripIR,
type SequencerStripIR,
type SequencerTimelineIR,
} from "../../../protocol/sequencer";
export const LONG_MEDIA_SCHEMA = 1 as const;
export const LONG_MEDIA_INDEX_BUCKET_FRAMES = 1_024;
export const LONG_MEDIA_MAX_INDEX_REFERENCES = 2_000_000;
export const LONG_MEDIA_MAX_CACHE_BYTES = 256 * 1024 * 1024;
export interface LongMediaIndexStatsIR {
stripCount: number;
bucketCount: number;
referenceCount: number;
estimatedBytes: number;
}
export interface LongMediaCacheStatsIR {
entries: number;
bytes: number;
maxBytes: number;
hits: number;
misses: number;
evictions: number;
keys: string[];
}
export interface LongMediaSeekResultIR {
status: "COMPLETED" | "SUPERSEDED" | "CANCELLED";
frame: number;
strips: SequencerFrameStripIR[];
previewBytes: number;
cache: LongMediaCacheStatsIR;
}
export interface LongMediaSessionAssetIR {
sourceId: string;
sha256: string;
mimeType: "image/png" | "audio/wav";
}
export interface LongMediaSessionManifestIR {
schemaVersion: typeof LONG_MEDIA_SCHEMA;
timeline: SequencerTimelineIR;
currentFrame: number;
cacheMaxBytes: number;
assets: LongMediaSessionAssetIR[];
}
export interface LongMediaPreviewSource {
load(strip: SequencerStripIR, sourceFrame: number, signal: AbortSignal): Promise<ArrayBuffer>;
}
function cancelled(signal: AbortSignal, stage: string): void {
if (signal.aborted) throw new SequencerValidationError("SEQUENCER_CANCELLED", `Long media ${stage} was cancelled`);
}
function cacheBudget(value: number): number {
if (!Number.isSafeInteger(value) || value < 1 || value > LONG_MEDIA_MAX_CACHE_BYTES) {
throw new SequencerValidationError("SEQUENCER_BUDGET_EXCEEDED", "Long media cache byte budget is invalid");
}
return value;
}
function frameInTimeline(timeline: SequencerTimelineIR, frame: number): number {
if (!Number.isSafeInteger(frame) || frame < timeline.frameStart || frame > timeline.frameEnd) {
throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `Long media frame ${frame} is outside the timeline`);
}
return frame;
}
export class LongMediaTimelineIndex {
readonly timeline: SequencerTimelineIR;
readonly stats: LongMediaIndexStatsIR;
private readonly buckets: ReadonlyMap<number, readonly number[]>;
private constructor(timeline: SequencerTimelineIR, buckets: Map<number, number[]>, referenceCount: number) {
this.timeline = timeline;
this.buckets = buckets;
this.stats = {
stripCount: timeline.strips.length,
bucketCount: buckets.size,
referenceCount,
estimatedBytes: referenceCount * Uint32Array.BYTES_PER_ELEMENT + buckets.size * 16,
};
}
static async build(value: unknown, signal: AbortSignal): Promise<LongMediaTimelineIndex> {
const timeline = parseSequencerTimeline(value);
const buckets = new Map<number, number[]>();
let referenceCount = 0;
for (let index = 0; index < timeline.strips.length; index++) {
if (index % 256 === 0) {
await new Promise((resolve) => setTimeout(resolve, 0));
cancelled(signal, "index build");
}
const strip = timeline.strips[index];
const first = Math.floor(strip.frameStart / LONG_MEDIA_INDEX_BUCKET_FRAMES);
const last = Math.floor((strip.frameEnd - 1) / LONG_MEDIA_INDEX_BUCKET_FRAMES);
for (let bucket = first; bucket <= last; bucket++) {
referenceCount++;
if (referenceCount > LONG_MEDIA_MAX_INDEX_REFERENCES) {
throw new SequencerValidationError("SEQUENCER_BUDGET_EXCEEDED", "Long media index reference budget exceeded");
}
const entries = buckets.get(bucket) ?? [];
entries.push(index);
buckets.set(bucket, entries);
}
}
cancelled(signal, "index build");
return new LongMediaTimelineIndex(timeline, buckets, referenceCount);
}
resolve(frameValue: number, signal: AbortSignal): SequencerFrameStripIR[] {
const frame = frameInTimeline(this.timeline, frameValue);
cancelled(signal, "seek");
const bucket = Math.floor(frame / LONG_MEDIA_INDEX_BUCKET_FRAMES);
const candidates = this.buckets.get(bucket) ?? [];
const active = candidates
.map((index) => this.timeline.strips[index])
.filter((strip) => !strip.muted && frame >= strip.frameStart && frame < strip.frameEnd);
const activeIds = new Set(active.map((strip) => strip.id));
const hiddenByMeta = new Set(active.filter((strip) => strip.type === "META").flatMap((strip) => strip.childStripIds ?? []));
const result = active.filter((strip) => !hiddenByMeta.has(strip.id)).map((strip): SequencerFrameStripIR => {
const dependencies = [...(strip.inputStripIds ?? []), ...(strip.childStripIds ?? [])];
if (dependencies.some((id) => !activeIds.has(id))) {
throw new SequencerValidationError("SEQUENCER_RESOURCE_MISSING", `${strip.id} has an inactive long-media dependency`);
}
return { stripId: strip.id, channel: strip.channel, sourceFrame: sequencerSourceFrame(strip, frame), dependencyStripIds: dependencies };
});
cancelled(signal, "seek");
return result.sort((left, right) => left.channel - right.channel || left.stripId.localeCompare(right.stripId));
}
}
export class LongMediaPreviewCache {
readonly maxBytes: number;
private readonly entries = new Map<string, ArrayBuffer>();
private currentBytes = 0;
private hitCount = 0;
private missCount = 0;
private evictionCount = 0;
constructor(maxBytes: number) {
this.maxBytes = cacheBudget(maxBytes);
}
get(key: string): ArrayBuffer | undefined {
const entry = this.entries.get(key);
if (!entry) { this.missCount++; return undefined; }
this.hitCount++;
this.entries.delete(key);
this.entries.set(key, entry);
return entry.slice(0);
}
set(key: string, data: ArrayBuffer): void {
if (!(data instanceof ArrayBuffer) || data.byteLength === 0 || data.byteLength > this.maxBytes) {
throw new SequencerValidationError("SEQUENCER_BUDGET_EXCEEDED", "Long media preview exceeds the cache byte budget");
}
const copy = data.slice(0);
const previous = this.entries.get(key);
if (previous) { this.currentBytes -= previous.byteLength; this.entries.delete(key); }
while (this.currentBytes + copy.byteLength > this.maxBytes) {
const oldest = this.entries.entries().next().value as [string, ArrayBuffer] | undefined;
if (!oldest) break;
this.entries.delete(oldest[0]);
this.currentBytes -= oldest[1].byteLength;
this.evictionCount++;
}
this.entries.set(key, copy);
this.currentBytes += copy.byteLength;
}
clear(): void {
this.entries.clear();
this.currentBytes = 0;
}
stats(): LongMediaCacheStatsIR {
return {
entries: this.entries.size,
bytes: this.currentBytes,
maxBytes: this.maxBytes,
hits: this.hitCount,
misses: this.missCount,
evictions: this.evictionCount,
keys: [...this.entries.keys()],
};
}
}
export class LongMediaTimelineSession {
private generation = 0;
private controller: AbortController | null = null;
private explicitlyCancelledGeneration: number | null = null;
readonly cache: LongMediaPreviewCache;
constructor(
private readonly index: LongMediaTimelineIndex,
private readonly source: LongMediaPreviewSource,
maxCacheBytes: number,
private readonly publish: (result: LongMediaSeekResultIR) => void = () => undefined,
) {
this.cache = new LongMediaPreviewCache(maxCacheBytes);
}
cancel(): void {
if (!this.controller) return;
this.explicitlyCancelledGeneration = this.generation;
this.controller.abort();
}
async seek(frame: number): Promise<LongMediaSeekResultIR> {
this.controller?.abort();
const generation = ++this.generation;
this.explicitlyCancelledGeneration = null;
const controller = new AbortController();
this.controller = controller;
try {
const strips = this.index.resolve(frame, controller.signal);
let previewBytes = 0;
for (const resolved of strips) {
const strip = this.index.timeline.strips.find((candidate) => candidate.id === resolved.stripId)!;
if (!strip.sourceId || !["IMAGE", "SOUND", "MOVIE"].includes(strip.type)) continue;
const key = `${strip.sourceId}:${resolved.sourceFrame}`;
let preview = this.cache.get(key);
if (!preview) {
preview = await this.source.load(strip, resolved.sourceFrame, controller.signal);
if (!this.isCurrent(generation, controller)) return this.interrupted(frame, generation);
this.cache.set(key, preview);
}
previewBytes += preview.byteLength;
}
if (!this.isCurrent(generation, controller)) return this.interrupted(frame, generation);
const result: LongMediaSeekResultIR = { status: "COMPLETED", frame, strips, previewBytes, cache: this.cache.stats() };
this.publish(result);
return result;
}
catch (error) {
if (controller.signal.aborted || error instanceof SequencerValidationError && error.code === "SEQUENCER_CANCELLED") {
return this.interrupted(frame, generation);
}
throw error;
}
finally {
if (this.generation === generation) this.controller = null;
}
}
dispose(): void {
this.cancel();
this.cache.clear();
}
private isCurrent(generation: number, controller: AbortController): boolean {
return generation === this.generation && this.controller === controller && !controller.signal.aborted;
}
private interrupted(frame: number, generation: number): LongMediaSeekResultIR {
return {
status: this.explicitlyCancelledGeneration === generation ? "CANCELLED" : "SUPERSEDED",
frame,
strips: [],
previewBytes: 0,
cache: this.cache.stats(),
};
}
}
const SHA256 = /^[a-f0-9]{64}$/;
export function parseLongMediaSessionManifest(value: unknown): LongMediaSessionManifestIR {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", "Long media session manifest must be an object");
const input = value as Record<string, unknown>;
if (input.schemaVersion !== LONG_MEDIA_SCHEMA || !Array.isArray(input.assets)) throw new SequencerValidationError("PROTOCOL_MISMATCH", "Unsupported long media session schema");
const timeline = parseSequencerTimeline(input.timeline);
const currentFrame = frameInTimeline(timeline, input.currentFrame as number);
const cacheMaxBytes = cacheBudget(input.cacheMaxBytes as number);
const sourceIds = new Set<string>();
const assets = input.assets.map((value, index): LongMediaSessionAssetIR => {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `Long media asset ${index} is invalid`);
const asset = value as Record<string, unknown>;
if (typeof asset.sourceId !== "string" || asset.sourceId.length === 0 || asset.sourceId.length > 128 || sourceIds.has(asset.sourceId) ||
typeof asset.sha256 !== "string" || !SHA256.test(asset.sha256) || (asset.mimeType !== "image/png" && asset.mimeType !== "audio/wav")) {
throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", `Long media asset ${index} is invalid`);
}
sourceIds.add(asset.sourceId);
return { sourceId: asset.sourceId, sha256: asset.sha256, mimeType: asset.mimeType };
});
for (const strip of timeline.strips) {
if (["IMAGE", "SOUND"].includes(strip.type) && strip.sourceId && !sourceIds.has(strip.sourceId)) {
throw new SequencerValidationError("SEQUENCER_RESOURCE_MISSING", `${strip.id} has no persisted long media asset`);
}
}
return { schemaVersion: LONG_MEDIA_SCHEMA, timeline, currentFrame, cacheMaxBytes, assets };
}
export function serializeLongMediaSessionManifest(value: LongMediaSessionManifestIR): ArrayBuffer {
const parsed = parseLongMediaSessionManifest(value);
return new TextEncoder().encode(JSON.stringify(parsed)).buffer as ArrayBuffer;
}
export function deserializeLongMediaSessionManifest(data: ArrayBuffer): LongMediaSessionManifestIR {
if (!(data instanceof ArrayBuffer) || data.byteLength === 0 || data.byteLength > 64 * 1024 * 1024) {
throw new SequencerValidationError("SEQUENCER_BUDGET_EXCEEDED", "Long media session manifest byte size is invalid");
}
try { return parseLongMediaSessionManifest(JSON.parse(new TextDecoder().decode(data))); }
catch (error) {
if (error instanceof SequencerValidationError) throw error;
throw new SequencerValidationError("SEQUENCER_SCHEMA_INVALID", "Long media session manifest JSON is invalid");
}
}
export async function buildLongMediaTimelineIndex(value: unknown, signal: AbortSignal): Promise<LongMediaTimelineIndex> {
return LongMediaTimelineIndex.build(value, signal);
}
export { gateSequencerCodec, sequencerRuntimeCapabilities } from "../../../protocol/sequencer";

View File

@@ -274,7 +274,13 @@ export async function writeProjectBlend(
stageName,
createdAt: new Date().toISOString(),
};
await writeFile(project, stageName, data);
try {
await writeFile(project, stageName, data);
}
catch (error) {
await removeFile(project, stageName);
throw error;
}
if (!await verifyFile(project, stageName, journal.bytes, journal.sha256)) {
throw new Error("PROJECT_SAVE_STAGE_VERIFY_FAILED: staged blend does not match its digest");
}

View File

@@ -0,0 +1,156 @@
import { OOM_FAULT_ERROR, type OOMFaultObservationIR, type OOMFaultPoint } from "../../../protocol/oom-recovery";
export interface OOMFaultConfiguration {
point: OOMFaultPoint;
failAfterBytes?: number;
failAfterCount?: number;
}
export interface OOMFaultSessionStats {
active: boolean;
currentBytes: number;
peakBytes: number;
liveResources: number;
peakResources: number;
releasedBytes: number;
allocationCount: number;
matchingAllocationCount: number;
unauthorizedAttempts: number;
triggered: boolean;
}
export interface OOMAllocationLease {
readonly id: string;
readonly bytes: number;
readonly tracked: boolean;
release(): void;
}
export class OOMFaultInjectedError extends Error {
readonly name = "OOMFaultInjectedError";
readonly observation: OOMFaultObservationIR;
constructor(observation: OOMFaultObservationIR) {
super(`${observation.code}: deterministic allocation failure at ${observation.stage}`);
this.observation = observation;
}
}
export class OOMFaultSessionAccessError extends Error {
readonly name = "OOMFaultSessionAccessError";
}
function threshold(value: number | undefined, label: string): number | undefined {
if (value === undefined) return undefined;
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`OOM_FAULT_CONFIG_INVALID: ${label}`);
return value;
}
export class OOMFaultSession {
private readonly token: string;
private readonly configuration: OOMFaultConfiguration;
private readonly resources = new Map<string, number>();
private active = true;
private currentBytes = 0;
private peakBytes = 0;
private peakResources = 0;
private releasedBytes = 0;
private allocationCount = 0;
private matchingAllocationCount = 0;
private matchingBytes = 0;
private unauthorizedAttempts = 0;
private triggered = false;
constructor(configuration: OOMFaultConfiguration, token: string) {
if (!OOM_FAULT_ERROR[configuration.point]) throw new Error("OOM_FAULT_CONFIG_INVALID: point");
const failAfterBytes = threshold(configuration.failAfterBytes, "failAfterBytes");
const failAfterCount = threshold(configuration.failAfterCount, "failAfterCount");
if (failAfterBytes === undefined && failAfterCount === undefined) throw new Error("OOM_FAULT_CONFIG_INVALID: threshold required");
if (!/^[a-f0-9-]{36,128}$/.test(token)) throw new Error("OOM_FAULT_CONFIG_INVALID: token");
this.configuration = { ...configuration, failAfterBytes, failAfterCount };
this.token = token;
}
reserve(token: string, point: OOMFaultPoint, bytes: number, resourceId: string = crypto.randomUUID()): OOMAllocationLease {
if (!this.active) return { id: resourceId, bytes, tracked: false, release: () => undefined };
if (token !== this.token) {
this.unauthorizedAttempts++;
throw new OOMFaultSessionAccessError("OOM_FAULT_TOKEN_MISMATCH: fault session token is isolated");
}
if (!Number.isSafeInteger(bytes) || bytes < 0 || !resourceId || this.resources.has(resourceId)) throw new Error("OOM_FAULT_ALLOCATION_INVALID");
this.allocationCount++;
if (point === this.configuration.point) {
const nextCount = this.matchingAllocationCount + 1;
const nextBytes = this.matchingBytes + bytes;
const countExceeded = this.configuration.failAfterCount !== undefined && this.matchingAllocationCount >= this.configuration.failAfterCount;
const bytesExceeded = this.configuration.failAfterBytes !== undefined && nextBytes > this.configuration.failAfterBytes;
this.matchingAllocationCount = nextCount;
this.matchingBytes = nextBytes;
if (countExceeded || bytesExceeded) {
this.triggered = true;
const mapping = OOM_FAULT_ERROR[point];
throw new OOMFaultInjectedError({
point,
...mapping,
attemptedBytes: bytes,
allocationCount: nextCount,
failAfterBytes: this.configuration.failAfterBytes,
failAfterCount: this.configuration.failAfterCount,
});
}
}
this.resources.set(resourceId, bytes);
this.currentBytes += bytes;
this.peakBytes = Math.max(this.peakBytes, this.currentBytes);
this.peakResources = Math.max(this.peakResources, this.resources.size);
let released = false;
return {
id: resourceId,
bytes,
tracked: true,
release: () => {
if (released) return;
released = true;
const allocated = this.resources.get(resourceId);
if (allocated === undefined) return;
this.resources.delete(resourceId);
this.currentBytes -= allocated;
this.releasedBytes += allocated;
},
};
}
close(token: string): OOMFaultSessionStats {
if (this.active && token !== this.token) {
this.unauthorizedAttempts++;
throw new OOMFaultSessionAccessError("OOM_FAULT_TOKEN_MISMATCH: fault session token is isolated");
}
this.active = false;
return this.stats();
}
stats(): OOMFaultSessionStats {
return {
active: this.active,
currentBytes: this.currentBytes,
peakBytes: this.peakBytes,
liveResources: this.resources.size,
peakResources: this.peakResources,
releasedBytes: this.releasedBytes,
allocationCount: this.allocationCount,
matchingAllocationCount: this.matchingAllocationCount,
unauthorizedAttempts: this.unauthorizedAttempts,
triggered: this.triggered,
};
}
}
export function beginOOMFaultSession(configuration: OOMFaultConfiguration): { session: OOMFaultSession; token: string } {
const token = `${crypto.randomUUID()}-${crypto.randomUUID()}`;
return { session: new OOMFaultSession(configuration, token), token };
}
export function assertFault(error: unknown, point: OOMFaultPoint): OOMFaultObservationIR {
if (!(error instanceof OOMFaultInjectedError) || error.observation.point !== point) throw error;
return error.observation;
}

View File

@@ -0,0 +1,453 @@
import {
OOM_RECOVERY_REPORT_SCHEMA,
parseOOMRecoverySuite,
type OOMFaultObservationIR,
type OOMFaultPoint,
type OOMRecoveryReportIR,
} from "../../../protocol/oom-recovery";
import { WebEngineClient } from "../engine-client/WebEngineClient";
import {
readProjectBlend,
recoverProjectBlend,
writeProjectBlend,
} from "../storage/opfs-files";
import { createNanoVDBFloat32GridPaged } from "../render/nanovdb-volume-renderer";
import {
BoxGeometry,
BufferGeometry,
Float32BufferAttribute,
Mesh,
MeshBasicMaterial,
PerspectiveCamera,
Scene,
WebGLRenderer,
} from "../vendor/three/three.module.js";
import {
assertFault,
beginOOMFaultSession,
OOMFaultSessionAccessError,
type OOMAllocationLease,
type OOMFaultSession,
} from "./oom-fault-session";
export { parseOOMRecoverySuite } from "../../../protocol/oom-recovery";
type OpfsStorage = StorageManager & { getDirectory?: () => Promise<FileSystemDirectoryHandle> };
type DirectoryEntries = AsyncIterableIterator<[string, FileSystemHandle]>;
type TestGPUBufferDescriptor = Parameters<GPUDevice["createBuffer"]>[0];
async function sha256(value: ArrayBuffer | string): Promise<string> {
const bytes = typeof value === "string" ? new TextEncoder().encode(value) : new Uint8Array(value);
const digest = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
function fault(session: OOMFaultSession, token: string, point: OOMFaultPoint, bytes: number): OOMFaultObservationIR {
try {
session.reserve(token, point, bytes, `${point.toLowerCase()}-failure`);
}
catch (error) {
return assertFault(error, point);
}
throw new Error(`OOM_FAULT_NOT_TRIGGERED: ${point}`);
}
function proveTokenIsolation(session: OOMFaultSession, token: string, point: OOMFaultPoint): boolean {
try {
session.reserve(`${token}-unauthorized`, point, 1, "unauthorized-allocation");
}
catch (error) {
return error instanceof OOMFaultSessionAccessError;
}
return false;
}
async function runWasmMainScenario(input: ArrayBuffer): Promise<OOMRecoveryReportIR> {
const engine = new WebEngineClient({ timeoutMs: 30_000 });
const restarted = new WebEngineClient({ timeoutMs: 30_000 });
const faults: OOMFaultObservationIR[] = [];
let openIsolation: boolean;
let editIsolation: boolean;
let saveIsolation: boolean;
let nativeBefore: number;
let nativeAfter: number;
let saveBytes: number;
let revisionBefore: number;
let revisionAfter: number;
let hashBefore: string;
let hashAfter: string;
try {
const initialized = await engine.init();
if (!initialized.ready || initialized.liveHandles !== 1) throw new Error("WASM OOM fixture did not initialize one clean handle");
const open = beginOOMFaultSession({ point: "WASM_MAIN_OPEN_INPUT", failAfterCount: 0 });
openIsolation = proveTokenIsolation(open.session, open.token, "WASM_MAIN_OPEN_INPUT");
faults.push(fault(open.session, open.token, "WASM_MAIN_OPEN_INPUT", input.byteLength));
const openStats = open.session.close(open.token);
if (!openStats.triggered || openStats.liveResources !== 0) throw new Error("WASM open fault leaked a test allocation");
const disabledLease = open.session.reserve(open.token, "WASM_MAIN_OPEN_INPUT", input.byteLength, "closed-session-probe");
if (disabledLease.tracked) throw new Error("Closed OOM session leaked into the next request");
const opened = await engine.openBlend(input.slice(0));
nativeBefore = opened.status.allocatedBytes;
const created = await engine.applyCommand({ type: "createPrimitive", primitive: "CUBE", name: "OOMRecoveryCube", location: [2, 0, 0] });
const createdObject = created.snapshot.nodes.find((node) => node.name === "OOMRecoveryCube");
if (!createdObject) throw new Error("WASM OOM fixture could not establish an undoable Main revision");
revisionBefore = created.snapshot.revision;
hashBefore = await sha256(JSON.stringify(created.snapshot));
const edit = beginOOMFaultSession({ point: "WASM_MAIN_EDIT_COMMAND", failAfterBytes: 0 });
editIsolation = proveTokenIsolation(edit.session, edit.token, "WASM_MAIN_EDIT_COMMAND");
const editBytes = new TextEncoder().encode(JSON.stringify({ type: "setFrame", frame: 21 })).byteLength;
faults.push(fault(edit.session, edit.token, "WASM_MAIN_EDIT_COMMAND", editBytes));
const editStats = edit.session.close(edit.token);
if (!editStats.triggered || editStats.liveResources !== 0) throw new Error("WASM edit fault leaked a test allocation");
const unchanged = await engine.snapshot();
revisionAfter = unchanged.snapshot.revision;
hashAfter = await sha256(JSON.stringify(unchanged.snapshot));
if (revisionAfter !== revisionBefore || hashAfter !== hashBefore) throw new Error("WASM edit OOM changed Main state");
const undone = await engine.applyCommand({ type: "undo" });
if (undone.snapshot.nodes.some((node) => node.id === createdObject.id)) throw new Error("WASM edit OOM damaged the undo stack");
const redone = await engine.applyCommand({ type: "redo" });
if (!redone.snapshot.nodes.some((node) => node.id === createdObject.id)) throw new Error("WASM edit OOM damaged the redo stack");
const firstSave = await engine.saveBlend();
saveBytes = firstSave.byteLength;
const save = beginOOMFaultSession({ point: "WASM_MAIN_SAVE_RESULT", failAfterBytes: Math.max(0, saveBytes - 1) });
saveIsolation = proveTokenIsolation(save.session, save.token, "WASM_MAIN_SAVE_RESULT");
faults.push(fault(save.session, save.token, "WASM_MAIN_SAVE_RESULT", saveBytes));
const saveStats = save.session.close(save.token);
if (!saveStats.triggered || saveStats.liveResources !== 0) throw new Error("WASM save fault leaked a test allocation");
const afterSaveFault = await engine.snapshot();
if (!afterSaveFault.snapshot.nodes.some((node) => node.id === createdObject.id)) throw new Error("WASM save OOM changed Main state");
nativeAfter = afterSaveFault.status.allocatedBytes;
const saved = await engine.saveBlend();
engine.terminate();
const reopened = await restarted.openBlend(saved);
if (!reopened.snapshot.nodes.some((node) => node.id === createdObject.id)) throw new Error("WASM OOM restart lost the last valid Main");
}
finally {
engine.terminate();
restarted.terminate();
}
const peakBytes = Math.max(nativeBefore, nativeAfter, nativeBefore + saveBytes);
return {
schemaVersion: OOM_RECOVERY_REPORT_SCHEMA,
scenario: "WASM_MAIN",
faults,
memory: { beforeBytes: nativeBefore, peakBytes, afterBytes: nativeAfter, releasedBytes: saveBytes },
state: { revisionBefore, revisionAfter, hashBefore, hashAfter, temporaryResourcesBefore: 0, temporaryResourcesPeak: 0, temporaryResourcesAfter: 0 },
recovery: { recovered: true, sameSession: true, restartedSession: true, tokenIsolated: openIsolation && editIsolation && saveIsolation },
checks: ["open-handle-clean", "main-revision-stable", "undo-redo-stable", "save-buffer-released", "worker-reopen"],
};
}
function wrapStageOOMFile(
file: FileSystemFileHandle,
session: OOMFaultSession,
token: string,
): FileSystemFileHandle {
return new Proxy(file, {
get(target, property) {
if (property === "createWritable") {
return async (...args: Parameters<FileSystemFileHandle["createWritable"]>) => {
const writable = await target.createWritable(...args);
return new Proxy(writable, {
get(stream, streamProperty) {
if (streamProperty === "write") {
return async (value: FileSystemWriteChunkType) => {
if (!(value instanceof ArrayBuffer)) return stream.write(value);
const firstLength = Math.max(1, Math.floor(value.byteLength / 2));
let lease: OOMAllocationLease | undefined;
try {
lease = session.reserve(token, "OPFS_STAGING_WRITE", firstLength, "opfs-partial-stage");
await stream.write(value.slice(0, firstLength));
session.reserve(token, "OPFS_STAGING_WRITE", value.byteLength - firstLength, "opfs-stage-remainder");
}
catch (error) {
await stream.abort(error).catch(() => undefined);
lease?.release();
throw error;
}
throw new Error("OPFS OOM injector failed to stop the staging write");
};
}
const current = Reflect.get(stream, streamProperty, stream);
return typeof current === "function" ? current.bind(stream) : current;
},
});
};
}
const current = Reflect.get(target, property, target);
return typeof current === "function" ? current.bind(target) : current;
},
});
}
function wrapStageOOMDirectory(
directory: FileSystemDirectoryHandle,
session: OOMFaultSession,
token: string,
): FileSystemDirectoryHandle {
return new Proxy(directory, {
get(target, property) {
if (property === "getDirectoryHandle") {
return async (...args: Parameters<FileSystemDirectoryHandle["getDirectoryHandle"]>) =>
wrapStageOOMDirectory(await target.getDirectoryHandle(...args), session, token);
}
if (property === "getFileHandle") {
return async (...args: Parameters<FileSystemDirectoryHandle["getFileHandle"]>) => {
const file = await target.getFileHandle(...args);
return args[0].endsWith(".stage") ? wrapStageOOMFile(file, session, token) : file;
};
}
const current = Reflect.get(target, property, target);
return typeof current === "function" ? current.bind(target) : current;
},
});
}
function stageOOMStorage(session: OOMFaultSession, token: string): StorageManager {
const storage = navigator.storage as OpfsStorage;
if (!storage.getDirectory) throw new Error("OPFS is unavailable for OOM recovery");
return new Proxy(storage, {
get(target, property) {
if (property === "getDirectory") return async () => wrapStageOOMDirectory(await target.getDirectory!(), session, token);
const current = Reflect.get(target, property, target);
return typeof current === "function" ? current.bind(target) : current;
},
});
}
async function projectTemporaryResources(projectId: string): Promise<number> {
const storage = navigator.storage as OpfsStorage;
if (!storage.getDirectory) throw new Error("OPFS is unavailable for OOM recovery");
let directory = await storage.getDirectory();
for (const name of ["projects", projectId]) directory = await directory.getDirectoryHandle(name);
let count = 0;
const entries = (directory as unknown as { entries: () => DirectoryEntries }).entries();
for await (const [name] of entries) if (name.endsWith(".stage")) count++;
const tmp = await directory.getDirectoryHandle("tmp");
const tmpEntries = (tmp as unknown as { entries: () => DirectoryEntries }).entries();
for await (const [name] of tmpEntries) if (name.endsWith(".journal.json") || name.endsWith(".stage") || name.endsWith(".tmp")) count++;
return count;
}
async function removeProject(projectId: string): Promise<void> {
const storage = navigator.storage as OpfsStorage;
if (!storage.getDirectory) return;
const root = await storage.getDirectory();
try { await (await root.getDirectoryHandle("projects")).removeEntry(projectId, { recursive: true }); }
catch (error) { if (!(error instanceof DOMException) || error.name !== "NotFoundError") throw error; }
}
async function runOpfsScenario(input: ArrayBuffer): Promise<OOMRecoveryReportIR> {
const projectId = `oom-opfs-${crypto.randomUUID()}`;
const oldBytes = input.slice(0);
const newBytes = input.slice(0);
const view = new Uint8Array(newBytes);
view[view.byteLength - 1] ^= 0x01;
const oldHash = await sha256(oldBytes);
let afterHash: string;
let temporaryBefore: number;
let temporaryAfter: number;
let observation: OOMFaultObservationIR | undefined;
let isolated: boolean;
let peakBytes: number;
try {
await writeProjectBlend(projectId, 7, oldBytes.slice(0));
temporaryBefore = await projectTemporaryResources(projectId);
const started = beginOOMFaultSession({ point: "OPFS_STAGING_WRITE", failAfterBytes: Math.max(1, Math.floor(newBytes.byteLength / 2)) });
isolated = proveTokenIsolation(started.session, started.token, "OPFS_STAGING_WRITE");
try {
await writeProjectBlend(projectId, 8, newBytes, stageOOMStorage(started.session, started.token));
throw new Error("OPFS staging OOM did not reject the write");
}
catch (error) {
observation = assertFault(error, "OPFS_STAGING_WRITE");
}
const stats = started.session.close(started.token);
peakBytes = stats.peakBytes;
if (!stats.triggered || stats.currentBytes !== 0 || stats.liveResources !== 0) throw new Error("OPFS staging OOM leaked its partial allocation");
const stored = await readProjectBlend(projectId);
afterHash = await sha256(stored);
const recovered = await recoverProjectBlend(projectId);
temporaryAfter = await projectTemporaryResources(projectId);
if (oldHash !== afterHash || recovered.status !== "clean" || recovered.manifest?.revision !== 7) throw new Error("OPFS staging OOM replaced the last committed revision");
if (temporaryAfter !== 0) throw new Error("OPFS staging OOM left temporary files");
const next = await writeProjectBlend(projectId, 8, oldBytes.slice(0));
if (next.manifest.revision !== 8) throw new Error("OPFS did not accept a save after OOM recovery");
}
finally {
await removeProject(projectId);
}
if (!observation) throw new Error("OPFS OOM observation is missing");
return {
schemaVersion: OOM_RECOVERY_REPORT_SCHEMA,
scenario: "OPFS_STAGING",
faults: [observation],
memory: { beforeBytes: 0, peakBytes, afterBytes: 0, releasedBytes: peakBytes },
state: { revisionBefore: 7, revisionAfter: 7, hashBefore: oldHash, hashAfter: afterHash, temporaryResourcesBefore: temporaryBefore, temporaryResourcesPeak: Math.max(1, temporaryBefore), temporaryResourcesAfter: temporaryAfter },
recovery: { recovered: true, sameSession: true, restartedSession: true, tokenIsolated: isolated },
checks: ["partial-stage-aborted", "journal-not-committed", "old-revision-preserved", "old-hash-preserved", "temporary-files-removed", "next-save-succeeds"],
};
}
function readPixels(renderer: WebGLRenderer): ArrayBuffer {
const pixels = new Uint8Array(64 * 64 * 4);
renderer.getContext().readPixels(0, 0, 64, 64, renderer.getContext().RGBA, renderer.getContext().UNSIGNED_BYTE, pixels);
return pixels.buffer;
}
async function runGpuScenario(): Promise<OOMRecoveryReportIR> {
const canvas = document.createElement("canvas");
const renderer = new WebGLRenderer({ canvas, antialias: false, preserveDrawingBuffer: true });
renderer.setSize(64, 64, false);
const scene = new Scene();
const camera = new PerspectiveCamera(50, 1, 0.1, 100);
camera.position.z = 4;
const baselineGeometry = new BoxGeometry(1, 1, 1);
const baselineMaterial = new MeshBasicMaterial({ color: 0x33aa66 });
const baseline = new Mesh(baselineGeometry, baselineMaterial);
scene.add(baseline);
renderer.render(scene, camera);
const hashBefore = await sha256(readPixels(renderer));
const started = beginOOMFaultSession({ point: "GPU_TEXTURE_UPLOAD", failAfterCount: 0 });
const isolated = proveTokenIsolation(started.session, started.token, "GPU_TEXTURE_UPLOAD");
let geometry: BufferGeometry | undefined;
let material: MeshBasicMaterial | undefined;
let geometryLease: OOMAllocationLease | undefined;
let observation: OOMFaultObservationIR | undefined;
try {
const positions = new Float32Array([-0.5, -0.5, 0, 0.5, -0.5, 0, 0, 0.5, 0]);
geometryLease = started.session.reserve(started.token, "GPU_GEOMETRY_UPLOAD", positions.byteLength, "gpu-partial-geometry");
geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
material = new MeshBasicMaterial({ color: 0xffffff });
started.session.reserve(started.token, "GPU_TEXTURE_UPLOAD", 4 * 1024 * 1024, "gpu-failed-texture");
scene.add(new Mesh(geometry, material));
throw new Error("GPU texture OOM did not stop the atomic scene object");
}
catch (error) {
observation = assertFault(error, "GPU_TEXTURE_UPLOAD");
geometry?.dispose();
material?.dispose();
geometryLease?.release();
}
const stats = started.session.close(started.token);
if (!stats.triggered || stats.liveResources !== 0) throw new Error("GPU OOM leaked a tracked resource");
renderer.render(scene, camera);
const hashAfter = await sha256(readPixels(renderer));
if (hashAfter !== hashBefore || scene.children.length !== 1) throw new Error("GPU OOM published a partial scene object");
const recoveryGeometry = new BoxGeometry(0.5, 0.5, 0.5);
const recoveryMaterial = new MeshBasicMaterial({ color: 0xcc3355 });
const recoveryMesh = new Mesh(recoveryGeometry, recoveryMaterial);
scene.add(recoveryMesh);
renderer.render(scene, camera);
const recoveryPixels = new Uint8Array(readPixels(renderer));
if (!recoveryPixels.some((value, index) => index % 4 !== 3 && value > 0)) throw new Error("GPU did not render after OOM cleanup");
scene.remove(recoveryMesh);
recoveryGeometry.dispose();
recoveryMaterial.dispose();
scene.remove(baseline);
baselineGeometry.dispose();
baselineMaterial.dispose();
renderer.dispose();
if (!observation) throw new Error("GPU OOM observation is missing");
return {
schemaVersion: OOM_RECOVERY_REPORT_SCHEMA,
scenario: "GPU_RESOURCES",
faults: [observation],
memory: { beforeBytes: 0, peakBytes: stats.peakBytes, afterBytes: stats.currentBytes, releasedBytes: stats.releasedBytes },
state: { revisionBefore: 0, revisionAfter: 0, hashBefore, hashAfter, temporaryResourcesBefore: 0, temporaryResourcesPeak: stats.peakResources, temporaryResourcesAfter: stats.liveResources },
recovery: { recovered: true, sameSession: true, restartedSession: false, tokenIsolated: isolated },
checks: ["partial-geometry-disposed", "partial-object-not-published", "previous-frame-stable", "small-scene-renders"],
};
}
async function runNanoVdbScenario(): Promise<OOMRecoveryReportIR> {
if (!navigator.gpu) throw new Error("WebGPU is unavailable for NanoVDB OOM recovery");
const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" });
if (!adapter) throw new Error("WebGPU adapter is unavailable for NanoVDB OOM recovery");
const device = await adapter.requestDevice();
const started = beginOOMFaultSession({ point: "NANOVDB_PAGE_TABLE", failAfterCount: 0 });
const isolated = proveTokenIsolation(started.session, started.token, "NANOVDB_PAGE_TABLE");
const destroyed = new Map<string, number>();
const trackedDevice = new Proxy(device, {
get(target, property) {
if (property === "createBuffer") {
return (descriptor: TestGPUBufferDescriptor): GPUBuffer => {
const label = descriptor.label ?? "unlabeled";
const point: OOMFaultPoint = label.includes("page table") ? "NANOVDB_PAGE_TABLE" : "NANOVDB_RESIDENT_BUFFER";
const lease = started.session.reserve(started.token, point, Number(descriptor.size), `nanovdb-${label}`);
const buffer = target.createBuffer(descriptor);
let released = false;
return new Proxy(buffer, {
get(bufferTarget, bufferProperty) {
if (bufferProperty === "destroy") {
return () => {
if (!released) {
released = true;
destroyed.set(label, (destroyed.get(label) ?? 0) + 1);
lease.release();
}
bufferTarget.destroy();
};
}
const current = Reflect.get(bufferTarget, bufferProperty, bufferTarget);
return typeof current === "function" ? current.bind(bufferTarget) : current;
},
});
};
}
const current = Reflect.get(target, property, target);
return typeof current === "function" ? current.bind(target) : current;
},
});
let observation: OOMFaultObservationIR | undefined;
try {
createNanoVDBFloat32GridPaged(trackedDevice, 128 * 1024, 64 * 1024, 64 * 1024);
throw new Error("NanoVDB page-table OOM did not reject the grid");
}
catch (error) {
observation = assertFault(error, "NANOVDB_PAGE_TABLE");
}
const stats = started.session.close(started.token);
if (!stats.triggered || stats.liveResources !== 0 || destroyed.get("NanoVDB paged Float32 grid") !== 1) {
throw new Error("NanoVDB OOM did not uniquely release the resident buffer");
}
const recovered = createNanoVDBFloat32GridPaged(device, 128 * 1024, 64 * 1024, 64 * 1024);
recovered.uploadPage(0, new ArrayBuffer(64 * 1024));
recovered.uploadPage(1, new ArrayBuffer(64 * 1024));
if (!recovered.hasResidentPage(1) || recovered.hasResidentPage(0) || recovered.evictionCount !== 1) throw new Error("NanoVDB resident allocator did not recover on the same device");
recovered.dispose();
device.destroy();
if (!observation) throw new Error("NanoVDB OOM observation is missing");
return {
schemaVersion: OOM_RECOVERY_REPORT_SCHEMA,
scenario: "NANOVDB_RESIDENT",
faults: [observation],
memory: { beforeBytes: 0, peakBytes: stats.peakBytes, afterBytes: stats.currentBytes, releasedBytes: stats.releasedBytes },
state: { revisionBefore: 0, revisionAfter: 0, temporaryResourcesBefore: 0, temporaryResourcesPeak: stats.peakResources, temporaryResourcesAfter: stats.liveResources },
recovery: { recovered: true, sameSession: true, restartedSession: false, tokenIsolated: isolated },
checks: ["resident-buffer-destroyed-once", "page-table-not-published", "resident-pages-recreated", "same-device-recovers", "lru-eviction-recovers"],
};
}
export async function runOOMRecoveryScenarios(input: ArrayBuffer): Promise<OOMRecoveryReportIR[]> {
if (!(input instanceof ArrayBuffer) || input.byteLength === 0) throw new Error("OOM recovery requires a non-empty .blend fixture");
return parseOOMRecoverySuite([
await runWasmMainScenario(input.slice(0)),
await runOpfsScenario(input.slice(0)),
await runGpuScenario(),
await runNanoVdbScenario(),
]);
}

View File

@@ -45,7 +45,7 @@ import { applyCurveHandlePreview, applyNonMeshElementSelection, applyNonMeshTran
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
import type { CurveGizmoFrameIR, CurveGizmoHandleIR, CurveGizmoScreenFrameIR } from "../../../protocol/nonmesh-interaction";
import type { NanoVDBViewportAssetIR, NanoVDBViewportRenderResultIR } from "../volume/nanovdb-viewport";
import { NanoVDBViewportRenderSession, renderNanoVDBViewportAsset } from "../volume/nanovdb-viewport";
import { NANOVDB_VIEWPORT_PREVIEW_SIZE, NanoVDBViewportRenderSession, renderNanoVDBViewportAsset } from "../volume/nanovdb-viewport";
import { createNanoVDBViewportObject } from "./volume";
import {
applyGreasePencilPointSelection,
@@ -394,7 +394,7 @@ export class ViewportRenderer {
const cacheKey = `${asset.dataId}:${asset.manifest.bundleSha256}:${JSON.stringify(asset.material ?? asset.manifest.material)}`;
let result = this.volumeRenderCache.get(cacheKey);
if (!result) {
result = await renderNanoVDBViewportAsset(asset, 128, 128, this.volumeRenderSession);
result = await renderNanoVDBViewportAsset(asset, NANOVDB_VIEWPORT_PREVIEW_SIZE, NANOVDB_VIEWPORT_PREVIEW_SIZE, this.volumeRenderSession);
this.volumeRenderCache.set(cacheKey, result);
}
if (generation !== this.volumeRenderGeneration || this.currentSnapshot !== snapshot) return;

View File

@@ -15,13 +15,13 @@ import { applyNonMeshTransform } from "./nonmesh";
export function createNanoVDBViewportObject(result: NanoVDBViewportRenderResultIR, node: SceneNodeIR): Mesh {
const { min, max } = result.grid.worldBounds;
const z = (min[2] + max[2]) / 2;
const positions = [
min[0], z, -min[1],
max[0], z, -min[1],
max[0], z, -max[1],
min[0], z, -max[1],
];
const center = [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2];
const blenderPositions = result.viewAxis === "X"
? [[center[0], min[1], min[2]], [center[0], max[1], min[2]], [center[0], max[1], max[2]], [center[0], min[1], max[2]]]
: result.viewAxis === "Y"
? [[min[0], center[1], min[2]], [max[0], center[1], min[2]], [max[0], center[1], max[2]], [min[0], center[1], max[2]]]
: [[min[0], min[1], center[2]], [max[0], min[1], center[2]], [max[0], max[1], center[2]], [min[0], max[1], center[2]]];
const positions = blenderPositions.flatMap(([x, y, z]) => [x, z, -y]);
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
geometry.setAttribute("uv", new Float32BufferAttribute([0, 0, 1, 0, 1, 1, 0, 1], 2));
@@ -38,6 +38,7 @@ export function createNanoVDBViewportObject(result: NanoVDBViewportRenderResultI
mesh.userData.nanoVDBVolume = true;
mesh.userData.nanoVDBGrid = result.grid.name;
mesh.userData.nanoVDBImageSize = [result.width, result.height];
mesh.userData.nanoVDBViewAxis = result.viewAxis;
applyNonMeshTransform(mesh, node);
return mesh;
}

View File

@@ -6,7 +6,7 @@ interface GPUBuffer {
mapAsync(mode: number): Promise<void>;
}
interface GPUAdapter {
limits: { maxStorageBufferBindingSize: number; maxBufferSize: number };
limits: { maxStorageBufferBindingSize: number; maxBufferSize: number; maxStorageBuffersPerShaderStage: number };
requestDevice(options?: { requiredLimits?: Record<string, number> }): Promise<GPUDevice>;
}
interface GPUQueue { writeBuffer(buffer: GPUBuffer, offset: number, data: ArrayBuffer | ArrayBufferView): void; submit(commands: Array<GPUCommandBuffer>): void }
@@ -29,7 +29,7 @@ interface GPUComputePipeline {
type GPUBindGroupLayout = object;
type GPUBindGroup = object;
interface GPUDevice {
limits: { maxStorageBufferBindingSize: number; maxBufferSize: number };
limits: { maxStorageBufferBindingSize: number; maxBufferSize: number; maxStorageBuffersPerShaderStage: number };
queue: GPUQueue;
lost: Promise<{ reason?: string; message: string }>;
createBuffer(descriptor: { label?: string; size: number; usage: number; mappedAtCreation?: boolean }): GPUBuffer;

View File

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

View File

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

View File

@@ -0,0 +1,179 @@
import {
MeshGeometryStreamError,
meshGeometryStreamChunkSha256,
meshGeometryStreamManifestSha256,
parseMeshGeometryStreamRequest,
type MeshGeometryStreamChunkIR,
type MeshGeometryStreamChunkRecordIR,
type MeshGeometryStreamRequestIR,
type MeshGeometryStreamWorkerRequest,
type MeshGeometryStreamWorkerResponse,
} from "../../../protocol/geometry-stream";
const workerScope = self as unknown as {
onmessage: ((event: MessageEvent<MeshGeometryStreamWorkerRequest>) => void) | null;
postMessage: (message: MeshGeometryStreamWorkerResponse, transfer?: Transferable[]) => void;
};
type Advance = "ack" | "cancel";
interface ActiveStream {
request: MeshGeometryStreamRequestIR;
cancelled: boolean;
resolveAdvance?: (advance: Advance) => void;
}
let active: ActiveStream | null = null;
function post(message: MeshGeometryStreamWorkerResponse, transfer: Transferable[] = []): void {
workerScope.postMessage(message, transfer);
}
function createGridGeometry(
request: MeshGeometryStreamRequestIR,
chunkIndex: number,
triangleOffset: number,
triangleCount: number,
meshId = request.meshId,
): Omit<MeshGeometryStreamChunkIR, "sha256"> {
const columns = Math.ceil(Math.sqrt(triangleCount / 2));
const rows = Math.ceil(triangleCount / (columns * 2));
const positions = new Float32Array((columns + 1) * (rows + 1) * 3);
for (let y = 0; y <= rows; y++) {
for (let x = 0; x <= columns; x++) {
const offset = (y * (columns + 1) + x) * 3;
positions[offset] = x / Math.max(1, columns) * 4 - 2;
positions[offset + 1] = y / Math.max(1, rows) * 4 - 2;
positions[offset + 2] = Math.sin((triangleOffset + x) * 0.0003) * Math.cos(y * 0.03) * 0.08;
}
}
const indices = new Uint32Array(triangleCount * 3);
for (let triangle = 0; triangle < triangleCount; triangle++) {
const cell = Math.floor(triangle / 2);
const x = cell % columns;
const y = Math.floor(cell / columns);
const a = y * (columns + 1) + x;
const offset = triangle * 3;
if (triangle % 2 === 0) indices.set([a, a + 1, a + columns + 2], offset);
else indices.set([a, a + columns + 2, a + columns + 1], offset);
}
return {
schemaVersion: 1,
streamId: request.streamId,
meshId,
chunkIndex,
chunkCount: Math.ceil(request.triangleCount / request.chunkTriangleCount),
triangleOffset,
triangleCount,
vertexCount: positions.length / 3,
byteLength: positions.byteLength + indices.byteLength,
positions: positions.buffer,
indices: indices.buffer,
};
}
function waitForAdvance(stream: ActiveStream): Promise<Advance> {
if (stream.cancelled) return Promise.resolve("cancel");
return new Promise((resolve) => { stream.resolveAdvance = resolve; });
}
function releaseAdvance(stream: ActiveStream, advance: Advance): void {
const resolve = stream.resolveAdvance;
stream.resolveAdvance = undefined;
resolve?.(advance);
}
async function sendGeometry(
stream: ActiveStream,
type: "lod" | "chunk",
unsigned: Omit<MeshGeometryStreamChunkIR, "sha256">,
): Promise<Advance> {
const geometry: MeshGeometryStreamChunkIR = { ...unsigned, sha256: await meshGeometryStreamChunkSha256(unsigned) };
const advance = waitForAdvance(stream);
const positions = geometry.positions;
const indices = geometry.indices;
post(type === "lod" ? { type, streamId: stream.request.streamId, geometry } : { type, streamId: stream.request.streamId, chunk: geometry }, [positions, indices]);
post({ type: "detached", streamId: stream.request.streamId, chunkIndex: geometry.chunkIndex, positionsByteLength: positions.byteLength, indicesByteLength: indices.byteLength });
return advance;
}
async function run(value: unknown): Promise<void> {
let request: MeshGeometryStreamRequestIR | undefined;
try {
request = parseMeshGeometryStreamRequest(value);
if (active) throw new MeshGeometryStreamError("GEOMETRY_STREAM_INVALID", "another geometry stream is active");
const stream: ActiveStream = { request, cancelled: false };
active = stream;
let transferredBytes = 0;
let peakWorkingSetBytes = 0;
const chunks: MeshGeometryStreamChunkRecordIR[] = [];
const lod = createGridGeometry(request, -1, 0, request.lodTriangleCount, `${request.meshId}:lod:0`);
if (await sendGeometry(stream, "lod", lod) === "cancel") {
post({ type: "cancelled", report: { streamId: request.streamId, meshId: request.meshId, status: "CANCELLED", triangleCount: 0, chunkCount: 0, transferredBytes: 0, peakWorkingSetBytes: lod.byteLength } });
return;
}
const chunkCount = Math.ceil(request.triangleCount / request.chunkTriangleCount);
let triangleOffset = 0;
for (let chunkIndex = 0; chunkIndex < chunkCount; chunkIndex++) {
const triangleCount = Math.min(request.chunkTriangleCount, request.triangleCount - triangleOffset);
const unsigned = createGridGeometry(request, chunkIndex, triangleOffset, triangleCount);
const sha256 = await meshGeometryStreamChunkSha256(unsigned);
const record = { chunkIndex, triangleOffset, triangleCount, vertexCount: unsigned.vertexCount, byteLength: unsigned.byteLength, sha256 };
const advance = waitForAdvance(stream);
const chunk: MeshGeometryStreamChunkIR = { ...unsigned, sha256 };
const positions = chunk.positions;
const indices = chunk.indices;
post({ type: "chunk", streamId: request.streamId, chunk }, [positions, indices]);
post({ type: "detached", streamId: request.streamId, chunkIndex, positionsByteLength: positions.byteLength, indicesByteLength: indices.byteLength });
transferredBytes += chunk.byteLength;
peakWorkingSetBytes = Math.max(peakWorkingSetBytes, chunk.byteLength, lod.byteLength);
chunks.push(record);
if (await advance === "cancel") {
post({ type: "cancelled", report: { streamId: request.streamId, meshId: request.meshId, status: "CANCELLED", triangleCount: triangleOffset + triangleCount, chunkCount: chunks.length, transferredBytes, peakWorkingSetBytes } });
return;
}
triangleOffset += triangleCount;
}
post({
type: "complete",
report: {
streamId: request.streamId,
meshId: request.meshId,
status: "COMPLETED",
triangleCount: request.triangleCount,
chunkCount,
transferredBytes,
peakWorkingSetBytes,
manifestSha256: await meshGeometryStreamManifestSha256(request, chunks),
},
chunks,
});
}
catch (error) {
post({
type: "error",
streamId: request?.streamId,
code: error instanceof MeshGeometryStreamError ? error.code : "GEOMETRY_STREAM_INVALID",
message: error instanceof Error ? error.message : String(error),
});
}
finally {
active = null;
}
}
workerScope.onmessage = (event: MessageEvent<MeshGeometryStreamWorkerRequest>): void => {
const message = event.data;
if (message.type === "start") {
void run(message.request);
return;
}
if (!active || active.request.streamId !== message.streamId) return;
if (message.type === "cancel") {
active.cancelled = true;
releaseAdvance(active, "cancel");
}
else if (message.type === "ack") {
releaseAdvance(active, "ack");
}
};

View File

@@ -0,0 +1,79 @@
import {
LongMediaTimelineSession,
buildLongMediaTimelineIndex,
type LongMediaSeekResultIR,
} from "../sequencer/LongMediaTimeline";
interface MediaAssetMessage {
sourceId: string;
data: ArrayBuffer;
}
type Request =
| { type: "init"; timeline: unknown; assets: MediaAssetMessage[]; cacheMaxBytes: number }
| { type: "seek"; requestId: string; frame: number; decodeDelayMs?: number }
| { type: "cancel" }
| { type: "dispose" };
type Response =
| { type: "ready"; index: { stripCount: number; bucketCount: number; referenceCount: number; estimatedBytes: number } }
| { type: "seekResult"; requestId: string; result: LongMediaSeekResultIR }
| { type: "disposed"; cacheBytes: number }
| { type: "error"; requestId?: string; message: string };
const scope = self as unknown as {
onmessage: ((event: MessageEvent<Request>) => void) | null;
postMessage: (message: Response) => void;
};
let session: LongMediaTimelineSession | null = null;
let assets = new Map<string, ArrayBuffer>();
let decodeDelayMs = 0;
function abortableDelay(delayMs: number, signal: AbortSignal): Promise<void> {
if (signal.aborted) return Promise.reject(new DOMException("Long media decode cancelled", "AbortError"));
if (delayMs <= 0) return Promise.resolve();
return new Promise((resolve, reject) => {
const timer = setTimeout(resolve, delayMs);
signal.addEventListener("abort", () => { clearTimeout(timer); reject(new DOMException("Long media decode cancelled", "AbortError")); }, { once: true });
});
}
scope.onmessage = (event): void => {
const message = event.data;
if (message.type === "init") {
void (async () => {
try {
session?.dispose();
const index = await buildLongMediaTimelineIndex(message.timeline, new AbortController().signal);
assets = new Map(message.assets.map((asset) => [asset.sourceId, asset.data]));
session = new LongMediaTimelineSession(index, {
load: async (strip, _sourceFrame, signal) => {
await abortableDelay(decodeDelayMs, signal);
const data = strip.sourceId ? assets.get(strip.sourceId) : undefined;
if (!data) throw new Error(`SEQUENCER_RESOURCE_MISSING: ${strip.id} source bytes are unavailable`);
return data.slice(0);
},
}, message.cacheMaxBytes);
scope.postMessage({ type: "ready", index: index.stats });
}
catch (error) { scope.postMessage({ type: "error", message: error instanceof Error ? error.message : String(error) }); }
})();
return;
}
if (message.type === "seek") {
const active = session;
if (!active) { scope.postMessage({ type: "error", requestId: message.requestId, message: "SEQUENCER_RESOURCE_MISSING: long media session is not initialized" }); return; }
decodeDelayMs = message.decodeDelayMs ?? 0;
void active.seek(message.frame)
.then((result) => scope.postMessage({ type: "seekResult", requestId: message.requestId, result }))
.catch((error) => scope.postMessage({ type: "error", requestId: message.requestId, message: error instanceof Error ? error.message : String(error) }));
return;
}
if (message.type === "cancel") { session?.cancel(); return; }
const cacheBytes = session?.cache.stats().bytes ?? 0;
session?.dispose();
session = null;
assets.clear();
scope.postMessage({ type: "disposed", cacheBytes });
};

View File

@@ -1,19 +1,33 @@
import { gateRelease, parseReleaseManifest, serializeReleaseManifest } from "../../../protocol/release-gate";
const family = (id: string, dependencies: string[] = []) => ({ id, name: id, status: "BLOCKED", roadmapStatus: "planned", completedSlices: ["schema"], blockedSlices: ["A", "B"], excludedSlices: [], acceptance: [], dependencies });
const family = (id: string, dependencies: string[] = []) => ({
id,
name: id,
parityStatus: "BLOCKED",
releaseClass: "LOCAL_BOUNDED",
releaseStatus: "BLOCKED",
roadmapStatus: "planned",
completedSlices: ["schema"],
blockedSlices: ["A", "B"],
excludedSlices: [],
v1RequiredSlices: ["A"],
v1ExcludedSlices: ["B"],
acceptance: [],
dependencies,
});
const evidenceRecord = { id: "fixture", fields: ["runtime.offline", "performance.geometry1M", "faults.malformedBlend", "faults.zipBomb", "provenance.license"], command: "fixture", exitCode: 0, durationMs: 1, output: "fixture passed", artifactSha256: ["a".repeat(64)] };
const evidence = { browser: { chromium: false }, runtime: { offline: true, workerRestart: false, opfsRecovery: false }, performance: { geometry1M: true, geometry10M: false, texture4K: false, texture8K: false, longMedia: false, simulationCache: false }, faults: { oom: false, deviceLoss: false, networkInterrupt: false, malformedBlend: true, zipBomb: true }, provenance: { license: true, sbom: false, sourceOffer: false, deterministicPackage: false }, records: [evidenceRecord] };
const base = { schemaVersion: 3, source: "docs/status/parity-ledger.json", sourceSha256: "b".repeat(64), generatedAt: "2026-08-11T00:00:00.000Z", families: [family("N-015"), family("N-016", ["N-015"])], evidence };
const base = { schemaVersion: 4, source: "docs/status/parity-ledger.json", sourceSha256: "b".repeat(64), generatedAt: "2026-08-11T00:00:00.000Z", families: [family("N-015"), family("N-016", ["N-015"])], evidence };
self.onmessage = () => {
const result: Record<string, unknown> = {};
try { const parsed = parseReleaseManifest(base); result.valid = [parsed.families.length, serializeReleaseManifest(base) === serializeReleaseManifest({ ...base, families: [...base.families].reverse() })]; } catch (error) { result.valid = error instanceof Error ? error.message : String(error); }
const gate = gateRelease(base); result.gate = [gate.status, gate.issues.map((issue) => issue.code)];
try { parseReleaseManifest({ ...base, schemaVersion: 2 }); } catch (error) { result.oldSchema = error instanceof Error ? error.message : String(error); }
try { parseReleaseManifest({ ...base, schemaVersion: 3 }); } catch (error) { result.oldSchema = error instanceof Error ? error.message : String(error); }
try { parseReleaseManifest({ ...base, families: [family("N-015", ["N-016"]), family("N-016", ["N-015"])] }); } catch (error) { result.cycle = error instanceof Error ? error.message : String(error); }
try { parseReleaseManifest({ ...base, families: [{ ...family("N-015"), status: "LOCAL_EXACT", completedSlices: [] }] }); } catch (error) { result.status = error instanceof Error ? error.message : String(error); }
try { parseReleaseManifest({ ...base, families: [{ ...family("N-015"), releaseStatus: "READY" }] }); } catch (error) { result.status = error instanceof Error ? error.message : String(error); }
try { parseReleaseManifest({ ...base, evidence: { ...evidence, browser: { chromium: true } } }); } catch (error) { result.unbound = error instanceof Error ? error.message : String(error); }
try { parseReleaseManifest({ ...base, families: [{ ...family("N-015"), excludedSlices: ["A"], blockedSlices: ["A", "B"] }, family("N-016", ["N-015"])] }); } catch (error) { result.excludedOverlap = error instanceof Error ? error.message : String(error); }
try { parseReleaseManifest({ ...base, families: [{ ...family("N-015"), v1ExcludedSlices: ["A"] }, family("N-016", ["N-015"])] }); } catch (error) { result.excludedOverlap = error instanceof Error ? error.message : String(error); }
try { parseReleaseManifest({ ...base, evidence: { ...evidence, records: [{ ...evidenceRecord, fields: ["performance.geometry10M"] }] } }); } catch (error) { result.disabledEvidence = error instanceof Error ? error.message : String(error); }
try { parseReleaseManifest({ ...base, evidence: { ...evidence, records: [{ ...evidenceRecord, artifactSha256: [] }] } }); } catch (error) { result.emptyArtifact = error instanceof Error ? error.message : String(error); }
try { parseReleaseManifest({ ...base, generatedAt: "2026-02-31T00:00:00.000Z" }); } catch (error) { result.generatedAt = error instanceof Error ? error.message : String(error); }

View File

@@ -1,12 +1,14 @@
import { verifyNanoVDBChunk, type NanoVDBBundleManifestIR } from "../../../protocol/volume-vdb";
import {
createNanoVDBFloat32GridPaged,
NanoVDBGpuPageAllocator,
NanoVDBWebGPUDeviceSession,
readNanoVDBWordsWebGPU,
sampleNanoVDBFloat32WebGPU,
uploadNanoVDBFloat32GridPaged,
} from "../render/nanovdb-volume-renderer";
import { createResumableHttpNanoVDBRangeSource } from "../volume/nanovdb-stream";
import { loadNanoVDBViewportAsset } from "../volume/nanovdb-viewport";
import { loadNanoVDBGridPage, loadNanoVDBViewportAsset, planNanoVDBGridResidency } from "../volume/nanovdb-viewport";
const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void };
@@ -128,12 +130,64 @@ scope.onmessage = (): void => {
const lru = allocator.stats();
allocator.dispose();
const pageByteLength = 256 * 1024;
const pagingManifest: NanoVDBBundleManifestIR = {
...manifest,
gpu: { ...manifest.gpu, pageByteLength, maxResidentBytes: 2 * pageByteLength },
};
const globalResidency = planNanoVDBGridResidency({
...pagingManifest,
gpu: { ...pagingManifest.gpu, maxResidentBytes: 3 * pageByteLength },
});
const pageRanges: string[] = [];
const pageSource = createResumableHttpNanoVDBRangeSource("/__vdb_fixture__/bundle", manifest.bundleByteLength, {
fetcher: async (input, init) => {
pageRanges.push(new Headers(init?.headers).get("Range") ?? "");
return fetch(input, init);
},
retries: 0,
requireStableEtag: true,
});
const constrained = createNanoVDBFloat32GridPaged(device, payload.byteLength, pageByteLength, 2 * pageByteLength);
const initialVirtualPages = [...constrained.residentVirtualPages];
const page0 = await loadNanoVDBGridPage(pagingManifest, density.name, 0, pageSource, new AbortController().signal);
const page1 = await loadNanoVDBGridPage(pagingManifest, density.name, 1, pageSource, new AbortController().signal);
const lastPageIndex = constrained.pageCount - 1;
const lastPage = await loadNanoVDBGridPage(pagingManifest, density.name, lastPageIndex, pageSource, new AbortController().signal);
constrained.uploadPage(0, page0);
constrained.uploadPage(1, page1);
constrained.touchPage(0);
constrained.uploadPage(lastPageIndex, lastPage);
const lastWords = new Uint32Array(lastPage);
const lastWordIndex = lastWords.findIndex((value) => value !== 0);
if (lastWordIndex < 0) throw new Error("VDB demand page fixture has no observable word");
const wordOffsets = [0, lastPageIndex * pageByteLength + lastWordIndex * 4, pageByteLength];
const expectedWords = [new Uint32Array(page0)[0], lastWords[lastWordIndex], 0];
const demandWords = await readNanoVDBWordsWebGPU(device, constrained, wordOffsets);
let incompletePage = "";
try { constrained.uploadPage(2, new ArrayBuffer(4)); }
catch (error) { incompletePage = error instanceof Error ? error.message : String(error); }
const demandPaging = {
pageCount: constrained.pageCount,
residentPageCapacity: constrained.residentPageCapacity,
residentBytes: constrained.residentBytes,
maxResidentBytes: constrained.maxResidentBytes,
evictions: constrained.evictionCount,
initialVirtualPages,
residentVirtualPages: [...constrained.residentVirtualPages],
requestedPageResident: constrained.hasResidentPage(lastPageIndex),
touchedPageResident: constrained.hasResidentPage(0),
evictedPageResident: constrained.hasResidentPage(1),
pageRanges,
wordOffsets,
expectedWords,
demandWords,
incompletePage,
};
constrained.dispose();
let oom = "";
try {
const constrained = uploadNanoVDBFloat32GridPaged(device, payload, 256 * 1024, 256 * 1024);
if (constrained.residentPageCount < constrained.pageCount) oom = "NANOVDB_GPU_BUDGET_EXCEEDED: resident paging active";
constrained.dispose();
}
try { uploadNanoVDBFloat32GridPaged(device, payload, 256 * 1024, 128 * 1024); }
catch (error) { oom = error instanceof Error ? error.message : String(error); }
const residentBudget = Math.ceil(payload.byteLength / (256 * 1024)) * 256 * 1024;
@@ -145,6 +199,11 @@ scope.onmessage = (): void => {
device.destroy();
const loss = await session.waitForLoss();
const recoveredDevice = await session.recover(payload.byteLength + 256 * 1024);
const recoveredDemand = createNanoVDBFloat32GridPaged(recoveredDevice, payload.byteLength, pageByteLength, 2 * pageByteLength);
recoveredDemand.uploadPage(0, await loadNanoVDBGridPage(pagingManifest, density.name, 0, pageSource, new AbortController().signal));
recoveredDemand.uploadPage(lastPageIndex, await loadNanoVDBGridPage(pagingManifest, density.name, lastPageIndex, pageSource, new AbortController().signal));
const recoveredDemandWords = await readNanoVDBWordsWebGPU(recoveredDevice, recoveredDemand, wordOffsets.slice(0, 2));
recoveredDemand.dispose();
const recovered = uploadNanoVDBFloat32GridPaged(recoveredDevice, payload, 256 * 1024, residentBudget);
const after = await sampleNanoVDBFloat32WebGPU(recoveredDevice, recovered, native.map((sample) => sample.coord));
recovered.dispose();
@@ -165,8 +224,10 @@ scope.onmessage = (): void => {
},
lru,
oom,
demandPaging,
globalResidency,
paging,
deviceLoss: { reason: loss.reason, firstGeneration, recoveredGeneration },
deviceLoss: { reason: loss.reason, firstGeneration, recoveredGeneration, recoveredDemandWords },
samplesStable: JSON.stringify(before) === JSON.stringify(after),
};
})().then((result) => scope.postMessage(result)).catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) }));

View File

@@ -1,7 +1,15 @@
import { validateNanoVDBBundleManifest, type NanoVDBBundleManifestIR } from "../../../protocol/volume-vdb";
import { NanoVDBFloat32Sampler } from "../volume/nanovdb-float32";
import { mapPrincipledVolumeToNanoVDB } from "../volume/volume-material-mapping";
import { probeNanoVDBWebGPU, renderNanoVDBFloat32WebGPU, sampleNanoVDBFloat32WebGPU, uploadNanoVDBFloat32Grid } from "../render/nanovdb-volume-renderer";
import {
probeNanoVDBWebGPU,
renderNanoVDBFloat32WebGPU,
sampleNanoVDBFloat32WebGPU,
uploadNanoVDBFloat32Grid,
uploadNanoVDBFloat32GridPaged,
type NanoVDBMaterialGridUploadsIR,
type NanoVDBViewAxis,
} from "../render/nanovdb-volume-renderer";
const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void };
@@ -12,12 +20,17 @@ scope.onmessage = (): void => {
const density = manifest.grids.find((grid) => grid.name === manifest.material.densityGrid);
const nativeDensity = report.grids.find((grid) => grid.name === manifest.material.densityGrid);
if (!density || !nativeDensity?.scalarSamples || !manifest.gpu.float32TreeLayout) throw new Error("VDB WebGPU fixture is incomplete");
const response = await fetch("/__vdb_fixture__/bundle", { headers: { Range: `bytes=${density.byteOffset}-${density.byteOffset + density.byteLength - 1}` }, cache: "no-store" });
if (response.status !== 206) throw new Error(`VDB payload range returned ${response.status}`);
const payload = await response.arrayBuffer();
const fetchGrid = async (name: string): Promise<ArrayBuffer> => {
const grid = manifest.grids.find((candidate) => candidate.name === name);
if (!grid) throw new Error(`VDB grid ${name} is missing`);
const response = await fetch("/__vdb_fixture__/bundle", { headers: { Range: `bytes=${grid.byteOffset}-${grid.byteOffset + grid.byteLength - 1}` }, cache: "no-store" });
if (response.status !== 206) throw new Error(`VDB payload range returned ${response.status}`);
return response.arrayBuffer();
};
const payload = await fetchGrid(density.name);
const cpu = new NanoVDBFloat32Sampler(payload, density, manifest.gpu.float32TreeLayout);
const cpuSamples = nativeDensity.scalarSamples.map((sample) => ({ coord: sample.coord, ...cpu.nearest(sample.coord), expectedValue: sample.value, expectedActive: sample.active }));
const probe = await probeNanoVDBWebGPU(payload.byteLength);
const probe = await probeNanoVDBWebGPU(payload.byteLength, 9);
if (!probe.capability.available || !probe.device) throw new Error(probe.capability.reason ?? "WebGPU adapter is unavailable");
const device = probe.device;
device.pushErrorScope("validation");
@@ -37,7 +50,30 @@ scope.onmessage = (): void => {
anisotropy: 0.2,
interpolation: "LINEAR",
});
const pixels = await renderNanoVDBFloat32WebGPU(device, uploaded, density, materialMapping.material, 96, 96);
const materialUploads: NanoVDBMaterialGridUploadsIR = {};
if (materialMapping.material.temperatureGrid) {
materialUploads.temperature = uploadNanoVDBFloat32GridPaged(device, await fetchGrid(materialMapping.material.temperatureGrid), 256 * 1024, 16 * 1024 * 1024);
}
if (materialMapping.material.colorGrid) {
materialUploads.color = uploadNanoVDBFloat32GridPaged(device, await fetchGrid(materialMapping.material.colorGrid), 256 * 1024, 16 * 1024 * 1024);
}
const emissionManifest = validateNanoVDBBundleManifest({
...manifest,
grids: manifest.grids.map((grid) => grid.name === manifest.material.temperatureGrid ? { ...grid, semantic: "EMISSION" as const } : grid),
material: { ...manifest.material, temperatureGrid: undefined, emissionGrid: manifest.material.temperatureGrid },
});
const emissionMapping = mapPrincipledVolumeToNanoVDB(emissionManifest, {
densityGrid: emissionManifest.material.densityGrid,
densityScale: 1,
emissionGrid: emissionManifest.material.emissionGrid,
emissionColor: [1, 0.2, 0.05],
emissionScale: 0.0005,
});
materialUploads.emission = materialUploads.temperature;
const combinedMaterial = { ...materialMapping.material, emissionGrid: emissionMapping.material.emissionGrid };
const viewAxis: NanoVDBViewAxis = "X";
const pixels = await renderNanoVDBFloat32WebGPU(device, uploaded, density, combinedMaterial, 64, 64, materialUploads, viewAxis);
const emissionVisiblePixels = Array.from({ length: pixels.length / 4 }, (_, index) => pixels[index * 4 + 3] > 0).filter(Boolean).length;
const validationError = await device.popErrorScope();
if (validationError) throw new Error(`WebGPU validation failed: ${validationError.message}`);
let visiblePixels = 0;
@@ -47,6 +83,8 @@ scope.onmessage = (): void => {
if (pixels[index] > 0) visiblePixels++;
}
const imageHash = await crypto.subtle.digest("SHA-256", new Uint8Array(pixels).buffer);
materialUploads.temperature?.dispose();
materialUploads.color?.dispose();
uploaded.dispose();
device.destroy();
return {
@@ -58,6 +96,9 @@ scope.onmessage = (): void => {
visiblePixels,
alphaSum,
imageSha256: Array.from(new Uint8Array(imageHash), (byte) => byte.toString(16).padStart(2, "0")).join(""),
viewAxis,
emissionVisiblePixels,
emissionMapping,
materialMapping,
};
})().then((result) => scope.postMessage(result)).catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) }));

View File

@@ -30,7 +30,7 @@ import type { OffscreenViewportRequest, OffscreenViewportResponse } from "../thr
import type { NonMeshElementKind } from "../three-adapter/nonmesh";
import type { CurveGizmoFrameIR, CurveGizmoScreenFrameIR } from "../../../protocol/nonmesh-interaction";
import type { NanoVDBViewportAssetIR, NanoVDBViewportRenderResultIR } from "../volume/nanovdb-viewport";
import { NanoVDBViewportRenderSession, renderNanoVDBViewportAsset } from "../volume/nanovdb-viewport";
import { NANOVDB_VIEWPORT_PREVIEW_SIZE, NanoVDBViewportRenderSession, renderNanoVDBViewportAsset } from "../volume/nanovdb-viewport";
import { createNanoVDBViewportObject } from "../three-adapter/volume";
import {
configurePBRLight,
@@ -74,6 +74,7 @@ raycaster.params.Points.threshold = 0.14;
const objectById = new Map<string, Object3D>();
let curveGizmoFrame: { dataId: string; frame: CurveGizmoFrameIR } | null = null;
let volumeAssets: NanoVDBViewportAssetIR[] = [];
let volumeAssetsInitialized = false;
const volumeRenderCache = new Map<string, NanoVDBViewportRenderResultIR>();
let volumeRenderGeneration = 0;
const volumeRenderSession = new NanoVDBViewportRenderSession(() => {
@@ -249,6 +250,7 @@ async function refreshVolumes(): Promise<void> {
return;
}
post({ type: "volumeStatus", status: "loading", count: 0 });
if (!volumeAssetsInitialized) return;
try {
let count = 0;
for (const node of nodes) {
@@ -257,7 +259,7 @@ async function refreshVolumes(): Promise<void> {
const cacheKey = `${asset.dataId}:${asset.manifest.bundleSha256}:${JSON.stringify(asset.material ?? asset.manifest.material)}`;
let result = volumeRenderCache.get(cacheKey);
if (!result) {
result = await renderNanoVDBViewportAsset(asset, 128, 128, volumeRenderSession);
result = await renderNanoVDBViewportAsset(asset, NANOVDB_VIEWPORT_PREVIEW_SIZE, NANOVDB_VIEWPORT_PREVIEW_SIZE, volumeRenderSession);
volumeRenderCache.set(cacheKey, result);
}
if (generation !== volumeRenderGeneration || currentSnapshot !== snapshot || !root) return;
@@ -546,6 +548,7 @@ workerScope.onmessage = (event): void => {
else if (message.type === "textureAssets") applyTextureAssets(message.assets);
else if (message.type === "volumeAssets") {
volumeAssets = message.assets;
volumeAssetsInitialized = true;
void refreshVolumes();
}
else if (message.type === "resize") resize(message.width, message.height, message.pixelRatio);