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);
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"test:simulation-cache-performance": "playwright test --config playwright.config.ts tests/e2e/simulation-cache-performance.spec.ts",
|
||||
"test:network-interruption": "playwright test --config playwright.config.ts tests/e2e/network-interruption.spec.ts",
|
||||
"test:device-loss": "playwright test --config playwright.config.ts tests/e2e/device-loss.spec.ts",
|
||||
"test:oom-recovery": "playwright test --config playwright.config.ts tests/e2e/oom-recovery.spec.ts",
|
||||
"test:texture-4k-performance": "playwright test --config playwright.config.ts tests/e2e/texture-4k-performance.spec.ts",
|
||||
"test:texture-8k-performance": "playwright test --config playwright.config.ts tests/e2e/texture-8k-performance.spec.ts",
|
||||
"test:physics-main-reader": "node ../tools/web/check-physics-main-reader.mjs",
|
||||
@@ -58,6 +59,7 @@
|
||||
"test:script-main-reader": "node ../tools/web/check-script-main-reader.mjs",
|
||||
"test:scripting-isolation": "node ../tools/web/check-scripting-isolation.mjs",
|
||||
"test:release-gate": "playwright test --config playwright.config.ts -g \"N-026 release\"",
|
||||
"test:v1-user-loop": "playwright test --config playwright.config.ts tests/e2e/v1-user-loop.spec.ts",
|
||||
"test:browser-smoke": "playwright test --config playwright.release.config.ts -g \"boots the offline engine\"",
|
||||
"test:cross-browser-smoke": "npm run test:browser-smoke",
|
||||
"test:authoring-roundtrip": "node ../tools/web/check-authoring-roundtrip.mjs",
|
||||
@@ -70,10 +72,17 @@
|
||||
"test:nonmesh-usd-serialization": "USD_DESKTOP_REQUIRED=0 node ../tools/web/check-nonmesh-usd-blender-roundtrip.mjs",
|
||||
"test:nonmesh-usd-blender-roundtrip": "USD_DESKTOP_REQUIRED=1 node ../tools/web/check-nonmesh-usd-blender-roundtrip.mjs",
|
||||
"test:release-performance": "node ../tools/web/check-release-performance.mjs",
|
||||
"test:geometry-10m-performance": "playwright test --config playwright.config.ts tests/e2e/geometry-10m-performance.spec.ts",
|
||||
"test:long-media-performance": "playwright test --config playwright.config.ts tests/e2e/long-media-performance.spec.ts",
|
||||
"test:malicious-blends": "node ../tools/web/check-malicious-blends.mjs",
|
||||
"test:release-package": "npm run build && npm run release:sbom && node ../tools/web/check-release-package.mjs",
|
||||
"release:sbom": "node ../tools/web/generate-sbom.mjs",
|
||||
"release:evidence": "node ../tools/web/collect-release-evidence.mjs",
|
||||
"release:evidence-sync": "node ../tools/web/collect-release-evidence.mjs --sync-ledger",
|
||||
"release:evidence-v1": "node ../tools/web/collect-release-evidence.mjs --record-v1-user-loop",
|
||||
"release:evidence-geometry-10m": "node ../tools/web/collect-release-evidence.mjs --record-geometry-10m",
|
||||
"release:evidence-long-media": "node ../tools/web/collect-release-evidence.mjs --record-long-media",
|
||||
"release:evidence-oom": "node ../tools/web/collect-release-evidence.mjs --record-oom-recovery",
|
||||
"test:release-evidence": "node ../tools/web/check-release-evidence.mjs",
|
||||
"test:status-consistency": "node ../tools/web/check-status-consistency.mjs",
|
||||
"release:offline": "npm run build && node ../tools/web/check-offline-reproducibility.mjs",
|
||||
|
||||
@@ -37,6 +37,7 @@ export type ErrorCode =
|
||||
| "GPU_TEXTURE_HASH_MISMATCH"
|
||||
| "GPU_TEXTURE_BUDGET_EXCEEDED"
|
||||
| "GPU_TEXTURE_DECODE_FAILED"
|
||||
| "GPU_GEOMETRY_BUDGET_EXCEEDED"
|
||||
| "UDIM_MANIFEST_INVALID"
|
||||
| "UDIM_TILE_MISSING"
|
||||
| "UDIM_MULTI_TILE_UNAVAILABLE"
|
||||
@@ -97,6 +98,7 @@ export type ErrorCode =
|
||||
| "SEQUENCER_RESOURCE_MISSING"
|
||||
| "SEQUENCER_RESOURCE_OUTSIDE_PROJECT"
|
||||
| "SEQUENCER_CODEC_UNSUPPORTED"
|
||||
| "SEQUENCER_CANCELLED"
|
||||
| "TRACKING_SCHEMA_INVALID"
|
||||
| "TRACKING_BUDGET_EXCEEDED"
|
||||
| "TRACKING_RESOURCE_OUTSIDE_PROJECT"
|
||||
|
||||
210
web/protocol/geometry-stream.ts
Normal file
210
web/protocol/geometry-stream.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
export const MESH_GEOMETRY_STREAM_SCHEMA = 1 as const;
|
||||
export const MESH_GEOMETRY_STREAM_MAX_TRIANGLES = 20_000_000;
|
||||
export const MESH_GEOMETRY_STREAM_MAX_CHUNK_TRIANGLES = 500_000;
|
||||
export const MESH_GEOMETRY_STREAM_MAX_LOD_TRIANGLES = 100_000;
|
||||
|
||||
export interface MeshGeometryStreamRequestIR {
|
||||
schemaVersion: typeof MESH_GEOMETRY_STREAM_SCHEMA;
|
||||
streamId: string;
|
||||
meshId: string;
|
||||
triangleCount: number;
|
||||
chunkTriangleCount: number;
|
||||
lodTriangleCount: number;
|
||||
}
|
||||
|
||||
export interface MeshGeometryStreamChunkIR {
|
||||
schemaVersion: typeof MESH_GEOMETRY_STREAM_SCHEMA;
|
||||
streamId: string;
|
||||
meshId: string;
|
||||
chunkIndex: number;
|
||||
chunkCount: number;
|
||||
triangleOffset: number;
|
||||
triangleCount: number;
|
||||
vertexCount: number;
|
||||
byteLength: number;
|
||||
positions: ArrayBuffer;
|
||||
indices: ArrayBuffer;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface MeshGeometryStreamChunkRecordIR {
|
||||
chunkIndex: number;
|
||||
triangleOffset: number;
|
||||
triangleCount: number;
|
||||
vertexCount: number;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface MeshGeometryStreamReportIR {
|
||||
streamId: string;
|
||||
meshId: string;
|
||||
status: "COMPLETED" | "CANCELLED";
|
||||
triangleCount: number;
|
||||
chunkCount: number;
|
||||
transferredBytes: number;
|
||||
peakWorkingSetBytes: number;
|
||||
manifestSha256?: string;
|
||||
}
|
||||
|
||||
export type MeshGeometryStreamWorkerRequest =
|
||||
| { type: "start"; request: MeshGeometryStreamRequestIR }
|
||||
| { type: "ack"; streamId: string; chunkIndex: number }
|
||||
| { type: "cancel"; streamId: string };
|
||||
|
||||
export type MeshGeometryStreamWorkerResponse =
|
||||
| { type: "lod"; streamId: string; geometry: MeshGeometryStreamChunkIR }
|
||||
| { type: "chunk"; streamId: string; chunk: MeshGeometryStreamChunkIR }
|
||||
| { type: "detached"; streamId: string; chunkIndex: number; positionsByteLength: number; indicesByteLength: number }
|
||||
| { type: "complete"; report: MeshGeometryStreamReportIR; chunks: MeshGeometryStreamChunkRecordIR[] }
|
||||
| { type: "cancelled"; report: MeshGeometryStreamReportIR }
|
||||
| { type: "error"; streamId?: string; code: string; message: string };
|
||||
|
||||
export class MeshGeometryStreamError extends Error {
|
||||
constructor(readonly code: "GEOMETRY_STREAM_INVALID" | "GEOMETRY_STREAM_RANGE_INVALID" | "GEOMETRY_STREAM_BUDGET_EXCEEDED", message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "MeshGeometryStreamError";
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function boundedInteger(value: unknown, name: string, minimum: number, maximum: number): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) {
|
||||
throw new MeshGeometryStreamError("GEOMETRY_STREAM_INVALID", `${name} must be an integer in [${minimum}, ${maximum}]`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function identifier(value: unknown, name: string): string {
|
||||
if (typeof value !== "string" || !/^[A-Za-z0-9:._-]{1,128}$/.test(value)) {
|
||||
throw new MeshGeometryStreamError("GEOMETRY_STREAM_INVALID", `${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseMeshGeometryStreamRequest(value: unknown): MeshGeometryStreamRequestIR {
|
||||
if (!record(value) || value.schemaVersion !== MESH_GEOMETRY_STREAM_SCHEMA) {
|
||||
throw new MeshGeometryStreamError("GEOMETRY_STREAM_INVALID", "unsupported stream request schema");
|
||||
}
|
||||
const request: MeshGeometryStreamRequestIR = {
|
||||
schemaVersion: MESH_GEOMETRY_STREAM_SCHEMA,
|
||||
streamId: identifier(value.streamId, "streamId"),
|
||||
meshId: identifier(value.meshId, "meshId"),
|
||||
triangleCount: boundedInteger(value.triangleCount, "triangleCount", 1, MESH_GEOMETRY_STREAM_MAX_TRIANGLES),
|
||||
chunkTriangleCount: boundedInteger(value.chunkTriangleCount, "chunkTriangleCount", 1, MESH_GEOMETRY_STREAM_MAX_CHUNK_TRIANGLES),
|
||||
lodTriangleCount: boundedInteger(value.lodTriangleCount, "lodTriangleCount", 1, MESH_GEOMETRY_STREAM_MAX_LOD_TRIANGLES),
|
||||
};
|
||||
if (request.lodTriangleCount > request.triangleCount) {
|
||||
throw new MeshGeometryStreamError("GEOMETRY_STREAM_INVALID", "lodTriangleCount exceeds triangleCount");
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
function hex(bytes: ArrayBuffer): string {
|
||||
return Array.from(new Uint8Array(bytes), (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
async function sha256(value: BufferSource): Promise<string> {
|
||||
return hex(await crypto.subtle.digest("SHA-256", value));
|
||||
}
|
||||
|
||||
export async function meshGeometryStreamChunkSha256(chunk: Omit<MeshGeometryStreamChunkIR, "sha256">): Promise<string> {
|
||||
const metadata = JSON.stringify([
|
||||
chunk.schemaVersion,
|
||||
chunk.streamId,
|
||||
chunk.meshId,
|
||||
chunk.chunkIndex,
|
||||
chunk.chunkCount,
|
||||
chunk.triangleOffset,
|
||||
chunk.triangleCount,
|
||||
chunk.vertexCount,
|
||||
chunk.byteLength,
|
||||
await sha256(chunk.positions),
|
||||
await sha256(chunk.indices),
|
||||
]);
|
||||
return sha256(new TextEncoder().encode(metadata));
|
||||
}
|
||||
|
||||
export async function meshGeometryStreamManifestSha256(
|
||||
request: MeshGeometryStreamRequestIR,
|
||||
chunks: readonly MeshGeometryStreamChunkRecordIR[],
|
||||
): Promise<string> {
|
||||
const canonical = JSON.stringify({
|
||||
schemaVersion: request.schemaVersion,
|
||||
streamId: request.streamId,
|
||||
meshId: request.meshId,
|
||||
triangleCount: request.triangleCount,
|
||||
chunkTriangleCount: request.chunkTriangleCount,
|
||||
lodTriangleCount: request.lodTriangleCount,
|
||||
chunks,
|
||||
});
|
||||
return sha256(new TextEncoder().encode(canonical));
|
||||
}
|
||||
|
||||
export class MeshGeometryStreamValidator {
|
||||
private readonly request: MeshGeometryStreamRequestIR;
|
||||
private readonly expectedChunkCount: number;
|
||||
private nextChunkIndex = 0;
|
||||
private nextTriangleOffset = 0;
|
||||
private transferredBytes = 0;
|
||||
private peakWorkingSetBytes = 0;
|
||||
private readonly chunks: MeshGeometryStreamChunkRecordIR[] = [];
|
||||
|
||||
constructor(value: MeshGeometryStreamRequestIR) {
|
||||
this.request = parseMeshGeometryStreamRequest(value);
|
||||
this.expectedChunkCount = Math.ceil(this.request.triangleCount / this.request.chunkTriangleCount);
|
||||
}
|
||||
|
||||
async accept(value: MeshGeometryStreamChunkIR): Promise<void> {
|
||||
if (!record(value) || value.schemaVersion !== MESH_GEOMETRY_STREAM_SCHEMA || value.streamId !== this.request.streamId || value.meshId !== this.request.meshId) {
|
||||
throw new MeshGeometryStreamError("GEOMETRY_STREAM_RANGE_INVALID", "chunk envelope does not match the active stream");
|
||||
}
|
||||
const expectedTriangles = Math.min(this.request.chunkTriangleCount, this.request.triangleCount - this.nextTriangleOffset);
|
||||
if (value.chunkIndex !== this.nextChunkIndex || value.chunkCount !== this.expectedChunkCount || value.triangleOffset !== this.nextTriangleOffset || value.triangleCount !== expectedTriangles) {
|
||||
throw new MeshGeometryStreamError("GEOMETRY_STREAM_RANGE_INVALID", "chunk ranges are not contiguous and complete");
|
||||
}
|
||||
if (!(value.positions instanceof ArrayBuffer) || !(value.indices instanceof ArrayBuffer) ||
|
||||
!Number.isSafeInteger(value.vertexCount) || value.vertexCount < 3 ||
|
||||
value.positions.byteLength !== value.vertexCount * 3 * Float32Array.BYTES_PER_ELEMENT ||
|
||||
value.indices.byteLength !== value.triangleCount * 3 * Uint32Array.BYTES_PER_ELEMENT ||
|
||||
value.byteLength !== value.positions.byteLength + value.indices.byteLength) {
|
||||
throw new MeshGeometryStreamError("GEOMETRY_STREAM_RANGE_INVALID", "chunk binary lengths are invalid");
|
||||
}
|
||||
const indices = new Uint32Array(value.indices);
|
||||
for (const index of indices) {
|
||||
if (index >= value.vertexCount) throw new MeshGeometryStreamError("GEOMETRY_STREAM_RANGE_INVALID", "chunk index exceeds its local vertex range");
|
||||
}
|
||||
const { sha256: declaredSha256, ...unsigned } = value;
|
||||
const digest = await meshGeometryStreamChunkSha256(unsigned);
|
||||
if (!/^[a-f0-9]{64}$/.test(declaredSha256) || digest !== declaredSha256) {
|
||||
throw new MeshGeometryStreamError("GEOMETRY_STREAM_RANGE_INVALID", "chunk SHA-256 mismatch");
|
||||
}
|
||||
this.chunks.push({
|
||||
chunkIndex: value.chunkIndex,
|
||||
triangleOffset: value.triangleOffset,
|
||||
triangleCount: value.triangleCount,
|
||||
vertexCount: value.vertexCount,
|
||||
byteLength: value.byteLength,
|
||||
sha256: declaredSha256,
|
||||
});
|
||||
this.nextChunkIndex += 1;
|
||||
this.nextTriangleOffset += value.triangleCount;
|
||||
this.transferredBytes += value.byteLength;
|
||||
this.peakWorkingSetBytes = Math.max(this.peakWorkingSetBytes, value.byteLength);
|
||||
}
|
||||
|
||||
async finish(): Promise<{ chunks: MeshGeometryStreamChunkRecordIR[]; transferredBytes: number; peakWorkingSetBytes: number; manifestSha256: string }> {
|
||||
if (this.nextChunkIndex !== this.expectedChunkCount || this.nextTriangleOffset !== this.request.triangleCount) {
|
||||
throw new MeshGeometryStreamError("GEOMETRY_STREAM_RANGE_INVALID", "stream ended before all triangle ranges arrived");
|
||||
}
|
||||
return {
|
||||
chunks: [...this.chunks],
|
||||
transferredBytes: this.transferredBytes,
|
||||
peakWorkingSetBytes: this.peakWorkingSetBytes,
|
||||
manifestSha256: await meshGeometryStreamManifestSha256(this.request, this.chunks),
|
||||
};
|
||||
}
|
||||
}
|
||||
172
web/protocol/oom-recovery.ts
Normal file
172
web/protocol/oom-recovery.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const OOM_RECOVERY_REPORT_SCHEMA = 1 as const;
|
||||
|
||||
export const OOM_RECOVERY_SCENARIOS = [
|
||||
"WASM_MAIN",
|
||||
"OPFS_STAGING",
|
||||
"GPU_RESOURCES",
|
||||
"NANOVDB_RESIDENT",
|
||||
] as const;
|
||||
|
||||
export type OOMRecoveryScenario = typeof OOM_RECOVERY_SCENARIOS[number];
|
||||
|
||||
export const OOM_FAULT_POINTS = [
|
||||
"WASM_MAIN_OPEN_INPUT",
|
||||
"WASM_MAIN_EDIT_COMMAND",
|
||||
"WASM_MAIN_SAVE_RESULT",
|
||||
"OPFS_STAGING_WRITE",
|
||||
"GPU_GEOMETRY_UPLOAD",
|
||||
"GPU_TEXTURE_UPLOAD",
|
||||
"NANOVDB_RESIDENT_BUFFER",
|
||||
"NANOVDB_PAGE_TABLE",
|
||||
] as const;
|
||||
|
||||
export type OOMFaultPoint = typeof OOM_FAULT_POINTS[number];
|
||||
|
||||
export const OOM_FAULT_ERROR: Record<OOMFaultPoint, { code: ErrorCode; stage: string }> = {
|
||||
WASM_MAIN_OPEN_INPUT: { code: "WASM_OUT_OF_MEMORY", stage: "WASM_OPEN_INPUT" },
|
||||
WASM_MAIN_EDIT_COMMAND: { code: "WASM_OUT_OF_MEMORY", stage: "MAIN_EDIT_COMMAND" },
|
||||
WASM_MAIN_SAVE_RESULT: { code: "WASM_OUT_OF_MEMORY", stage: "WASM_SAVE_RESULT" },
|
||||
OPFS_STAGING_WRITE: { code: "STORAGE_QUOTA", stage: "OPFS_STAGING_WRITE" },
|
||||
GPU_GEOMETRY_UPLOAD: { code: "GPU_GEOMETRY_BUDGET_EXCEEDED", stage: "GPU_GEOMETRY_UPLOAD" },
|
||||
GPU_TEXTURE_UPLOAD: { code: "GPU_TEXTURE_BUDGET_EXCEEDED", stage: "GPU_TEXTURE_UPLOAD" },
|
||||
NANOVDB_RESIDENT_BUFFER: { code: "NANOVDB_GPU_BUDGET_EXCEEDED", stage: "NANOVDB_RESIDENT_BUFFER" },
|
||||
NANOVDB_PAGE_TABLE: { code: "NANOVDB_GPU_BUDGET_EXCEEDED", stage: "NANOVDB_PAGE_TABLE" },
|
||||
};
|
||||
|
||||
export interface OOMFaultObservationIR {
|
||||
point: OOMFaultPoint;
|
||||
code: ErrorCode;
|
||||
stage: string;
|
||||
attemptedBytes: number;
|
||||
allocationCount: number;
|
||||
failAfterBytes?: number;
|
||||
failAfterCount?: number;
|
||||
}
|
||||
|
||||
export interface OOMMemoryReportIR {
|
||||
beforeBytes: number;
|
||||
peakBytes: number;
|
||||
afterBytes: number;
|
||||
releasedBytes: number;
|
||||
}
|
||||
|
||||
export interface OOMStateReportIR {
|
||||
revisionBefore: number;
|
||||
revisionAfter: number;
|
||||
hashBefore?: string;
|
||||
hashAfter?: string;
|
||||
temporaryResourcesBefore: number;
|
||||
temporaryResourcesPeak: number;
|
||||
temporaryResourcesAfter: number;
|
||||
}
|
||||
|
||||
export interface OOMRecoveryReportIR {
|
||||
schemaVersion: typeof OOM_RECOVERY_REPORT_SCHEMA;
|
||||
scenario: OOMRecoveryScenario;
|
||||
faults: OOMFaultObservationIR[];
|
||||
memory: OOMMemoryReportIR;
|
||||
state: OOMStateReportIR;
|
||||
recovery: {
|
||||
recovered: boolean;
|
||||
sameSession: boolean;
|
||||
restartedSession: boolean;
|
||||
tokenIsolated: boolean;
|
||||
};
|
||||
checks: string[];
|
||||
}
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`OOM_REPORT_INVALID: ${label}`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error(`OOM_REPORT_INVALID: ${label}`);
|
||||
return value as number;
|
||||
}
|
||||
|
||||
export function parseOOMRecoveryReport(value: unknown): OOMRecoveryReportIR {
|
||||
const source = record(value, "report must be an object");
|
||||
if (source.schemaVersion !== OOM_RECOVERY_REPORT_SCHEMA || !OOM_RECOVERY_SCENARIOS.includes(source.scenario as OOMRecoveryScenario)) {
|
||||
throw new Error("OOM_REPORT_INVALID: schema or scenario");
|
||||
}
|
||||
if (!Array.isArray(source.faults) || source.faults.length === 0) throw new Error("OOM_REPORT_INVALID: faults");
|
||||
const faults = source.faults.map((candidate, index): OOMFaultObservationIR => {
|
||||
const fault = record(candidate, `fault ${index}`);
|
||||
const point = fault.point as OOMFaultPoint;
|
||||
if (!OOM_FAULT_POINTS.includes(point)) throw new Error(`OOM_REPORT_INVALID: fault ${index} point`);
|
||||
const expected = OOM_FAULT_ERROR[point];
|
||||
if (fault.code !== expected.code || fault.stage !== expected.stage) throw new Error(`OOM_REPORT_INVALID: fault ${index} mapping`);
|
||||
const parsed: OOMFaultObservationIR = {
|
||||
point,
|
||||
code: expected.code,
|
||||
stage: expected.stage,
|
||||
attemptedBytes: integer(fault.attemptedBytes, `fault ${index} attemptedBytes`),
|
||||
allocationCount: integer(fault.allocationCount, `fault ${index} allocationCount`),
|
||||
};
|
||||
if (fault.failAfterBytes !== undefined) parsed.failAfterBytes = integer(fault.failAfterBytes, `fault ${index} failAfterBytes`);
|
||||
if (fault.failAfterCount !== undefined) parsed.failAfterCount = integer(fault.failAfterCount, `fault ${index} failAfterCount`);
|
||||
if (parsed.failAfterBytes === undefined && parsed.failAfterCount === undefined) throw new Error(`OOM_REPORT_INVALID: fault ${index} threshold`);
|
||||
return parsed;
|
||||
});
|
||||
|
||||
const rawMemory = record(source.memory, "memory");
|
||||
const memory: OOMMemoryReportIR = {
|
||||
beforeBytes: integer(rawMemory.beforeBytes, "memory.beforeBytes"),
|
||||
peakBytes: integer(rawMemory.peakBytes, "memory.peakBytes"),
|
||||
afterBytes: integer(rawMemory.afterBytes, "memory.afterBytes"),
|
||||
releasedBytes: integer(rawMemory.releasedBytes, "memory.releasedBytes"),
|
||||
};
|
||||
if (memory.peakBytes < memory.beforeBytes || memory.peakBytes < memory.afterBytes) throw new Error("OOM_REPORT_INVALID: memory peak");
|
||||
|
||||
const rawState = record(source.state, "state");
|
||||
const state: OOMStateReportIR = {
|
||||
revisionBefore: integer(rawState.revisionBefore, "state.revisionBefore"),
|
||||
revisionAfter: integer(rawState.revisionAfter, "state.revisionAfter"),
|
||||
temporaryResourcesBefore: integer(rawState.temporaryResourcesBefore, "state.temporaryResourcesBefore"),
|
||||
temporaryResourcesPeak: integer(rawState.temporaryResourcesPeak, "state.temporaryResourcesPeak"),
|
||||
temporaryResourcesAfter: integer(rawState.temporaryResourcesAfter, "state.temporaryResourcesAfter"),
|
||||
};
|
||||
if (rawState.hashBefore !== undefined) {
|
||||
if (typeof rawState.hashBefore !== "string" || !SHA256.test(rawState.hashBefore)) throw new Error("OOM_REPORT_INVALID: state.hashBefore");
|
||||
state.hashBefore = rawState.hashBefore;
|
||||
}
|
||||
if (rawState.hashAfter !== undefined) {
|
||||
if (typeof rawState.hashAfter !== "string" || !SHA256.test(rawState.hashAfter)) throw new Error("OOM_REPORT_INVALID: state.hashAfter");
|
||||
state.hashAfter = rawState.hashAfter;
|
||||
}
|
||||
if ((state.hashBefore === undefined) !== (state.hashAfter === undefined)) throw new Error("OOM_REPORT_INVALID: state hashes must be paired");
|
||||
if (state.temporaryResourcesPeak < state.temporaryResourcesBefore || state.temporaryResourcesPeak < state.temporaryResourcesAfter) {
|
||||
throw new Error("OOM_REPORT_INVALID: temporary resource peak");
|
||||
}
|
||||
|
||||
const rawRecovery = record(source.recovery, "recovery");
|
||||
const recovery = {
|
||||
recovered: rawRecovery.recovered,
|
||||
sameSession: rawRecovery.sameSession,
|
||||
restartedSession: rawRecovery.restartedSession,
|
||||
tokenIsolated: rawRecovery.tokenIsolated,
|
||||
};
|
||||
if (Object.values(recovery).some((candidate) => typeof candidate !== "boolean") || !recovery.recovered || !recovery.tokenIsolated || (!recovery.sameSession && !recovery.restartedSession)) {
|
||||
throw new Error("OOM_REPORT_INVALID: recovery");
|
||||
}
|
||||
if (!Array.isArray(source.checks) || source.checks.length === 0 || source.checks.some((check) => typeof check !== "string" || check.length === 0)) {
|
||||
throw new Error("OOM_REPORT_INVALID: checks");
|
||||
}
|
||||
|
||||
return { schemaVersion: OOM_RECOVERY_REPORT_SCHEMA, scenario: source.scenario as OOMRecoveryScenario, faults, memory, state, recovery: recovery as OOMRecoveryReportIR["recovery"], checks: [...new Set(source.checks as string[])] };
|
||||
}
|
||||
|
||||
export function parseOOMRecoverySuite(value: unknown): OOMRecoveryReportIR[] {
|
||||
if (!Array.isArray(value)) throw new Error("OOM_REPORT_INVALID: suite");
|
||||
const reports = value.map(parseOOMRecoveryReport);
|
||||
if (reports.length !== OOM_RECOVERY_SCENARIOS.length || new Set(reports.map((report) => report.scenario)).size !== OOM_RECOVERY_SCENARIOS.length) {
|
||||
throw new Error("OOM_REPORT_INVALID: suite must contain each scenario exactly once");
|
||||
}
|
||||
for (const scenario of OOM_RECOVERY_SCENARIOS) if (!reports.some((report) => report.scenario === scenario)) throw new Error(`OOM_REPORT_INVALID: missing ${scenario}`);
|
||||
return reports;
|
||||
}
|
||||
@@ -1,9 +1,25 @@
|
||||
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
|
||||
import type { ErrorCode } from "./error";
|
||||
|
||||
export const RELEASE_GATE_SCHEMA = 3 as const;
|
||||
export type ParityStatus = "LOCAL_EXACT" | "LOCAL_BOUNDED" | "SERVER" | "BLOCKED";
|
||||
export interface ParityFamilyEvidenceIR { id: string; name: string; status: ParityStatus; roadmapStatus: "completed" | "in_progress" | "planned"; completedSlices: string[]; blockedSlices: string[]; excludedSlices: string[]; acceptance: string[]; dependencies: string[] }
|
||||
export const RELEASE_GATE_SCHEMA = 4 as const;
|
||||
export type ParityStatus = "COMPLETE" | "BLOCKED";
|
||||
export type ReleaseClass = "LOCAL_EXACT" | "LOCAL_BOUNDED" | "SERVER" | "EXCLUDED";
|
||||
export type ReleaseStatus = "READY" | "BLOCKED";
|
||||
export interface ParityFamilyEvidenceIR {
|
||||
id: string;
|
||||
name: string;
|
||||
parityStatus: ParityStatus;
|
||||
releaseClass: ReleaseClass;
|
||||
releaseStatus: ReleaseStatus;
|
||||
roadmapStatus: "completed" | "in_progress" | "planned";
|
||||
completedSlices: string[];
|
||||
blockedSlices: string[];
|
||||
excludedSlices: string[];
|
||||
v1RequiredSlices: string[];
|
||||
v1ExcludedSlices: string[];
|
||||
acceptance: string[];
|
||||
dependencies: string[];
|
||||
}
|
||||
export interface ReleaseEvidenceIR {
|
||||
browser: { chromium: boolean };
|
||||
runtime: { offline: boolean; workerRestart: boolean; opfsRecovery: boolean };
|
||||
@@ -21,7 +37,9 @@ export class ReleaseGateValidationError extends Error {
|
||||
constructor(code: ErrorCode, message: string) { super(`${code}: ${message}`); this.name = "ReleaseGateValidationError"; this.code = code; }
|
||||
}
|
||||
|
||||
const STATUSES = new Set<ParityStatus>(["LOCAL_EXACT", "LOCAL_BOUNDED", "SERVER", "BLOCKED"]);
|
||||
const PARITY_STATUSES = new Set<ParityStatus>(["COMPLETE", "BLOCKED"]);
|
||||
const RELEASE_CLASSES = new Set<ReleaseClass>(["LOCAL_EXACT", "LOCAL_BOUNDED", "SERVER", "EXCLUDED"]);
|
||||
const RELEASE_STATUSES = new Set<ReleaseStatus>(["READY", "BLOCKED"]);
|
||||
function record(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
||||
function text(value: unknown, name: string, maximum = 256): string { if (typeof value !== "string" || value.length === 0 || value.length > maximum) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} is invalid`); return value; }
|
||||
function bool(value: unknown, name: string): boolean { if (typeof value !== "boolean") throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} must be boolean`); return value; }
|
||||
@@ -59,7 +77,59 @@ function assertDependencies(families: readonly ParityFamilyEvidenceIR[]): void {
|
||||
|
||||
export function parseReleaseManifest(value: unknown): ReleaseManifestIR {
|
||||
if (!record(value) || value.schemaVersion !== RELEASE_GATE_SCHEMA || !Array.isArray(value.families)) throw new ReleaseGateValidationError("PROTOCOL_MISMATCH", "Unsupported release manifest schema");
|
||||
const ids = new Set<string>(); const families = value.families.map((item, index): ParityFamilyEvidenceIR => { const name = `families[${index}]`; if (!record(item) || !STATUSES.has(item.status as ParityStatus) || !["completed", "in_progress", "planned"].includes(item.roadmapStatus as string)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} is invalid`); const id = text(item.id, `${name}.id`); if (ids.has(id)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `Duplicate family ${id}`); ids.add(id); const completedSlices = strings(item.completedSlices, `${name}.completedSlices`); const blockedSlices = strings(item.blockedSlices, `${name}.blockedSlices`); const excludedSlices = strings(item.excludedSlices ?? [], `${name}.excludedSlices`); const declared = [...completedSlices, ...blockedSlices, ...excludedSlices]; if (new Set(declared).size !== declared.length) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} declares a slice in more than one state`); if (item.status !== "BLOCKED" && completedSlices.length === 0) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} must declare completed slices`); return { id, name: text(item.name, `${name}.name`), status: item.status as ParityStatus, roadmapStatus: item.roadmapStatus as ParityFamilyEvidenceIR["roadmapStatus"], completedSlices, blockedSlices, excludedSlices, acceptance: strings(item.acceptance, `${name}.acceptance`), dependencies: strings(item.dependencies, `${name}.dependencies`) }; });
|
||||
const ids = new Set<string>();
|
||||
const families = value.families.map((item, index): ParityFamilyEvidenceIR => {
|
||||
const name = `families[${index}]`;
|
||||
if (!record(item) || !PARITY_STATUSES.has(item.parityStatus as ParityStatus) ||
|
||||
!RELEASE_CLASSES.has(item.releaseClass as ReleaseClass) || !RELEASE_STATUSES.has(item.releaseStatus as ReleaseStatus) ||
|
||||
!["completed", "in_progress", "planned"].includes(item.roadmapStatus as string)) {
|
||||
throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} is invalid`);
|
||||
}
|
||||
const id = text(item.id, `${name}.id`);
|
||||
if (ids.has(id)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `Duplicate family ${id}`);
|
||||
ids.add(id);
|
||||
const completedSlices = strings(item.completedSlices, `${name}.completedSlices`);
|
||||
const blockedSlices = strings(item.blockedSlices, `${name}.blockedSlices`);
|
||||
const excludedSlices = strings(item.excludedSlices ?? [], `${name}.excludedSlices`);
|
||||
const v1RequiredSlices = strings(item.v1RequiredSlices, `${name}.v1RequiredSlices`);
|
||||
const v1ExcludedSlices = strings(item.v1ExcludedSlices, `${name}.v1ExcludedSlices`);
|
||||
const declared = [...completedSlices, ...blockedSlices, ...excludedSlices];
|
||||
if (new Set(declared).size !== declared.length) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} declares a slice in more than one parity state`);
|
||||
if (v1RequiredSlices.length === 0 || new Set(v1RequiredSlices).size !== v1RequiredSlices.length || new Set(v1ExcludedSlices).size !== v1ExcludedSlices.length) {
|
||||
throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} has invalid V1 slices`);
|
||||
}
|
||||
const declaredStates = new Map(declared.map((slice) => [slice, completedSlices.includes(slice) ? "completed" : blockedSlices.includes(slice) ? "blocked" : "excluded"]));
|
||||
if (v1RequiredSlices.some((slice) => !declaredStates.has(slice)) || v1ExcludedSlices.some((slice) => !declaredStates.has(slice))) {
|
||||
throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} references an undeclared V1 slice`);
|
||||
}
|
||||
if (v1RequiredSlices.some((slice) => v1ExcludedSlices.includes(slice)) || v1ExcludedSlices.some((slice) => declaredStates.get(slice) === "completed")) {
|
||||
throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} overlaps required/excluded V1 slices or excludes a completed slice`);
|
||||
}
|
||||
const blockedRequired = v1RequiredSlices.filter((slice) => declaredStates.get(slice) !== "completed");
|
||||
if ((item.releaseStatus === "READY" && blockedRequired.length > 0) || (item.releaseStatus === "BLOCKED" && blockedRequired.length === 0)) {
|
||||
throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name}.releaseStatus disagrees with its V1 required slices`);
|
||||
}
|
||||
if (item.parityStatus === "COMPLETE" && (blockedSlices.length > 0 || excludedSlices.length > 0)) {
|
||||
throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name}.parityStatus cannot be COMPLETE with unresolved slices`);
|
||||
}
|
||||
const acceptance = strings(item.acceptance, `${name}.acceptance`);
|
||||
if (item.releaseStatus === "READY" && acceptance.length === 0) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} has no V1 acceptance command`);
|
||||
return {
|
||||
id,
|
||||
name: text(item.name, `${name}.name`),
|
||||
parityStatus: item.parityStatus as ParityStatus,
|
||||
releaseClass: item.releaseClass as ReleaseClass,
|
||||
releaseStatus: item.releaseStatus as ReleaseStatus,
|
||||
roadmapStatus: item.roadmapStatus as ParityFamilyEvidenceIR["roadmapStatus"],
|
||||
completedSlices,
|
||||
blockedSlices,
|
||||
excludedSlices,
|
||||
v1RequiredSlices,
|
||||
v1ExcludedSlices,
|
||||
acceptance,
|
||||
dependencies: strings(item.dependencies, `${name}.dependencies`),
|
||||
};
|
||||
});
|
||||
assertDependencies(families);
|
||||
const sourceSha256 = text(value.sourceSha256, "sourceSha256", 64); if (!/^[a-f0-9]{64}$/.test(sourceSha256)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", "sourceSha256 is invalid");
|
||||
return { schemaVersion: RELEASE_GATE_SCHEMA, source: text(value.source, "source", 2048), sourceSha256, generatedAt: utcTimestamp(value.generatedAt, "generatedAt"), families, evidence: parseEvidence(value.evidence) };
|
||||
@@ -68,7 +138,12 @@ export function parseReleaseManifest(value: unknown): ReleaseManifestIR {
|
||||
export function evaluateReleaseManifest(value: unknown): ReleaseGateEvaluationIR {
|
||||
const manifest = parseReleaseManifest(value); const missing: string[] = []; const issueCodes: ErrorCode[] = [];
|
||||
const add = (path: string, code: ErrorCode): void => { missing.push(path); if (!issueCodes.includes(code)) issueCodes.push(code); };
|
||||
manifest.families.forEach((family) => { if (family.status === "BLOCKED") add(`family.${family.id}`, "RELEASE_EVIDENCE_MISSING"); if (family.status !== "BLOCKED" && family.acceptance.length === 0) add(`family.${family.id}.acceptance`, "RELEASE_EVIDENCE_MISSING"); });
|
||||
manifest.families.forEach((family) => {
|
||||
if (family.releaseStatus !== "BLOCKED") return;
|
||||
for (const slice of family.v1RequiredSlices.filter((item) => !family.completedSlices.includes(item))) {
|
||||
add(`family.${family.id}.${slice}`, "RELEASE_EVIDENCE_MISSING");
|
||||
}
|
||||
});
|
||||
(Object.entries(manifest.evidence.browser) as [string, boolean][]).forEach(([key, ok]) => { if (!ok) add(`browser.${key}`, "RELEASE_TEST_CHANNEL_MISSING"); });
|
||||
(Object.entries(manifest.evidence.runtime) as [string, boolean][]).forEach(([key, ok]) => { if (!ok) add(`runtime.${key}`, "RELEASE_EVIDENCE_MISSING"); });
|
||||
(Object.entries(manifest.evidence.performance) as [string, boolean][]).forEach(([key, ok]) => { if (!ok) add(`performance.${key}`, "RELEASE_PERFORMANCE_MISSING"); });
|
||||
|
||||
375
web/tests/e2e/geometry-10m-performance.spec.ts
Normal file
375
web/tests/e2e/geometry-10m-performance.spec.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("streams ten-million-triangle SceneIR ranges into a bounded interactive LOD", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const [largeGeometry, viewportModule, offscreenModule, storageModule, engineModule] = await Promise.all([
|
||||
import("/src/performance/large-geometry.ts"),
|
||||
import("/src/three-adapter/viewport.ts"),
|
||||
import("/src/three-adapter/offscreen-viewport.ts"),
|
||||
import("/src/storage/StorageClient.ts"),
|
||||
import("/src/engine-client/WebEngineClient.ts"),
|
||||
]);
|
||||
const { MeshGeometryStreamValidator, parseMeshGeometryStreamRequest, buildLODCacheKey, encodeLODGeometry, decodeLODGeometry } = largeGeometry;
|
||||
const { ViewportRenderer } = viewportModule;
|
||||
const { OffscreenViewportRenderer } = offscreenModule;
|
||||
const { StorageClient } = storageModule;
|
||||
const { WebEngineClient } = engineModule;
|
||||
const worker = new Worker("/src/workers/geometry-stream.worker.ts", { type: "module" });
|
||||
const started = performance.now();
|
||||
const heap = (): number => Number((performance as Performance & { memory?: { usedJSHeapSize?: number } }).memory?.usedJSHeapSize ?? 0);
|
||||
const baselineHeapBytes = heap();
|
||||
let peakHeapBytes = baselineHeapBytes;
|
||||
|
||||
const engine = new WebEngineClient({ timeoutMs: 60_000 });
|
||||
const wasm = await engine.init();
|
||||
|
||||
const createCanvas = (): HTMLCanvasElement => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 320;
|
||||
canvas.height = 240;
|
||||
canvas.style.cssText = "position:fixed;left:0;top:0;width:320px;height:240px";
|
||||
document.body.append(canvas);
|
||||
return canvas;
|
||||
};
|
||||
const waitFor = async (condition: () => boolean, timeoutMs = 30_000): Promise<void> => {
|
||||
const deadline = performance.now() + timeoutMs;
|
||||
while (!condition()) {
|
||||
if (performance.now() > deadline) throw new Error("GEOMETRY_STREAM_BUDGET_EXCEEDED: viewport did not become interactive");
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
};
|
||||
const visiblePixels = (renderer: InstanceType<typeof ViewportRenderer>): number => {
|
||||
const gl = renderer.renderer.getContext();
|
||||
const pixels = new Uint8Array(64 * 64 * 4);
|
||||
gl.readPixels(
|
||||
Math.max(0, Math.floor((gl.drawingBufferWidth - 64) / 2)),
|
||||
Math.max(0, Math.floor((gl.drawingBufferHeight - 64) / 2)),
|
||||
64,
|
||||
64,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
pixels,
|
||||
);
|
||||
let visible = 0;
|
||||
for (let index = 0; index < pixels.length; index += 4) {
|
||||
if (pixels[index] + pixels[index + 1] + pixels[index + 2] > 80) visible++;
|
||||
}
|
||||
return visible;
|
||||
};
|
||||
const snapshot = (meshId: string, triangleCount: number) => ({
|
||||
schemaVersion: 1 as const,
|
||||
revision: 1,
|
||||
sceneId: "scene:geometry-10m",
|
||||
source: { kind: "mock" as const },
|
||||
coordinateSystem: { upAxis: "Z" as const, forwardAxis: "-Y" as const, handedness: "RIGHT" as const, unitSystem: 0, unitScale: 1 },
|
||||
activeObjectId: "object:geometry-10m",
|
||||
frame: { current: 1, start: 1, end: 250 },
|
||||
nodes: [{
|
||||
id: "object:geometry-10m", name: "Ten Million Triangle Mesh", type: "MESH" as const, parentId: null, dataId: meshId,
|
||||
visible: true, selectable: true,
|
||||
localMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
|
||||
worldMatrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
|
||||
transform: { translation: [0, 0, 0] as [number, number, number], rotationEuler: [0, 0, 0] as [number, number, number], scale: [1, 1, 1] as [number, number, number], rotationMode: 1 },
|
||||
}],
|
||||
meshes: [{
|
||||
id: meshId, name: "Ten Million Triangle Mesh", vertexCount: 5_100_000, edgeCount: 0, faceCount: triangleCount,
|
||||
cornerCount: triangleCount * 3, triangleCount, geometryStatus: "binary" as const, geometryBufferId: meshId,
|
||||
bounds: { min: [-2, -2, -0.1] as [number, number, number], max: [2, 2, 0.1] as [number, number, number] },
|
||||
}],
|
||||
materials: [], cameras: [], lights: [], worlds: [], images: [], animations: [], collections: [],
|
||||
scenes: [{ id: "scene:geometry-10m", name: "Geometry 10M" }],
|
||||
});
|
||||
|
||||
type Geometry = import("/src/performance/large-geometry.ts").MeshGeometryStreamChunkIR;
|
||||
type Report = import("/src/performance/large-geometry.ts").MeshGeometryStreamReportIR;
|
||||
interface SessionResult {
|
||||
report: Report;
|
||||
lod?: Geometry;
|
||||
acceptedChunks: number;
|
||||
detachedTransfers: number;
|
||||
lateChunks: number;
|
||||
validator?: Awaited<ReturnType<InstanceType<typeof MeshGeometryStreamValidator>["finish"]>>;
|
||||
viewport?: Awaited<ReturnType<typeof renderLOD>>;
|
||||
cancelLatencyMs?: number;
|
||||
}
|
||||
|
||||
const renderLOD = async (geometry: Geometry, acceptedChunks: () => number) => {
|
||||
const meshId = "mesh:geometry-10m";
|
||||
const lodMeshId = `${meshId}:lod:0`;
|
||||
const geometryBuffer = {
|
||||
schemaVersion: 1 as const,
|
||||
meshId: lodMeshId,
|
||||
byteLength: geometry.byteLength,
|
||||
positions: geometry.positions,
|
||||
indices: geometry.indices,
|
||||
};
|
||||
const sourceBuffer = { ...geometryBuffer, meshId };
|
||||
const level = {
|
||||
level: 0,
|
||||
meshId: lodMeshId,
|
||||
triangleBudget: geometry.triangleCount,
|
||||
outputTriangleCount: geometry.triangleCount,
|
||||
outputVertexCount: geometry.vertexCount,
|
||||
geometryBuffers: [geometryBuffer],
|
||||
};
|
||||
const lowColumns = 32;
|
||||
const lowRows = 32;
|
||||
const lowPositions = new Float32Array((lowColumns + 1) * (lowRows + 1) * 3);
|
||||
for (let y = 0; y <= lowRows; y++) for (let x = 0; x <= lowColumns; x++) {
|
||||
const offset = (y * (lowColumns + 1) + x) * 3;
|
||||
lowPositions.set([x / lowColumns * 4 - 2, y / lowRows * 4 - 2, 0], offset);
|
||||
}
|
||||
const lowIndices = new Uint32Array(lowColumns * lowRows * 6);
|
||||
for (let cell = 0; cell < lowColumns * lowRows; cell++) {
|
||||
const x = cell % lowColumns;
|
||||
const y = Math.floor(cell / lowColumns);
|
||||
const a = y * (lowColumns + 1) + x;
|
||||
lowIndices.set([a, a + 1, a + lowColumns + 2, a, a + lowColumns + 2, a + lowColumns + 1], cell * 6);
|
||||
}
|
||||
const lowGeometryBuffer = {
|
||||
schemaVersion: 1 as const,
|
||||
meshId: `${meshId}:lod:1`,
|
||||
byteLength: lowPositions.byteLength + lowIndices.byteLength,
|
||||
positions: lowPositions.buffer,
|
||||
indices: lowIndices.buffer,
|
||||
};
|
||||
const lowLevel = {
|
||||
level: 1,
|
||||
meshId: lowGeometryBuffer.meshId,
|
||||
triangleBudget: lowColumns * lowRows * 2,
|
||||
outputTriangleCount: lowColumns * lowRows * 2,
|
||||
outputVertexCount: lowPositions.length / 3,
|
||||
geometryBuffers: [lowGeometryBuffer],
|
||||
};
|
||||
const cacheKey = buildLODCacheKey({ objectId: "object:geometry-10m", meshRevision: 1, modifierStackHash: "none", profileHash: "geometry-10m-lod-v1", poseOrRestState: "REST" });
|
||||
const encoded = encodeLODGeometry([level, lowLevel]);
|
||||
const encodedBytes = encoded.byteLength;
|
||||
const projectId = `geometry-10m-${Date.now()}`;
|
||||
const manifest = {
|
||||
schemaVersion: 1 as const,
|
||||
meshId,
|
||||
sourceMeshRevision: 1,
|
||||
levels: [
|
||||
{ level: 0, sourceMeshRevision: 1, triangleBudget: geometry.triangleCount, meshId: lodMeshId },
|
||||
{ level: 1, sourceMeshRevision: 1, triangleBudget: lowLevel.triangleBudget, meshId: lowLevel.meshId },
|
||||
],
|
||||
cacheKey,
|
||||
objectId: "object:geometry-10m",
|
||||
modifierStackHash: "none",
|
||||
profileHash: "geometry-10m-lod-v1",
|
||||
poseOrRestState: "REST" as const,
|
||||
generatedAt: "2026-08-14T00:00:00.000Z",
|
||||
byteLength: encodedBytes,
|
||||
};
|
||||
const writer = new StorageClient();
|
||||
const saved = await writer.saveLOD(projectId, cacheKey, encoded);
|
||||
await writer.putLODManifest(projectId, manifest);
|
||||
writer.terminate();
|
||||
const reader = new StorageClient();
|
||||
const reopenedManifest = await reader.getLODManifest(projectId, cacheKey);
|
||||
const reopened = await reader.readLOD(projectId, cacheKey);
|
||||
const decoded = decodeLODGeometry(reopened.data);
|
||||
|
||||
let selectedObjectId = "";
|
||||
const mainCanvas = createCanvas();
|
||||
const main = new ViewportRenderer(mainCanvas, (objectId) => { selectedObjectId = objectId; });
|
||||
main.setSnapshot(snapshot(meshId, 10_000_000), [sourceBuffer]);
|
||||
main.installLODLevels(meshId, [level, lowLevel]);
|
||||
main.camera.position.set(0.1, -0.1, 0.1);
|
||||
const nearLOD = main.updateLODSelection().get(meshId)?.level;
|
||||
main.camera.position.set(100, -100, 100);
|
||||
const farLOD = main.updateLODSelection().get(meshId)?.level;
|
||||
main.camera.position.set(4.5, -4.5, 3.5);
|
||||
main.updateLODSelection();
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const mainVisible = visiblePixels(main);
|
||||
const bounds = mainCanvas.getBoundingClientRect();
|
||||
mainCanvas.dispatchEvent(new MouseEvent("click", { clientX: bounds.left + bounds.width / 2, clientY: bounds.top + bounds.height / 2, bubbles: true }));
|
||||
mainCanvas.dispatchEvent(new WheelEvent("wheel", { deltaY: -20, bubbles: true, cancelable: true }));
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const mainGeometries = main.renderer.info.memory.geometries;
|
||||
|
||||
const offscreenCanvas = createCanvas();
|
||||
const offscreen = new OffscreenViewportRenderer(offscreenCanvas);
|
||||
offscreen.setSnapshot(snapshot(meshId, 10_000_000), [sourceBuffer]);
|
||||
await waitFor(() => Number(offscreenCanvas.dataset.rendererPixels ?? 0) > 0);
|
||||
const offscreenVisible = Number(offscreenCanvas.dataset.rendererPixels ?? 0);
|
||||
offscreenCanvas.dispatchEvent(new WheelEvent("wheel", { deltaY: 20, bubbles: true, cancelable: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
const firstInteractiveMs = Math.round(performance.now() - started);
|
||||
const chunksAtFirstInteractive = acceptedChunks();
|
||||
const estimatedGpuBytes = geometry.byteLength * 3 + lowGeometryBuffer.byteLength;
|
||||
main.dispose();
|
||||
offscreen.dispose();
|
||||
mainCanvas.remove();
|
||||
offscreenCanvas.remove();
|
||||
const deleted = await reader.deleteLOD(projectId, cacheKey);
|
||||
const missing = await reader.getLODManifest(projectId, cacheKey);
|
||||
reader.terminate();
|
||||
|
||||
return {
|
||||
firstInteractiveMs,
|
||||
chunksAtFirstInteractive,
|
||||
mainVisible,
|
||||
offscreenVisible,
|
||||
selectedObjectId,
|
||||
lodSelections: [nearLOD, farLOD],
|
||||
mainGeometries,
|
||||
estimatedGpuBytes,
|
||||
cache: {
|
||||
key: cacheKey,
|
||||
savedBytes: saved.bytes,
|
||||
encodedBytes,
|
||||
reopenedBytes: reopened.bytes,
|
||||
persisted: reopenedManifest.persisted,
|
||||
decodedTriangles: decoded.map((item) => item.outputTriangleCount),
|
||||
deleted: deleted.persisted && !missing.persisted,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const runSession = (
|
||||
streamId: string,
|
||||
cancelAfterChunks?: number,
|
||||
onLOD?: (geometry: Geometry, acceptedChunks: () => number) => Promise<Awaited<ReturnType<typeof renderLOD>>>,
|
||||
): Promise<SessionResult> => {
|
||||
const request = parseMeshGeometryStreamRequest({
|
||||
schemaVersion: 1,
|
||||
streamId,
|
||||
meshId: "mesh:geometry-10m",
|
||||
triangleCount: 10_000_000,
|
||||
chunkTriangleCount: 250_000,
|
||||
lodTriangleCount: 20_000,
|
||||
});
|
||||
const validator = new MeshGeometryStreamValidator(request);
|
||||
let acceptedChunks = 0;
|
||||
let detachedTransfers = 0;
|
||||
let lateChunks = 0;
|
||||
let cancellationRequested = false;
|
||||
let cancellationStarted = 0;
|
||||
let lod: Geometry | undefined;
|
||||
let viewport: Promise<Awaited<ReturnType<typeof renderLOD>>> | undefined;
|
||||
return new Promise((resolve, reject) => {
|
||||
worker.onerror = (event) => reject(new Error(event.message));
|
||||
worker.onmessage = async (event: MessageEvent<import("/src/performance/large-geometry.ts").MeshGeometryStreamWorkerResponse>) => {
|
||||
try {
|
||||
const message = event.data;
|
||||
if (message.type === "error") throw new Error(`${message.code}: ${message.message}`);
|
||||
if (message.type === "detached") {
|
||||
if (message.positionsByteLength !== 0 || message.indicesByteLength !== 0) throw new Error("GEOMETRY_STREAM_BUDGET_EXCEEDED: sender retained transferred buffers");
|
||||
detachedTransfers++;
|
||||
return;
|
||||
}
|
||||
if (message.type === "lod") {
|
||||
lod = message.geometry;
|
||||
if (onLOD) viewport = onLOD(lod, () => acceptedChunks);
|
||||
worker.postMessage({ type: "ack", streamId, chunkIndex: -1 });
|
||||
return;
|
||||
}
|
||||
if (message.type === "chunk") {
|
||||
if (cancellationRequested) { lateChunks++; return; }
|
||||
await validator.accept(message.chunk);
|
||||
acceptedChunks++;
|
||||
peakHeapBytes = Math.max(peakHeapBytes, heap());
|
||||
if (cancelAfterChunks !== undefined && acceptedChunks >= cancelAfterChunks) {
|
||||
cancellationRequested = true;
|
||||
cancellationStarted = performance.now();
|
||||
worker.postMessage({ type: "cancel", streamId });
|
||||
}
|
||||
else worker.postMessage({ type: "ack", streamId, chunkIndex: message.chunk.chunkIndex });
|
||||
return;
|
||||
}
|
||||
if (message.type === "cancelled") {
|
||||
await new Promise((done) => setTimeout(done, 50));
|
||||
resolve({ report: message.report, lod, acceptedChunks, detachedTransfers, lateChunks, cancelLatencyMs: Math.round(performance.now() - cancellationStarted) });
|
||||
return;
|
||||
}
|
||||
if (message.type === "complete") {
|
||||
const validated = await validator.finish();
|
||||
const rendered = await viewport;
|
||||
resolve({ report: message.report, lod, acceptedChunks, detachedTransfers, lateChunks, validator: validated, viewport: rendered });
|
||||
}
|
||||
}
|
||||
catch (error) { reject(error); }
|
||||
};
|
||||
worker.postMessage({ type: "start", request });
|
||||
});
|
||||
};
|
||||
|
||||
const cancelled = await runSession("geometry-10m-cancel", 2);
|
||||
const completed = await runSession("geometry-10m-complete", undefined, renderLOD);
|
||||
const invalidCode = await new Promise<string>((resolve, reject) => {
|
||||
worker.onerror = (event) => reject(new Error(event.message));
|
||||
worker.onmessage = (event: MessageEvent<import("/src/performance/large-geometry.ts").MeshGeometryStreamWorkerResponse>) => {
|
||||
if (event.data.type === "error") resolve(event.data.code);
|
||||
};
|
||||
worker.postMessage({ type: "start", request: { schemaVersion: 1, streamId: "geometry-invalid", meshId: "mesh:invalid", triangleCount: 0, chunkTriangleCount: 1, lodTriangleCount: 1 } });
|
||||
});
|
||||
worker.terminate();
|
||||
engine.terminate();
|
||||
|
||||
const recoveryCanvas = createCanvas();
|
||||
const recovery = new ViewportRenderer(recoveryCanvas);
|
||||
const recoveryPositions = new Float32Array([-1, -1, 0, 1, -1, 0, 0, 1, 0]).buffer;
|
||||
const recoveryIndices = new Uint32Array([0, 1, 2]).buffer;
|
||||
recovery.setSnapshot(snapshot("mesh:geometry-10m", 1), [{ schemaVersion: 1, meshId: "mesh:geometry-10m", byteLength: recoveryPositions.byteLength + recoveryIndices.byteLength, positions: recoveryPositions, indices: recoveryIndices }]);
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const recoveryVisible = visiblePixels(recovery);
|
||||
recovery.dispose();
|
||||
recoveryCanvas.remove();
|
||||
|
||||
return {
|
||||
elapsedMs: Math.round(performance.now() - started),
|
||||
baselineHeapBytes,
|
||||
peakHeapBytes,
|
||||
wasmAllocatedBytes: wasm.allocatedBytes,
|
||||
invalidCode,
|
||||
cancelled,
|
||||
completed,
|
||||
recoveryVisible,
|
||||
};
|
||||
});
|
||||
|
||||
console.log("geometry-10m-performance", JSON.stringify({
|
||||
triangleCount: result.completed.report.triangleCount,
|
||||
chunkCount: result.completed.report.chunkCount,
|
||||
transferredBytes: result.completed.report.transferredBytes,
|
||||
peakWorkingSetBytes: result.completed.report.peakWorkingSetBytes,
|
||||
manifestSha256: result.completed.report.manifestSha256,
|
||||
firstInteractiveMs: result.completed.viewport?.firstInteractiveMs,
|
||||
chunksAtFirstInteractive: result.completed.viewport?.chunksAtFirstInteractive,
|
||||
estimatedGpuBytes: result.completed.viewport?.estimatedGpuBytes,
|
||||
wasmAllocatedBytes: result.wasmAllocatedBytes,
|
||||
cancelLatencyMs: result.cancelled.cancelLatencyMs,
|
||||
elapsedMs: result.elapsedMs,
|
||||
}));
|
||||
|
||||
expect(result.invalidCode).toBe("GEOMETRY_STREAM_INVALID");
|
||||
expect(result.cancelled.report).toMatchObject({ status: "CANCELLED", chunkCount: 2, triangleCount: 500_000 });
|
||||
expect(result.cancelled.acceptedChunks).toBe(2);
|
||||
expect(result.cancelled.lateChunks).toBe(0);
|
||||
expect(result.cancelled.cancelLatencyMs).toBeLessThan(2_000);
|
||||
expect(result.completed.report).toMatchObject({ status: "COMPLETED", triangleCount: 10_000_000, chunkCount: 40 });
|
||||
expect(result.completed.acceptedChunks).toBe(40);
|
||||
expect(result.completed.detachedTransfers).toBe(41);
|
||||
expect(result.completed.report.transferredBytes).toBe(result.completed.validator?.transferredBytes);
|
||||
expect(result.completed.report.peakWorkingSetBytes).toBeLessThanOrEqual(8 * 1024 * 1024);
|
||||
expect(result.completed.report.manifestSha256).toBe(result.completed.validator?.manifestSha256);
|
||||
expect(result.completed.report.manifestSha256).toBe("224738e0c3aa28ae42167bf8974455a3d0f940f59f19eb51c5768f435c66b24f");
|
||||
expect(result.completed.viewport?.firstInteractiveMs).toBeLessThan(60_000);
|
||||
expect(result.completed.viewport?.chunksAtFirstInteractive).toBeLessThan(40);
|
||||
expect(result.completed.viewport?.mainVisible).toBeGreaterThan(100);
|
||||
expect(result.completed.viewport?.offscreenVisible).toBeGreaterThan(10);
|
||||
expect(result.completed.viewport?.selectedObjectId).toBe("object:geometry-10m");
|
||||
expect(result.completed.viewport?.lodSelections).toEqual([0, 1]);
|
||||
expect(result.completed.viewport?.estimatedGpuBytes).toBeLessThan(16 * 1024 * 1024);
|
||||
expect(result.completed.viewport?.cache).toMatchObject({ persisted: true, decodedTriangles: [20_000, 2_048], deleted: true });
|
||||
expect(result.completed.viewport?.cache.savedBytes).toBe(result.completed.viewport?.cache.encodedBytes);
|
||||
expect(result.completed.viewport?.cache.reopenedBytes).toBe(result.completed.viewport?.cache.encodedBytes);
|
||||
expect(result.wasmAllocatedBytes).toBeLessThan(512 * 1024 * 1024);
|
||||
if (result.baselineHeapBytes > 0) expect(result.peakHeapBytes - result.baselineHeapBytes).toBeLessThan(768 * 1024 * 1024);
|
||||
expect(result.recoveryVisible).toBeGreaterThan(100);
|
||||
expect(result.elapsedMs).toBeLessThan(150_000);
|
||||
});
|
||||
272
web/tests/e2e/long-media-performance.spec.ts
Normal file
272
web/tests/e2e/long-media-performance.spec.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const imagePath = path.resolve(import.meta.dirname, "../../../tests/files/web/media/sequencer-frame.png");
|
||||
const audioPath = path.resolve(import.meta.dirname, "../../../tests/files/web/media/sequencer-silence.wav");
|
||||
|
||||
test("indexes, seeks, cancels and reopens a bounded one-million-frame media timeline", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const image = fs.readFileSync(imagePath);
|
||||
const audio = fs.readFileSync(audioPath);
|
||||
const result = await page.evaluate(async ({ imageBytes, audioBytes }) => {
|
||||
const [mediaModule, storageModule] = await Promise.all([
|
||||
import("/src/sequencer/LongMediaTimeline.ts"),
|
||||
import("/src/storage/StorageClient.ts"),
|
||||
]);
|
||||
const {
|
||||
buildLongMediaTimelineIndex,
|
||||
deserializeLongMediaSessionManifest,
|
||||
gateSequencerCodec,
|
||||
parseLongMediaSessionManifest,
|
||||
sequencerRuntimeCapabilities,
|
||||
serializeLongMediaSessionManifest,
|
||||
} = mediaModule;
|
||||
const { StorageClient } = storageModule;
|
||||
const started = performance.now();
|
||||
const projectId = `long-media-${Date.now()}`;
|
||||
const cacheMaxBytes = 20 * 1024;
|
||||
const frameCount = 1_000_000;
|
||||
const stripFrames = 100;
|
||||
const imageStripCount = frameCount / stripFrames;
|
||||
const timeline = {
|
||||
schemaVersion: 1 as const,
|
||||
id: "sequencer:long-media",
|
||||
revision: 1,
|
||||
frameStart: 0,
|
||||
frameEnd: frameCount,
|
||||
fpsNumerator: 24_000,
|
||||
fpsDenominator: 1_001,
|
||||
strips: [
|
||||
{
|
||||
id: "strip:long-audio", name: "Long Audio", type: "SOUND" as const, channel: 1,
|
||||
frameStart: 0, frameEnd: frameCount, sourceStart: 0, sourceEnd: frameCount,
|
||||
speed: 1, muted: false, locked: false, sourceId: "media:audio", sourcePath: "//media/sequencer-silence.wav", mimeType: "audio/wav",
|
||||
},
|
||||
...Array.from({ length: imageStripCount }, (_, index) => ({
|
||||
id: `strip:image:${index}`, name: `Image ${index}`, type: "IMAGE" as const, channel: 2,
|
||||
frameStart: index * stripFrames, frameEnd: (index + 1) * stripFrames, sourceStart: 0, sourceEnd: 1,
|
||||
speed: 1, muted: false, locked: false, sourceId: "media:image", sourcePath: "//media/sequencer-frame.png", mimeType: "image/png",
|
||||
})),
|
||||
],
|
||||
};
|
||||
|
||||
const writer = new StorageClient();
|
||||
const storedImage = await writer.putAsset(projectId, imageBytes.buffer.slice(imageBytes.byteOffset, imageBytes.byteOffset + imageBytes.byteLength), "image/png", "media/sequencer-frame.png");
|
||||
const storedAudio = await writer.putAsset(projectId, audioBytes.buffer.slice(audioBytes.byteOffset, audioBytes.byteOffset + audioBytes.byteLength), "audio/wav", "media/sequencer-silence.wav");
|
||||
|
||||
const cancellation = new AbortController();
|
||||
const cancelledBuild = buildLongMediaTimelineIndex(timeline, cancellation.signal)
|
||||
.then(() => "unexpected-success")
|
||||
.catch((error: unknown) => error instanceof Error ? error.message.split(":", 1)[0] : String(error));
|
||||
setTimeout(() => cancellation.abort(), 0);
|
||||
const indexCancelCode = await cancelledBuild;
|
||||
const indexStarted = performance.now();
|
||||
const directIndex = await buildLongMediaTimelineIndex(timeline, new AbortController().signal);
|
||||
const indexBuildMs = Math.round(performance.now() - indexStarted);
|
||||
const seekCancellation = new AbortController();
|
||||
seekCancellation.abort();
|
||||
let seekCancelCode = "";
|
||||
try { directIndex.resolve(0, seekCancellation.signal); }
|
||||
catch (error) { seekCancelCode = error instanceof Error ? error.message.split(":", 1)[0] : String(error); }
|
||||
|
||||
interface WorkerClient {
|
||||
worker: Worker;
|
||||
init(timelineValue: unknown, assets: Array<{ sourceId: string; data: ArrayBuffer }>): Promise<{ stripCount: number; bucketCount: number; referenceCount: number; estimatedBytes: number }>;
|
||||
seek(frame: number, decodeDelayMs?: number): Promise<import("/src/sequencer/LongMediaTimeline.ts").LongMediaSeekResultIR>;
|
||||
cancel(): void;
|
||||
dispose(): Promise<number>;
|
||||
terminate(): void;
|
||||
}
|
||||
const createWorkerClient = (): WorkerClient => {
|
||||
const worker = new Worker("/src/workers/long-media.worker.ts", { type: "module" });
|
||||
let counter = 0;
|
||||
let readyResolve: ((value: { stripCount: number; bucketCount: number; referenceCount: number; estimatedBytes: number }) => void) | undefined;
|
||||
let readyReject: ((reason: Error) => void) | undefined;
|
||||
let disposeResolve: ((bytes: number) => void) | undefined;
|
||||
const pending = new Map<string, { resolve: (value: import("/src/sequencer/LongMediaTimeline.ts").LongMediaSeekResultIR) => void; reject: (reason: Error) => void }>();
|
||||
worker.onmessage = (event: MessageEvent<any>) => {
|
||||
const message = event.data;
|
||||
if (message.type === "ready") { readyResolve?.(message.index); readyResolve = undefined; readyReject = undefined; return; }
|
||||
if (message.type === "disposed") { disposeResolve?.(message.cacheBytes); disposeResolve = undefined; return; }
|
||||
if (message.type === "error") {
|
||||
if (message.requestId) { pending.get(message.requestId)?.reject(new Error(message.message)); pending.delete(message.requestId); }
|
||||
else { readyReject?.(new Error(message.message)); readyResolve = undefined; readyReject = undefined; }
|
||||
return;
|
||||
}
|
||||
if (message.type === "seekResult") {
|
||||
pending.get(message.requestId)?.resolve(message.result);
|
||||
pending.delete(message.requestId);
|
||||
}
|
||||
};
|
||||
worker.onerror = (event) => {
|
||||
const error = new Error(event.message);
|
||||
readyReject?.(error);
|
||||
for (const request of pending.values()) request.reject(error);
|
||||
pending.clear();
|
||||
};
|
||||
return {
|
||||
worker,
|
||||
init: (timelineValue, assets) => new Promise((resolve, reject) => {
|
||||
readyResolve = resolve;
|
||||
readyReject = reject;
|
||||
worker.postMessage({ type: "init", timeline: timelineValue, assets, cacheMaxBytes }, assets.map((asset) => asset.data));
|
||||
}),
|
||||
seek: (frame, decodeDelayMs = 0) => new Promise((resolve, reject) => {
|
||||
const requestId = `seek-${++counter}`;
|
||||
pending.set(requestId, { resolve, reject });
|
||||
worker.postMessage({ type: "seek", requestId, frame, decodeDelayMs });
|
||||
}),
|
||||
cancel: () => worker.postMessage({ type: "cancel" }),
|
||||
dispose: () => new Promise((resolve) => { disposeResolve = resolve; worker.postMessage({ type: "dispose" }); }),
|
||||
terminate: () => worker.terminate(),
|
||||
};
|
||||
};
|
||||
|
||||
const sourceImage = await writer.readAsset(projectId, storedImage.sha256);
|
||||
const sourceAudio = await writer.readAsset(projectId, storedAudio.sha256);
|
||||
const client = createWorkerClient();
|
||||
const workerIndex = await client.init(timeline, [
|
||||
{ sourceId: "media:image", data: sourceImage.data },
|
||||
{ sourceId: "media:audio", data: sourceAudio.data },
|
||||
]);
|
||||
const coldStarted = performance.now();
|
||||
const cold = await client.seek(123_450);
|
||||
const coldSeekMs = Math.round(performance.now() - coldStarted);
|
||||
const hotStarted = performance.now();
|
||||
const hot = await client.seek(123_450);
|
||||
const hotSeekMs = Math.round(performance.now() - hotStarted);
|
||||
const endpoints = await Promise.all([0, 500_000, 999_999].map((frame) => client.seek(frame)));
|
||||
const randomFrames = Array.from({ length: 64 }, (_, index) => (index * 104_729) % frameCount);
|
||||
const randomStarted = performance.now();
|
||||
const random = [];
|
||||
for (const frame of randomFrames) random.push(await client.seek(frame));
|
||||
const randomSeekMs = Math.round(performance.now() - randomStarted);
|
||||
|
||||
const supersededPromise = client.seek(250_000, 50);
|
||||
const latestPromise = client.seek(765_432, 0);
|
||||
const [superseded, latest] = await Promise.all([supersededPromise, latestPromise]);
|
||||
const cancelledSeekPromise = client.seek(333_333, 100);
|
||||
setTimeout(() => client.cancel(), 0);
|
||||
const cancelledSeek = await cancelledSeekPromise;
|
||||
|
||||
const manifest = parseLongMediaSessionManifest({
|
||||
schemaVersion: 1,
|
||||
timeline,
|
||||
currentFrame: latest.frame,
|
||||
cacheMaxBytes,
|
||||
assets: [
|
||||
{ sourceId: "media:image", sha256: storedImage.sha256, mimeType: "image/png" },
|
||||
{ sourceId: "media:audio", sha256: storedAudio.sha256, mimeType: "audio/wav" },
|
||||
],
|
||||
});
|
||||
const serialized = serializeLongMediaSessionManifest(manifest);
|
||||
const manifestSha256 = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", serialized.slice(0))), (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
const storedSession = await writer.putAsset(projectId, serialized, "application/vnd.blender.long-media+json", "cache/long-media-session.json");
|
||||
const cacheBytesBeforeDispose = await client.dispose();
|
||||
client.terminate();
|
||||
writer.terminate();
|
||||
|
||||
const reader = new StorageClient();
|
||||
const reopenedSessionAsset = await reader.readAsset(projectId, storedSession.sha256);
|
||||
const reopenedManifest = deserializeLongMediaSessionManifest(reopenedSessionAsset.data);
|
||||
const reopenedImage = await reader.readAsset(projectId, reopenedManifest.assets.find((asset) => asset.sourceId === "media:image")!.sha256);
|
||||
const reopenedAudio = await reader.readAsset(projectId, reopenedManifest.assets.find((asset) => asset.sourceId === "media:audio")!.sha256);
|
||||
reader.terminate();
|
||||
const restarted = createWorkerClient();
|
||||
const restartedIndex = await restarted.init(reopenedManifest.timeline, [
|
||||
{ sourceId: "media:image", data: reopenedImage.data },
|
||||
{ sourceId: "media:audio", data: reopenedAudio.data },
|
||||
]);
|
||||
const reopenedSeek = await restarted.seek(reopenedManifest.currentFrame);
|
||||
const restartedCacheBytes = await restarted.dispose();
|
||||
restarted.terminate();
|
||||
|
||||
const codec = gateSequencerCodec("video/mp4", new Set(["image/png", "audio/wav"]));
|
||||
const runtime = sequencerRuntimeCapabilities();
|
||||
let corruptManifestCode = "";
|
||||
try { parseLongMediaSessionManifest({ ...manifest, assets: [{ ...manifest.assets[0], sha256: "bad" }, manifest.assets[1]] }); }
|
||||
catch (error) { corruptManifestCode = error instanceof Error ? error.message.split(":", 1)[0] : String(error); }
|
||||
|
||||
return {
|
||||
frameCount,
|
||||
stripCount: timeline.strips.length,
|
||||
localBytes: { image: imageBytes.byteLength, audio: audioBytes.byteLength, session: reopenedSessionAsset.data.byteLength },
|
||||
indexCancelCode,
|
||||
seekCancelCode,
|
||||
indexBuildMs,
|
||||
directIndex: directIndex.stats,
|
||||
workerIndex,
|
||||
restartedIndex,
|
||||
cold: { status: cold.status, strips: cold.strips.length, cache: cold.cache, elapsedMs: coldSeekMs },
|
||||
hot: { status: hot.status, strips: hot.strips.length, cache: hot.cache, elapsedMs: hotSeekMs },
|
||||
endpoints: endpoints.map((item) => [item.frame, item.status, item.strips.length]),
|
||||
randomCompleted: random.filter((item) => item.status === "COMPLETED").length,
|
||||
randomCache: random.at(-1)?.cache,
|
||||
randomSeekMs,
|
||||
latest: [superseded.status, latest.status, latest.frame],
|
||||
cancelledSeek: cancelledSeek.status,
|
||||
cacheBytesBeforeDispose,
|
||||
restartedCacheBytes,
|
||||
manifestSha256,
|
||||
storedSessionSha256: storedSession.sha256,
|
||||
reopenedFrame: reopenedSeek.frame,
|
||||
reopenedStatus: reopenedSeek.status,
|
||||
codec: { status: codec.status, code: codec.issues[0]?.code },
|
||||
localEncoding: runtime.localEncoding,
|
||||
corruptManifestCode,
|
||||
elapsedMs: Math.round(performance.now() - started),
|
||||
};
|
||||
}, { imageBytes: new Uint8Array(image), audioBytes: new Uint8Array(audio) });
|
||||
|
||||
console.log("long-media-performance", JSON.stringify({
|
||||
frameCount: result.frameCount,
|
||||
stripCount: result.stripCount,
|
||||
referenceCount: result.workerIndex.referenceCount,
|
||||
indexBuildMs: result.indexBuildMs,
|
||||
coldSeekMs: result.cold.elapsedMs,
|
||||
hotSeekMs: result.hot.elapsedMs,
|
||||
randomSeekMs: result.randomSeekMs,
|
||||
cacheBytes: result.randomCache?.bytes,
|
||||
evictions: result.randomCache?.evictions,
|
||||
manifestSha256: result.manifestSha256,
|
||||
elapsedMs: result.elapsedMs,
|
||||
}));
|
||||
expect(result).toMatchObject({
|
||||
frameCount: 1_000_000,
|
||||
stripCount: 10_001,
|
||||
indexCancelCode: "SEQUENCER_CANCELLED",
|
||||
seekCancelCode: "SEQUENCER_CANCELLED",
|
||||
latest: ["SUPERSEDED", "COMPLETED", 765_432],
|
||||
cancelledSeek: "CANCELLED",
|
||||
reopenedFrame: 765_432,
|
||||
reopenedStatus: "COMPLETED",
|
||||
codec: { status: "BLOCKED", code: "SEQUENCER_CODEC_UNSUPPORTED" },
|
||||
localEncoding: "BLOCKED",
|
||||
corruptManifestCode: "SEQUENCER_SCHEMA_INVALID",
|
||||
});
|
||||
expect(result.localBytes).toMatchObject({ image: 261, audio: 16_044 });
|
||||
expect(result.localBytes.session).toBeGreaterThan(1_000_000);
|
||||
expect(result.directIndex).toEqual(result.workerIndex);
|
||||
expect(result.restartedIndex).toEqual(result.workerIndex);
|
||||
expect(result.workerIndex.stripCount).toBe(10_001);
|
||||
expect(result.workerIndex.referenceCount).toBeLessThan(25_000);
|
||||
expect(result.workerIndex.estimatedBytes).toBeLessThan(256 * 1024);
|
||||
expect(result.indexBuildMs).toBeLessThan(10_000);
|
||||
expect(result.cold).toMatchObject({ status: "COMPLETED", strips: 2 });
|
||||
expect(result.hot).toMatchObject({ status: "COMPLETED", strips: 2 });
|
||||
expect(result.hot.cache.hits).toBeGreaterThan(result.cold.cache.hits);
|
||||
expect(result.hot.cache.bytes).toBeLessThanOrEqual(result.hot.cache.maxBytes);
|
||||
expect(result.endpoints).toEqual([[0, "COMPLETED", 2], [500_000, "COMPLETED", 2], [999_999, "COMPLETED", 2]]);
|
||||
expect(result.randomCompleted).toBe(64);
|
||||
expect(result.randomCache?.bytes).toBeLessThanOrEqual(20 * 1024);
|
||||
expect(result.randomCache?.evictions).toBeGreaterThan(0);
|
||||
expect(result.randomCache?.keys).toEqual(expect.arrayContaining(["media:audio:597927", "media:image:1"]));
|
||||
expect(result.randomSeekMs).toBeLessThan(15_000);
|
||||
expect(result.cacheBytesBeforeDispose).toBeLessThanOrEqual(20 * 1024);
|
||||
expect(result.restartedCacheBytes).toBeLessThanOrEqual(20 * 1024);
|
||||
expect(result.manifestSha256).toBe(result.storedSessionSha256);
|
||||
expect(result.manifestSha256).toBe("d2e7e3c5ed9ae4fda6fe358cebb474f5774f1d2037dff3df9b6dfde9e0bebd2d");
|
||||
expect(result.elapsedMs).toBeLessThan(30_000);
|
||||
});
|
||||
91
web/tests/e2e/oom-recovery.spec.ts
Normal file
91
web/tests/e2e/oom-recovery.spec.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
|
||||
|
||||
test("recovers deterministically from WASM, OPFS, GPU and NanoVDB allocation faults", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const bytes = fs.readFileSync(basicBlend);
|
||||
const result = await page.evaluate(async (input) => {
|
||||
const { runOOMRecoveryScenarios, parseOOMRecoverySuite } = await import("/src/testing/oom-recovery-scenarios.ts");
|
||||
const reports = await runOOMRecoveryScenarios(Uint8Array.from(input).buffer);
|
||||
let rejectsMappingDrift = false;
|
||||
try {
|
||||
const invalid = structuredClone(reports);
|
||||
invalid[0].faults[0].code = "INVALID_ARGUMENT";
|
||||
parseOOMRecoverySuite(invalid);
|
||||
}
|
||||
catch {
|
||||
rejectsMappingDrift = true;
|
||||
}
|
||||
return { reports, rejectsMappingDrift };
|
||||
}, Array.from(bytes));
|
||||
|
||||
expect(result.rejectsMappingDrift).toBe(true);
|
||||
expect(result.reports.map((report) => report.scenario)).toEqual([
|
||||
"WASM_MAIN",
|
||||
"OPFS_STAGING",
|
||||
"GPU_RESOURCES",
|
||||
"NANOVDB_RESIDENT",
|
||||
]);
|
||||
|
||||
for (const report of result.reports) {
|
||||
expect(report.schemaVersion).toBe(1);
|
||||
expect(report.faults.length).toBeGreaterThan(0);
|
||||
expect(report.memory.beforeBytes).toBeGreaterThanOrEqual(0);
|
||||
expect(report.memory.peakBytes).toBeGreaterThanOrEqual(report.memory.beforeBytes);
|
||||
expect(report.memory.peakBytes).toBeGreaterThanOrEqual(report.memory.afterBytes);
|
||||
expect(report.state.temporaryResourcesAfter).toBe(0);
|
||||
expect(report.recovery.recovered).toBe(true);
|
||||
expect(report.recovery.tokenIsolated).toBe(true);
|
||||
expect(report.recovery.sameSession || report.recovery.restartedSession).toBe(true);
|
||||
expect(report.checks.length).toBeGreaterThanOrEqual(4);
|
||||
}
|
||||
|
||||
const wasm = result.reports.find((report) => report.scenario === "WASM_MAIN")!;
|
||||
expect(wasm.faults.map((fault) => [fault.point, fault.code, fault.stage])).toEqual([
|
||||
["WASM_MAIN_OPEN_INPUT", "WASM_OUT_OF_MEMORY", "WASM_OPEN_INPUT"],
|
||||
["WASM_MAIN_EDIT_COMMAND", "WASM_OUT_OF_MEMORY", "MAIN_EDIT_COMMAND"],
|
||||
["WASM_MAIN_SAVE_RESULT", "WASM_OUT_OF_MEMORY", "WASM_SAVE_RESULT"],
|
||||
]);
|
||||
expect(wasm.state.revisionAfter).toBe(wasm.state.revisionBefore);
|
||||
expect(wasm.state.hashAfter).toBe(wasm.state.hashBefore);
|
||||
expect(wasm.memory.releasedBytes).toBeGreaterThan(0);
|
||||
expect(wasm.recovery.sameSession).toBe(true);
|
||||
expect(wasm.recovery.restartedSession).toBe(true);
|
||||
expect(wasm.checks).toEqual(expect.arrayContaining(["undo-redo-stable", "worker-reopen"]));
|
||||
|
||||
const opfs = result.reports.find((report) => report.scenario === "OPFS_STAGING")!;
|
||||
expect(opfs.faults[0]).toMatchObject({ point: "OPFS_STAGING_WRITE", code: "STORAGE_QUOTA", stage: "OPFS_STAGING_WRITE" });
|
||||
expect(opfs.state.revisionBefore).toBe(7);
|
||||
expect(opfs.state.revisionAfter).toBe(7);
|
||||
expect(opfs.state.hashAfter).toBe(opfs.state.hashBefore);
|
||||
expect(opfs.memory.releasedBytes).toBeGreaterThan(0);
|
||||
expect(opfs.state.temporaryResourcesPeak).toBeGreaterThan(0);
|
||||
expect(opfs.checks).toEqual(expect.arrayContaining(["journal-not-committed", "temporary-files-removed", "next-save-succeeds"]));
|
||||
|
||||
const gpu = result.reports.find((report) => report.scenario === "GPU_RESOURCES")!;
|
||||
expect(gpu.faults[0]).toMatchObject({ point: "GPU_TEXTURE_UPLOAD", code: "GPU_TEXTURE_BUDGET_EXCEEDED", stage: "GPU_TEXTURE_UPLOAD" });
|
||||
expect(gpu.state.hashAfter).toBe(gpu.state.hashBefore);
|
||||
expect(gpu.memory.releasedBytes).toBeGreaterThan(0);
|
||||
expect(gpu.state.temporaryResourcesPeak).toBe(1);
|
||||
expect(gpu.checks).toEqual(expect.arrayContaining(["partial-object-not-published", "small-scene-renders"]));
|
||||
|
||||
const nanoVdb = result.reports.find((report) => report.scenario === "NANOVDB_RESIDENT")!;
|
||||
expect(nanoVdb.faults[0]).toMatchObject({ point: "NANOVDB_PAGE_TABLE", code: "NANOVDB_GPU_BUDGET_EXCEEDED", stage: "NANOVDB_PAGE_TABLE" });
|
||||
expect(nanoVdb.memory.releasedBytes).toBe(64 * 1024);
|
||||
expect(nanoVdb.state.temporaryResourcesPeak).toBe(1);
|
||||
expect(nanoVdb.checks).toEqual(expect.arrayContaining(["resident-buffer-destroyed-once", "same-device-recovers", "lru-eviction-recovers"]));
|
||||
|
||||
console.log("oom-recovery", JSON.stringify(result.reports.map((report) => ({
|
||||
scenario: report.scenario,
|
||||
errors: report.faults.map((fault) => `${fault.code}@${fault.stage}`),
|
||||
revision: [report.state.revisionBefore, report.state.revisionAfter],
|
||||
hashStable: report.state.hashBefore === report.state.hashAfter,
|
||||
releasedBytes: report.memory.releasedBytes,
|
||||
temporaryResourcesAfter: report.state.temporaryResourcesAfter,
|
||||
recovered: report.recovery.recovered,
|
||||
}))));
|
||||
});
|
||||
@@ -1230,6 +1230,7 @@ test("commits and reopens a hash-bound NanoVDB project through OPFS", async ({ p
|
||||
});
|
||||
|
||||
test("samples and integrates a real NanoVDB Float32 tree with WebGPU", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(() => new Promise<any>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/vdb-webgpu-test.worker.ts", { type: "module" });
|
||||
@@ -1250,17 +1251,18 @@ test("samples and integrates a real NanoVDB Float32 tree with WebGPU", async ({
|
||||
});
|
||||
expect(result.visiblePixels).toBeGreaterThan(500);
|
||||
expect(result.alphaSum).toBeGreaterThan(10_000);
|
||||
expect(result.imageSha256).toBe("7aab6639d8d173a4b22d913d16b9c61eeea4cc00cb8ccec2202edf75a1b1f978");
|
||||
expect(result.materialMapping.supportedSemantics).toEqual(["DENSITY_GRID", "CONSTANT_COLOR", "CONSTANT_EMISSION", "ANISOTROPY", "INTERPOLATION"]);
|
||||
expect(result.imageSha256).toBe("718e824bda68197e5bf63e2261396f47afe35efbc3ec84d3b38e4afe5b07e33f");
|
||||
expect(result.viewAxis).toBe("X");
|
||||
expect(result.materialMapping.supportedSemantics).toEqual(["DENSITY_GRID", "COLOR_GRID", "TEMPERATURE_GRID_BLACKBODY", "EMISSION_GRID", "CONSTANT_COLOR", "CONSTANT_EMISSION", "ANISOTROPY", "INTERPOLATION"]);
|
||||
expect(result.materialMapping.material).toMatchObject({ interpolation: "LINEAR", color: [0.7, 0.8, 0.95], emissionColor: [1, 0.35, 0.1] });
|
||||
expect(result.materialMapping.losses.map((loss: any) => loss.code)).toEqual([
|
||||
"VOLUME_COLOR_GRID_UNSUPPORTED",
|
||||
"VOLUME_TEMPERATURE_BLACKBODY_UNSUPPORTED",
|
||||
"VOLUME_VELOCITY_RENDER_UNSUPPORTED",
|
||||
]);
|
||||
expect(result.materialMapping.losses.map((loss: any) => loss.code)).toEqual(["VOLUME_VELOCITY_RENDER_UNSUPPORTED"]);
|
||||
expect(result.emissionMapping.material.emissionGrid).toBe("temperature");
|
||||
expect(result.emissionMapping.losses).toEqual([]);
|
||||
expect(result.emissionVisiblePixels).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
test("renders a real NanoVDB volume through both production viewport backends", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await page.goto("/");
|
||||
const result = await page.evaluate(async () => {
|
||||
const [{ loadNanoVDBViewportAsset }, { ViewportRenderer }, { OffscreenViewportRenderer }] = await Promise.all([
|
||||
@@ -1269,6 +1271,11 @@ test("renders a real NanoVDB volume through both production viewport backends",
|
||||
import("/src/three-adapter/offscreen-viewport.ts"),
|
||||
]);
|
||||
const asset = await loadNanoVDBViewportAsset("volume:ViewportSmoke", "/__vdb_fixture__/manifest", "/__vdb_fixture__/bundle", new AbortController().signal);
|
||||
asset.material = {
|
||||
...asset.manifest.material,
|
||||
interpolation: "NEAREST",
|
||||
emissionGrid: asset.manifest.material.temperatureGrid,
|
||||
};
|
||||
const snapshot: any = {
|
||||
schemaVersion: 1, revision: 1, sceneId: "scene:Volume", source: { kind: "mock" },
|
||||
coordinateSystem: { upAxis: "Z", forwardAxis: "-Y", handedness: "RIGHT", unitSystem: 0, unitScale: 1 },
|
||||
@@ -1281,7 +1288,7 @@ test("renders a real NanoVDB volume through both production viewport backends",
|
||||
nonMeshData: [{ id: asset.dataId, name: "Viewport Volume", type: "VOLUME", geometryStatus: "blocked", pointCount: 0, splineCount: 0, sourcePath: "//volumes/generated-smoke.vdb", resourceKind: "OPENVDB" }],
|
||||
activeObjectId: "object:Volume", frame: { current: 1, start: 1, end: 250 },
|
||||
};
|
||||
const waitFor = async (condition: () => boolean, timeoutMs = 20_000): Promise<void> => {
|
||||
const waitFor = async (condition: () => boolean, timeoutMs = 80_000): Promise<void> => {
|
||||
const deadline = performance.now() + timeoutMs;
|
||||
while (!condition()) {
|
||||
if (performance.now() > deadline) throw new Error("viewport volume timed out");
|
||||
@@ -1299,7 +1306,8 @@ test("renders a real NanoVDB volume through both production viewport backends",
|
||||
const main = new ViewportRenderer(mainCanvas);
|
||||
main.setSnapshot(snapshot);
|
||||
main.setVolumeAssets([asset]);
|
||||
await waitFor(() => mainCanvas.dataset.volumeStatus === "ready");
|
||||
await waitFor(() => ["ready", "blocked"].includes(mainCanvas.dataset.volumeStatus ?? ""));
|
||||
if (mainCanvas.dataset.volumeStatus !== "ready") throw new Error(`main viewport volume ${mainCanvas.dataset.volumeErrorCode ?? "blocked"}`);
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
let mainVolumeObjects = 0;
|
||||
main.scene.traverse((object: any) => { if (object.userData.nanoVDBVolume) mainVolumeObjects++; });
|
||||
@@ -1314,7 +1322,9 @@ test("renders a real NanoVDB volume through both production viewport backends",
|
||||
const offscreen = new OffscreenViewportRenderer(offscreenCanvas);
|
||||
offscreen.setSnapshot(snapshot);
|
||||
offscreen.setVolumeAssets([asset]);
|
||||
await waitFor(() => offscreenCanvas.dataset.volumeStatus === "ready" && Number(offscreenCanvas.dataset.rendererPixels ?? 0) > 0);
|
||||
await waitFor(() => ["ready", "blocked"].includes(offscreenCanvas.dataset.volumeStatus ?? ""));
|
||||
if (offscreenCanvas.dataset.volumeStatus !== "ready") throw new Error(`offscreen viewport volume ${offscreenCanvas.dataset.volumeErrorCode ?? "blocked"}`);
|
||||
await waitFor(() => Number(offscreenCanvas.dataset.rendererPixels ?? 0) > 0);
|
||||
const offscreenResult = { status: offscreenCanvas.dataset.volumeStatus, count: Number(offscreenCanvas.dataset.volumeCount), visible: Number(offscreenCanvas.dataset.rendererPixels) };
|
||||
offscreen.dispose();
|
||||
offscreenCanvas.remove();
|
||||
@@ -1355,9 +1365,25 @@ test("recovers NanoVDB paging from network, Worker and WebGPU device faults", as
|
||||
expect(gpu.network.outOfOrderResponse).toContain("NANOVDB_STREAM_INCOMPLETE");
|
||||
expect(gpu.lru).toMatchObject({ residentPages: 2, residentBytes: 128 * 1024, evictions: 1, keys: ["page-a", "page-c"] });
|
||||
expect(gpu.oom).toContain("NANOVDB_GPU_BUDGET_EXCEEDED");
|
||||
expect(gpu.demandPaging.pageCount).toBeGreaterThan(gpu.demandPaging.residentPageCapacity);
|
||||
expect(gpu.demandPaging.initialVirtualPages).toEqual([]);
|
||||
expect(gpu.demandPaging.residentBytes).toBe(512 * 1024);
|
||||
expect(gpu.demandPaging.maxResidentBytes).toBe(512 * 1024);
|
||||
expect(gpu.demandPaging.evictions).toBe(1);
|
||||
expect(gpu.demandPaging.requestedPageResident).toBe(true);
|
||||
expect(gpu.demandPaging.touchedPageResident).toBe(true);
|
||||
expect(gpu.demandPaging.evictedPageResident).toBe(false);
|
||||
expect(gpu.demandPaging.residentVirtualPages).toContain(gpu.demandPaging.pageCount - 1);
|
||||
expect(gpu.demandPaging.pageRanges.length).toBeGreaterThanOrEqual(3);
|
||||
expect(gpu.demandPaging.pageRanges.every((range: string) => /^bytes=\d+-\d+$/.test(range))).toBe(true);
|
||||
expect(gpu.demandPaging.demandWords).toEqual(gpu.demandPaging.expectedWords);
|
||||
expect(gpu.demandPaging.incompletePage).toContain("NANOVDB_STREAM_INCOMPLETE");
|
||||
expect(gpu.globalResidency.totalResidentBytes).toBeLessThanOrEqual(gpu.globalResidency.maxResidentBytes);
|
||||
expect(Object.values(gpu.globalResidency.gridResidentBytes)).toEqual([256 * 1024, 256 * 1024, 256 * 1024]);
|
||||
expect(gpu.paging.pageCount).toBeGreaterThan(1);
|
||||
expect(gpu.paging.residentPageCount).toBe(gpu.paging.pageCount);
|
||||
expect(gpu.deviceLoss.recoveredGeneration).toBe(gpu.deviceLoss.firstGeneration + 1);
|
||||
expect(gpu.deviceLoss.recoveredDemandWords).toEqual(gpu.demandPaging.expectedWords.slice(0, 2));
|
||||
expect(gpu.samplesStable).toBe(true);
|
||||
|
||||
const interrupted = await page.evaluate(() => new Promise<any>((resolve, reject) => {
|
||||
|
||||
165
web/tests/e2e/v1-user-loop.spec.ts
Normal file
165
web/tests/e2e/v1-user-loop.spec.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
|
||||
const basicBlend = path.resolve(import.meta.dirname, "../../../tests/files/web/basic_scene.blend");
|
||||
|
||||
test("completes the V1 import-edit-save-restart-reopen-GLB loop", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
const bytes = await import("node:fs").then((fs) => fs.readFileSync(basicBlend));
|
||||
const result = await page.evaluate(async (input) => {
|
||||
const [{ WebEngineClient }, { StorageClient }, { ViewportRenderer }, { OffscreenViewportRenderer }] = await Promise.all([
|
||||
import("/src/engine-client/WebEngineClient.ts"),
|
||||
import("/src/storage/StorageClient.ts"),
|
||||
import("/src/three-adapter/viewport.ts"),
|
||||
import("/src/three-adapter/offscreen-viewport.ts"),
|
||||
]);
|
||||
const projectId = `v1-loop-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const digestGeometry = async (buffers: Array<{ meshId: string; positions: ArrayBuffer; indices: ArrayBuffer }>, meshId: string): Promise<string> => {
|
||||
const geometry = buffers.find((candidate) => candidate.meshId === meshId);
|
||||
if (!geometry) throw new Error(`missing geometry ${meshId}`);
|
||||
const bytes = new Uint8Array(geometry.positions.byteLength + geometry.indices.byteLength);
|
||||
bytes.set(new Uint8Array(geometry.positions));
|
||||
bytes.set(new Uint8Array(geometry.indices), geometry.positions.byteLength);
|
||||
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
||||
return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
};
|
||||
const materialByName = (snapshot: { materials: Array<{ name: string; roughness: number; metallic: number; baseColor: number[] }> }, name: string) =>
|
||||
snapshot.materials.find((material) => material.name === name);
|
||||
|
||||
const firstEngine = new WebEngineClient({ timeoutMs: 30_000 });
|
||||
const firstStorage = new StorageClient();
|
||||
let editedRevision = 0;
|
||||
let objectId = "";
|
||||
let meshId = "";
|
||||
let materialId = "";
|
||||
let editedGeometrySha256 = "";
|
||||
let storedRevision = 0;
|
||||
try {
|
||||
const opened = await firstEngine.openBlend(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength));
|
||||
const created = await firstEngine.applyCommand({ type: "createPrimitive", primitive: "CUBE", name: "V1LoopCube", location: [2, 0, 0] });
|
||||
const object = created.snapshot.nodes.find((node) => node.name === "V1LoopCube");
|
||||
if (!object?.dataId) throw new Error("V1 object creation did not return stable object/mesh IDs");
|
||||
objectId = object.id;
|
||||
meshId = object.dataId;
|
||||
await firstEngine.applyCommand({ type: "setObjectTransform", objectId, translation: [2, 1, 0.5], rotationEuler: [0.1, 0.2, 0.3], scale: [1.25, 0.75, 1.5] });
|
||||
await firstEngine.applyCommand({ type: "translateMeshVertices", meshId, vertexIndices: [0, 1], offset: [0, 0, 0.25] });
|
||||
const slotted = await firstEngine.applyCommand({ type: "addMaterialSlot", objectId, name: "V1LoopMaterial" });
|
||||
const material = materialByName(slotted.snapshot, "V1LoopMaterial");
|
||||
if (!material) throw new Error("V1 material slot did not create a material");
|
||||
materialId = (material as { id?: string }).id ?? "";
|
||||
if (!materialId) throw new Error("V1 material has no stable ID");
|
||||
const materialEdited = await firstEngine.applyCommand({
|
||||
type: "setMaterialPrincipled",
|
||||
materialId,
|
||||
baseColor: [0.12, 0.34, 0.56, 1],
|
||||
roughness: 0.23,
|
||||
metallic: 0.67,
|
||||
});
|
||||
const undone = await firstEngine.applyCommand({ type: "undo" });
|
||||
const undoMaterial = materialByName(undone.snapshot, "V1LoopMaterial");
|
||||
const redone = await firstEngine.applyCommand({ type: "redo" });
|
||||
const redoMaterial = materialByName(redone.snapshot, "V1LoopMaterial");
|
||||
if (!undoMaterial || !redoMaterial || undoMaterial.roughness === redoMaterial.roughness) throw new Error("V1 material undo/redo did not change Main state");
|
||||
if (materialEdited.snapshot.revision >= redone.snapshot.revision) throw new Error("V1 history revisions did not advance");
|
||||
editedRevision = redone.snapshot.revision;
|
||||
editedGeometrySha256 = await digestGeometry(redone.geometryBuffers, meshId);
|
||||
const unsupported = await firstEngine.queryRenderCapability({ kind: "ARBITRARY_SHADER", nodeTypes: ["UNSUPPORTED"] });
|
||||
if (unsupported.status !== "BLOCKED" || unsupported.issues[0]?.code !== "SHADER_NODE_UNSUPPORTED") throw new Error("V1 unsupported shader gate drifted");
|
||||
const saved = await firstEngine.saveBlend();
|
||||
const stored = await firstStorage.saveProject(projectId, editedRevision, saved.slice(0));
|
||||
await firstStorage.saveSnapshot(projectId, editedRevision, saved.slice(0));
|
||||
storedRevision = stored.revision;
|
||||
}
|
||||
finally {
|
||||
firstEngine.terminate();
|
||||
firstStorage.terminate();
|
||||
}
|
||||
|
||||
const restartedStorage = new StorageClient();
|
||||
const stored = await restartedStorage.readProject(projectId);
|
||||
restartedStorage.terminate();
|
||||
const restartedEngine = new WebEngineClient({ timeoutMs: 30_000 });
|
||||
const reopened = await restartedEngine.openBlend(stored.buffer);
|
||||
restartedEngine.terminate();
|
||||
const reopenedObject = reopened.snapshot.nodes.find((node) => node.id === objectId);
|
||||
const reopenedMaterial = reopened.snapshot.materials.find((material) => material.id === materialId);
|
||||
const reopenedGeometrySha256 = await digestGeometry(reopened.geometryBuffers, meshId);
|
||||
if (!reopenedObject || !reopenedMaterial) throw new Error("V1 reopened scene lost edited IDs");
|
||||
|
||||
const createCanvas = (): HTMLCanvasElement => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 320;
|
||||
canvas.height = 240;
|
||||
canvas.style.cssText = "position:fixed;left:0;top:0;width:320px;height:240px";
|
||||
document.body.append(canvas);
|
||||
return canvas;
|
||||
};
|
||||
const mainCanvas = createCanvas();
|
||||
const main = new ViewportRenderer(mainCanvas);
|
||||
main.setSnapshot(reopened.snapshot, reopened.geometryBuffers, reopened.nonMeshGeometryBuffers ?? []);
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const gl = main.renderer.getContext();
|
||||
const mainPixels = new Uint8Array(32 * 32 * 4);
|
||||
gl.readPixels(0, 0, 32, 32, gl.RGBA, gl.UNSIGNED_BYTE, mainPixels);
|
||||
const mainVisible = Array.from({ length: 32 * 32 }, (_, index) => mainPixels[index * 4] + mainPixels[index * 4 + 1] + mainPixels[index * 4 + 2] > 30).filter(Boolean).length;
|
||||
main.dispose();
|
||||
mainCanvas.remove();
|
||||
|
||||
const offscreenCanvas = createCanvas();
|
||||
const offscreen = new OffscreenViewportRenderer(offscreenCanvas);
|
||||
offscreen.setSnapshot(reopened.snapshot, reopened.geometryBuffers, reopened.nonMeshGeometryBuffers ?? []);
|
||||
const deadline = performance.now() + 30_000;
|
||||
while (Number(offscreenCanvas.dataset.rendererPixels ?? 0) === 0 && !offscreenCanvas.dataset.rendererError) {
|
||||
if (performance.now() > deadline) throw new Error("V1 Offscreen viewport timed out");
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
const offscreenVisible = Number(offscreenCanvas.dataset.rendererPixels ?? 0);
|
||||
const offscreenError = offscreenCanvas.dataset.rendererError;
|
||||
offscreen.dispose();
|
||||
offscreenCanvas.remove();
|
||||
|
||||
const glb = await new Promise<{ ok: boolean; byteLength: number; roundTrip?: { compatible: boolean; mismatches: string[] }; pbrSummary?: { baseColorFactor?: number[]; roughnessFactor?: number; metallicFactor?: number }; error?: string }>((resolve, reject) => {
|
||||
const worker = new Worker("/src/workers/glb-test.worker.ts", { type: "module" });
|
||||
worker.onmessage = (event) => { worker.terminate(); resolve(event.data); };
|
||||
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
|
||||
const transfer: Transferable[] = [];
|
||||
for (const geometry of reopened.geometryBuffers) {
|
||||
for (const value of Object.values(geometry)) if (value instanceof ArrayBuffer) transfer.push(value);
|
||||
}
|
||||
worker.postMessage({ snapshot: reopened.snapshot, geometryBuffers: reopened.geometryBuffers, assetBuffers: [] }, transfer);
|
||||
});
|
||||
return {
|
||||
editedRevision,
|
||||
storedRevision,
|
||||
persistedRevision: stored.revision,
|
||||
reopenedRevision: reopened.snapshot.revision,
|
||||
objectTransform: reopenedObject.transform,
|
||||
material: reopenedMaterial,
|
||||
geometry: [editedGeometrySha256, reopenedGeometrySha256],
|
||||
viewport: { mainVisible, offscreenVisible, offscreenError },
|
||||
glb,
|
||||
};
|
||||
}, new Uint8Array(bytes));
|
||||
|
||||
expect(result.editedRevision).toBeGreaterThan(1);
|
||||
expect(result.storedRevision).toBe(result.editedRevision);
|
||||
expect(result.persistedRevision).toBe(result.editedRevision);
|
||||
expect(result.reopenedRevision).toBe(1);
|
||||
expect(result.objectTransform.translation).toEqual([2, 1, 0.5]);
|
||||
expect(result.objectTransform.scale).toEqual([1.25, 0.75, 1.5]);
|
||||
[0.12, 0.34, 0.56, 1].forEach((value, index) => expect(result.material.baseColor[index]).toBeCloseTo(value, 6));
|
||||
expect(result.material.roughness).toBeCloseTo(0.23);
|
||||
expect(result.material.metallic).toBeCloseTo(0.67);
|
||||
expect(result.geometry[0]).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(result.geometry[1]).toBe(result.geometry[0]);
|
||||
expect(result.viewport.mainVisible).toBeGreaterThan(0);
|
||||
expect(result.viewport.offscreenVisible).toBeGreaterThan(0);
|
||||
expect(result.viewport.offscreenError).toBeUndefined();
|
||||
expect(result.glb.ok).toBe(true);
|
||||
expect(result.glb.byteLength).toBeGreaterThan(1_000);
|
||||
expect(result.glb.roundTrip).toEqual({ compatible: true, mismatches: [] });
|
||||
[0.12, 0.34, 0.56, 1].forEach((value, index) => expect(result.glb.pbrSummary?.baseColorFactor?.[index]).toBeCloseTo(value, 6));
|
||||
expect(result.glb.pbrSummary?.roughnessFactor).toBeCloseTo(0.23);
|
||||
expect(result.glb.pbrSummary?.metallicFactor).toBeCloseTo(0.67);
|
||||
});
|
||||
Reference in New Issue
Block a user