Complete V1 performance and OOM release gates

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",

View File

@@ -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"

View 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),
};
}
}

View 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;
}

View File

@@ -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"); });

View 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);
});

View 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);
});

View 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,
}))));
});

View File

@@ -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) => {

View 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);
});