Complete V1 performance and OOM release gates
This commit is contained in:
3
web/app/src/performance/large-geometry.ts
Normal file
3
web/app/src/performance/large-geometry.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "../../../protocol/geometry-stream";
|
||||
export { buildLODCacheKey } from "../../../protocol/lod";
|
||||
export { decodeLODGeometry, encodeLODGeometry } from "../../../protocol/mesh-cache";
|
||||
@@ -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();
|
||||
|
||||
323
web/app/src/sequencer/LongMediaTimeline.ts
Normal file
323
web/app/src/sequencer/LongMediaTimeline.ts
Normal 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";
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
156
web/app/src/testing/oom-fault-session.ts
Normal file
156
web/app/src/testing/oom-fault-session.ts
Normal 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;
|
||||
}
|
||||
453
web/app/src/testing/oom-recovery-scenarios.ts
Normal file
453
web/app/src/testing/oom-recovery-scenarios.ts
Normal 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(),
|
||||
]);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
4
web/app/src/types/webgpu.d.ts
vendored
4
web/app/src/types/webgpu.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
}
|
||||
|
||||
179
web/app/src/workers/geometry-stream.worker.ts
Normal file
179
web/app/src/workers/geometry-stream.worker.ts
Normal 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");
|
||||
}
|
||||
};
|
||||
79
web/app/src/workers/long-media.worker.ts
Normal file
79
web/app/src/workers/long-media.worker.ts
Normal 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 });
|
||||
};
|
||||
@@ -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); }
|
||||
|
||||
@@ -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) }));
|
||||
|
||||
@@ -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) }));
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user