Advance M8-M11 parity workflows
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

This commit is contained in:
mes123456
2026-08-17 04:37:07 -04:00
parent 7c16b279ae
commit 0fe8d2bb56
324 changed files with 31920 additions and 863 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -10,13 +10,14 @@
body { margin: 0; min-width: 320px; min-height: 100vh; overflow: hidden; }
button, input { font: inherit; }
button { color: inherit; border: 0; cursor: pointer; }
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, [tabindex]:focus-visible { outline: 2px solid #f2a15b; outline-offset: 1px; }
.blender-app { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: 38px 30px minmax(0, 1fr) 24px; width: 100%; height: 100vh; min-height: 480px; background: #202124; }
.blender-app { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: 38px 34px 30px minmax(0, 1fr) 24px; width: 100%; height: 100vh; min-height: 480px; background: #202124; }
.topbar, .workspace-toolbar, .status-bar { display: flex; align-items: center; gap: 8px; padding: 0 10px; background: #27282b; border-bottom: 1px solid #111214; }
.topbar { gap: 14px; }
.brand { display: flex; align-items: center; gap: 7px; min-width: 120px; font-weight: 700; color: #f4f4f5; }
.brand-mark { color: #e37a2c; font-size: 18px; }
.menu-bar, .workspace-tabs, .topbar-actions { display: flex; align-items: center; gap: 2px; }
.menu-bar, .workspace-tabs, .topbar-actions { display: flex; align-items: center; gap: 2px; }.menu-entry { position: relative; display: inline-flex; }
.menu-bar button, .topbar-actions button, .workspace-tab, .workspace-toolbar button { padding: 5px 8px; background: transparent; border-radius: 3px; color: #bfc2c8; }
.menu-bar button:hover, .topbar-actions button:hover, .workspace-tab:hover, .workspace-toolbar button:hover { background: #3a3c41; color: #fff; }
.topbar-actions button:disabled { color: #6f737b; background: transparent; cursor: default; }
@@ -42,6 +43,8 @@ button { color: inherit; border: 0; cursor: pointer; }
.timeline-area { grid-column: 1; grid-row: 2; }
.viewport-placeholder { position: relative; width: 100%; height: 100%; min-height: 260px; overflow: hidden; background: #25272b; }
.viewport-canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.grease-pencil-marquee-surface { position: absolute; inset: 0; z-index: 1; cursor: crosshair; touch-action: none; }
.grease-pencil-marquee-box { position: absolute; border: 1px solid #7fc4ff; background: #4c9ed126; box-shadow: 0 0 0 1px #17212aaa; pointer-events: none; }
.viewport-grid { position: absolute; inset: 0; opacity: .3; background-image: linear-gradient(#62656b 1px, transparent 1px), linear-gradient(90deg, #62656b 1px, transparent 1px); background-size: 32px 32px; transform: perspective(500px) rotateX(58deg) scale(1.7); transform-origin: 50% 100%; }
.viewport-placeholder::after { position: absolute; inset: 48% 0 0; border-top: 1px solid #777b83; content: ""; opacity: .5; pointer-events: none; }
.viewport-message { position: absolute; top: 50%; left: 50%; z-index: 1; display: grid; gap: 5px; transform: translate(-50%, -50%); text-align: center; color: #c8cbd0; }
@@ -50,6 +53,7 @@ button { color: inherit; border: 0; cursor: pointer; }
.axis-gizmo { position: absolute; top: 14px; right: 14px; z-index: 2; width: 54px; height: 54px; border: 1px solid #545860; border-radius: 50%; color: #c2c5ca; font-size: 11px; }
.axis-gizmo span { position: absolute; padding: 2px; border-radius: 2px; background: #35373d; }.axis-x { right: -3px; top: 23px; color: #f06a6a; }.axis-y { left: 21px; top: -4px; color: #75d18d; }.axis-z { left: 3px; bottom: 4px; color: #6fa4f5; }
.viewport-toolbar { position: absolute; top: 14px; left: 12px; z-index: 2; display: grid; gap: 3px; padding: 4px; background: #303238d9; border: 1px solid #45484f; border-radius: 4px; }.tool-button { width: 27px; height: 27px; background: transparent; color: #c7cad0; border-radius: 3px; }.tool-button:hover, .tool-button.active { color: #fff; background: #d26928; }
.tool-button:disabled { color: #666a72; cursor: default; }.tool-button:disabled:hover { background: transparent; }
.transform-gizmo { position: absolute; left: 50%; top: 50%; z-index: 2; width: 92px; height: 92px; transform: translate(-46px, -46px); pointer-events: none; }
.transform-gizmo.handle-local::before { position: absolute; left: 42px; top: 42px; width: 8px; height: 8px; border: 1px solid #fff; border-radius: 50%; background: #24262b; box-shadow: 0 0 0 2px #24262baa; content: ""; }
.gizmo-axis { position: absolute; width: 28px; height: 28px; padding: 0; border: 2px solid currentColor; border-radius: 50%; background: #24262bcc; font-weight: 700; pointer-events: auto; touch-action: none; }
@@ -57,17 +61,25 @@ button { color: inherit; border: 0; cursor: pointer; }
.viewport-sidebar { position: absolute; top: 14px; right: 12px; z-index: 2; display: grid; gap: 7px; padding: 9px; color: #aeb3bc; background: #303238d9; border: 1px solid #45484f; border-radius: 4px; }.viewport-sidebar span { writing-mode: vertical-rl; }
.outliner-content, .properties-content { padding: 8px; color: #c8cbd0; }
.outliner-tools { display: flex; gap: 4px; margin-bottom: 8px; }.outliner-tools input { min-width: 0; flex: 1; padding: 5px 7px; color: #e4e6ea; background: #1d1f22; border: 1px solid #464950; border-radius: 3px; outline: none; }.outliner-tools button { width: 30px; background: #3a3c42; border-radius: 3px; }
.tree-row { display: flex; align-items: center; gap: 7px; min-height: 26px; padding: 2px 5px; border-radius: 3px; }.tree-row.child { padding-left: 24px; }.tree-row.selected { color: #fff; background: #a55325; }.tree-icon { color: #d7a04b; }.tree-icon.mesh { color: #72a7dc; }.tree-action { margin-left: auto; min-width: 22px; padding: 2px 4px; color: #8f949d; background: transparent; border: 0; border-radius: 3px; }.tree-action:hover { color: #fff; background: #41434a; }
.tree-row { display: flex; align-items: center; gap: 7px; min-height: 26px; padding: 2px 5px; border-radius: 3px; }.tree-row.child { padding-left: 24px; }.tree-row.selected { color: #fff; background: #a55325; }.tree-icon { color: #d7a04b; }.tree-icon.mesh { color: #72a7dc; }.tree-action { width: 24px; min-width: 24px; height: 24px; margin-left: auto; padding: 2px 4px; color: #8f949d; background: transparent; border: 0; border-radius: 3px; }.tree-action:hover { color: #fff; background: #41434a; }
.property-tabs { display: flex; gap: 2px; margin-bottom: 8px; border-bottom: 1px solid #42454b; }.property-tab { padding: 6px 8px; color: #aeb3bc; background: transparent; border-bottom: 2px solid transparent; }.property-tab.active { color: #fff; border-bottom-color: #e37a2c; }.property-section { padding: 8px 0; border-bottom: 1px solid #383a40; }.property-section h3 { margin: 0 0 8px; color: #eceef1; font-size: 12px; font-weight: 600; }.property-section label { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 24px; color: #9fa4ad; }.property-section output { color: #e0e2e6; font-variant-numeric: tabular-nums; }.property-section select { min-width: 118px; padding: 3px 5px; color: #e0e2e6; background: #26282c; border: 1px solid #484c53; border-radius: 2px; }.property-section input[type="range"] { flex: 1; min-width: 78px; accent-color: #e37a2c; }.modifier-summary { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 8px; color: #7f858e; font-size: 11px; }.modifier-summary button { padding: 4px 9px; color: #fff; background: #b45f29; border: 1px solid #dc8243; border-radius: 2px; }.modifier-summary button:disabled { color: #777c84; background: #303237; border-color: #45484e; }.swatch { width: 32px; height: 14px; background: #a35d43; border: 1px solid #d18a68; border-radius: 2px; }
.modifier-row { display: grid; grid-template-columns: minmax(0, 1fr) repeat(4, 26px); align-items: center; gap: 3px; min-height: 26px; }.modifier-row > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.property-section .modifier-row label { justify-content: center; gap: 2px; min-height: 20px; font-size: 10px; }
.property-section input[type="number"] { width: 72px; padding: 3px 5px; color: #e0e2e6; background: #26282c; border: 1px solid #484c53; border-radius: 2px; }.property-section input[type="text"], .property-section label > input:not([type]) { min-width: 0; width: 130px; padding: 3px 5px; color: #e0e2e6; background: #26282c; border: 1px solid #484c53; border-radius: 2px; }.property-section input[type="checkbox"] { accent-color: #e37a2c; }.property-actions { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0; }.property-actions button, .file-button { padding: 4px 7px; color: #e6e7e9; background: #393c42; border: 1px solid #4d5158; border-radius: 2px; }.property-actions button:disabled { opacity: .45; cursor: default; }.file-button { position: relative; cursor: pointer; }.file-button input { position: absolute; width: 1px; height: 1px; opacity: 0; }.material-slots { display: grid; gap: 2px; max-height: 62px; overflow: auto; color: #b9bdc5; font-size: 11px; }.delimit-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px 8px; margin: 6px 0; padding: 5px 0 3px; border: 0; border-top: 1px solid #383a40; }.delimit-options legend { padding: 0 5px 0 0; color: #7f858e; font-size: 10px; }.property-section .delimit-options label { justify-content: flex-start; min-width: 0; min-height: 20px; font-size: 10px; }
.timeline-content { display: grid; grid-template-rows: 34px 20px 16px minmax(30px, 1fr); height: 100%; padding: 6px 12px; }.timeline-controls { display: flex; align-items: center; gap: 4px; overflow-x: auto; }.timeline-controls button { min-width: 28px; height: 26px; padding: 0 6px; color: #c9ccd2; background: #35373d; border-radius: 3px; white-space: nowrap; }.timeline-controls button:hover { background: #4a4d54; }.frame-number { min-width: 45px; margin-left: 10px; padding: 5px 8px; text-align: center; color: #fff; background: #181a1d; border: 1px solid #4a4d54; border-radius: 3px; }.frame-slider { width: 100%; accent-color: #e37a2c; }.timeline-scale { display: flex; justify-content: space-between; color: #7f858f; font-size: 11px; }.dope-sheet { display: grid; grid-template-columns: 100px minmax(120px, 1fr) 90px; align-items: center; gap: 8px; border-top: 1px solid #3a3d43; }.channel-name { overflow: hidden; color: #b8bcc4; text-overflow: ellipsis; white-space: nowrap; }.key-track { position: relative; height: 20px; background: #1e2024; border: 1px solid #373a40; }.key-dot { position: absolute; top: 5px; width: 9px; height: 9px; padding: 0; transform: translateX(-50%) rotate(45deg); background: #d6a348; border: 1px solid #f2c977; }.key-dot.active { background: #e36d2d; }.dope-sheet select { min-width: 0; color: #ddd; background: #292b30; border: 1px solid #484c53; }
.grease-pencil-canvas-2d { display: block; width: 100%; max-width: 280px; aspect-ratio: 28 / 15; margin: 7px 0; border: 1px solid #4d5158; border-radius: 3px; background: #202328; cursor: crosshair; touch-action: none; }
.grease-pencil-canvas-2d:focus-visible { outline: 2px solid #f2a15b; outline-offset: 1px; }
.timeline-content { display: grid; grid-template-rows: 34px 20px 16px minmax(30px, 1fr); height: 100%; padding: 6px 12px; }.timeline-controls { display: flex; align-items: center; gap: 4px; overflow-x: auto; }.timeline-controls button { min-width: 28px; height: 26px; padding: 0 6px; color: #c9ccd2; background: #35373d; border-radius: 3px; white-space: nowrap; }.timeline-controls button:hover { background: #4a4d54; }.frame-number { min-width: 45px; margin-left: 10px; padding: 5px 8px; text-align: center; color: #fff; background: #181a1d; border: 1px solid #4a4d54; border-radius: 3px; }.frame-slider { width: 100%; accent-color: #e37a2c; }.timeline-scale { display: flex; justify-content: space-between; color: #aeb3bc; font-size: 11px; }.dope-sheet { display: grid; grid-template-columns: 100px minmax(120px, 1fr) 90px; align-items: center; gap: 8px; border-top: 1px solid #3a3d43; }.channel-name { overflow: hidden; color: #b8bcc4; text-overflow: ellipsis; white-space: nowrap; }.key-track { position: relative; height: 20px; background: #1e2024; border: 1px solid #373a40; }.key-dot { position: absolute; top: 5px; width: 9px; height: 9px; padding: 0; transform: translateX(-50%) rotate(45deg); background: #d6a348; border: 1px solid #f2c977; }.key-dot.active { background: #e36d2d; }.dope-sheet select { min-width: 0; color: #ddd; background: #292b30; border: 1px solid #484c53; }
.status-bar { gap: 16px; min-width: 0; min-height: 24px; overflow: hidden; color: #8f949c; font-size: 11px; border: 0; }.status-bar span:not(.status-spacer) { white-space: nowrap; }
.storage-budget-panel { display: flex; align-items: center; gap: 12px; min-width: 0; padding: 0 10px; overflow-x: auto; color: #aeb2bb; background: #222427; border-bottom: 1px solid #111214; white-space: nowrap; scrollbar-width: thin; }.storage-budget-panel strong { color: #e4e6e9; font-size: 11px; }.storage-budget-panel span { color: #aeb2bb; font-variant-numeric: tabular-nums; }.storage-budget-panel output { margin-left: auto; color: #e37a2c; font-variant-numeric: tabular-nums; }.storage-budget-panel button { flex: none; padding: 3px 7px; color: #d9dce1; background: #35373d; border: 1px solid #4d5158; border-radius: 3px; }.storage-budget-panel button:disabled { opacity: .45; cursor: default; }
.open-progress { display: inline-flex; align-items: center; gap: 5px; min-width: 0; max-width: 360px; }.open-progress progress { width: 72px; height: 8px; accent-color: #e37a2c; }.open-progress > span { overflow: hidden; text-overflow: ellipsis; }.open-progress button { width: 20px; height: 20px; padding: 0; color: #d6d8dc; background: #3a3c41; border-radius: 2px; }
.worker-fault-banner { position: fixed; top: 76px; left: 50%; z-index: 12; display: flex; align-items: center; gap: 10px; max-width: calc(100vw - 24px); padding: 8px 10px; transform: translateX(-50%); color: #f1f2f4; background: #3a2923; border: 1px solid #c6753b; border-radius: 4px; box-shadow: 0 8px 24px #0008; }.worker-fault-banner span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.worker-fault-banner button { flex: none; padding: 5px 9px; color: #fff; background: #a55325; border: 1px solid #d17a42; border-radius: 3px; }.worker-fault-banner button:disabled { opacity: .6; cursor: default; }
.recent-project-repair-banner { position: fixed; top: 76px; left: 50%; z-index: 11; display: grid; gap: 6px; width: min(560px, calc(100vw - 24px)); padding: 9px 11px; transform: translateX(-50%); color: #f1f2f4; background: #332d22; border: 1px solid #b98a42; border-radius: 4px; box-shadow: 0 8px 24px #0008; }.recent-project-repair-list { display: grid; gap: 4px; max-height: 160px; overflow: auto; }.recent-project-repair-item { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-width: 0; color: #d9d1bd; }.recent-project-repair-item > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.recent-project-repair-item button { flex: none; padding: 4px 8px; color: #fff; background: #765729; border: 1px solid #c49c58; border-radius: 3px; }.recent-project-repair-item button:hover { background: #92703a; }
.file-input-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.operator-search { position: fixed; top: 56px; left: 50%; z-index: 10; width: min(480px, calc(100vw - 24px)); padding: 8px; transform: translateX(-50%); background: #303238; border: 1px solid #545860; border-radius: 4px; box-shadow: 0 12px 30px #0008; }
.main-menu-popover { position: absolute; top: 100%; left: 0; z-index: 14; display: grid; min-width: 128px; padding: 4px; background: #303238; border: 1px solid #545860; border-radius: 3px; box-shadow: 0 8px 20px #0008; }.main-menu-popover button { padding: 6px 10px; text-align: left; color: #d9dce1; background: transparent; border-radius: 2px; white-space: nowrap; }.main-menu-popover button:hover, .main-menu-popover button:focus-visible { color: #fff; background: #a55325; }
.operator-search input { width: 100%; padding: 8px 10px; color: #f4f5f6; background: #1d1f22; border: 1px solid #5a5e66; border-radius: 3px; outline: none; }
.operator-results { display: grid; gap: 2px; margin-top: 6px; }.operator-results button { padding: 8px 10px; text-align: left; color: #d9dce1; background: transparent; border-radius: 3px; }.operator-results button:hover { color: #fff; background: #a55325; }
.outliner-tools input:focus-visible, .operator-search input:focus-visible { outline: 2px solid #f2a15b; outline-offset: 1px; }
.file-button:has(input:focus-visible) { outline: 2px solid #f2a15b; outline-offset: 1px; }
@media (max-width: 800px) {
.topbar { min-width: 0; overflow-x: auto; scrollbar-width: none; }
.topbar::-webkit-scrollbar { display: none; }
@@ -81,6 +93,7 @@ button { color: inherit; border: 0; cursor: pointer; }
.timeline-area { grid-row: 2; }
.workspace-tab { min-width: 64px; }
.status-bar span:nth-child(2), .status-bar span:nth-child(4) { display: none; }
.status-bar { overflow-x: auto; scrollbar-width: thin; }
.timeline-content { padding-inline: 8px; }
.timeline-controls { gap: 2px; }
.timeline-controls button { min-width: 24px; padding-inline: 4px; font-size: 11px; }

View File

@@ -1,6 +1,8 @@
export {
COMPOSITOR_WEBGPU_NODE_ALLOWLIST,
CompositorFrameCache,
CompositorValidationError,
compileCompositorWebGPUPlan,
compositorFrameCacheKey,
executeCompositorGraph,
executeCompositorGraphCached,
@@ -12,4 +14,6 @@ export type {
CompositorExecutionResult,
CompositorGraphIR,
CompositorImageBuffer,
CompositorWebGPUInstructionIR,
CompositorWebGPUPlanIR,
} from "../../../protocol/compositor";

View File

@@ -0,0 +1,83 @@
import {
COMPOSITOR_BUDGET,
CompositorValidationError,
compileCompositorWebGPUPlan,
type CompositorImageBuffer,
type CompositorWebGPUInstructionIR,
} from "../../../protocol/compositor";
function wgslFloat(value: number): string {
if (!Number.isFinite(value)) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", "WebGPU compositor constant is not finite");
const text = String(Math.fround(value));
return text.includes(".") || /e/i.test(text) ? text : `${text}.0`;
}
function instructionWGSL(instruction: CompositorWebGPUInstructionIR): string {
if (instruction.type === "CONSTANT_COLOR") {
return `color = vec4<f32>(${instruction.color.map(wgslFloat).join(", ")});`;
}
if (instruction.type === "EXPOSURE") {
return `color = vec4<f32>(color.rgb * ${wgslFloat(2 ** instruction.exposure)}, color.a);`;
}
if (instruction.type === "INVERT") return "color = vec4<f32>(vec3<f32>(1.0) - color.rgb, color.a);";
return "";
}
export async function requestCompositorWebGPUDevice(): Promise<GPUDevice> {
if (!navigator.gpu) throw new CompositorValidationError("WEBGPU_RENDERER_UNAVAILABLE", "WebGPU is unavailable for the compositor allowlist");
const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" });
if (!adapter) throw new CompositorValidationError("WEBGPU_RENDERER_UNAVAILABLE", "No WebGPU adapter is available for the compositor allowlist");
return adapter.requestDevice();
}
export async function executeCompositorGraphWebGPU(
device: GPUDevice,
value: unknown,
width: number,
height: number,
): Promise<CompositorImageBuffer> {
const plan = compileCompositorWebGPUPlan(value);
const pixelCount = width * height;
const byteLength = pixelCount * 16;
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width < 1 || height < 1 ||
width > COMPOSITOR_BUDGET.maxDimension || height > COMPOSITOR_BUDGET.maxDimension ||
!Number.isSafeInteger(pixelCount) || pixelCount > COMPOSITOR_BUDGET.maxPixels ||
byteLength > COMPOSITOR_BUDGET.maxImageBytes || byteLength > device.limits.maxStorageBufferBindingSize ||
byteLength > device.limits.maxBufferSize) {
throw new CompositorValidationError("COMPOSITOR_BUDGET_EXCEEDED", "WebGPU compositor output exceeds the image or device budget");
}
const output = device.createBuffer({ label: "Compositor WebGPU output", size: byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC });
const readback = device.createBuffer({ label: "Compositor WebGPU readback", size: byteLength, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
try {
device.pushErrorScope("validation");
const module = device.createShaderModule({ label: `Compositor ${plan.graphId}`, code: /* wgsl */`
@group(0) @binding(0) var<storage, read_write> pixels: array<vec4<f32>>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
if (id.x >= ${pixelCount}u) { return; }
var color = vec4<f32>(0.0);
${plan.instructions.map(instructionWGSL).filter(Boolean).join("\n ")}
pixels[id.x] = color;
}` });
const pipeline = device.createComputePipeline({ layout: "auto", compute: { module, entryPoint: "main" } });
const bindGroup = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: output } }] });
const encoder = device.createCommandEncoder();
const pass = encoder.beginComputePass();
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.dispatchWorkgroups(Math.ceil(pixelCount / 64));
pass.end();
encoder.copyBufferToBuffer(output, 0, readback, 0, byteLength);
device.queue.submit([encoder.finish()]);
await readback.mapAsync(GPUMapMode.READ);
const data = new Float32Array(readback.getMappedRange().slice(0));
readback.unmap();
const validationError = await device.popErrorScope();
if (validationError) throw new CompositorValidationError("COMPOSITOR_NODE_UNSUPPORTED", `WebGPU compositor shader validation failed: ${validationError.message ?? "unknown error"}`);
return { width, height, data, colorSpace: "LINEAR_SRGB" };
}
finally {
output.destroy();
readback.destroy();
}
}

View File

@@ -13,6 +13,7 @@ import type {
} from "../../../protocol/web-engine";
import type { LODGenerationRequest } from "../../../protocol/lod";
import type { SceneSnapshotIR } from "../../../protocol/scene-ir";
import type { ShaderCompileReport } from "../../../protocol/shader-compiler";
import type { SceneDelta } from "../../../protocol/scene-delta";
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
import type { SimplifyResult } from "../../../protocol/simplify";
@@ -20,6 +21,15 @@ import type { DepsgraphEvaluationIR } from "../../../protocol/depsgraph";
import type { RenderCapabilityRequest } from "../../../protocol/render-capabilities";
import type { CapabilityGateResult } from "../../../protocol/capability-gates";
import { applyMeshGeometryDelta } from "../../../protocol/mesh-geometry-delta";
import { createWorkerFault, type WorkerFault } from "../../../protocol/worker-fault";
import type {
PaintStrokeSessionBeginIR,
PaintStrokeSessionCancelIR,
PaintStrokeSessionChunkIR,
PaintStrokeSessionCommitIR,
PaintStrokeSessionReceiptIR,
} from "../../../protocol/paint-stroke-session";
import type { PaintPBVHCapabilityRequest } from "../../../protocol/paint-pbvh-capability";
interface PendingRequest {
resolve: (result: WebEngineResult) => void;
@@ -27,11 +37,13 @@ interface PendingRequest {
onProgress?: (progress: ProgressEvent) => void;
timer: ReturnType<typeof setTimeout>;
cleanup?: () => void;
aborted?: boolean;
}
export interface WebEngineClientOptions {
timeoutMs?: number;
workerFactory?: () => Worker;
onWorkerFault?: (fault: WorkerFault) => void;
}
const defaultWorkerFactory = () =>
@@ -48,7 +60,10 @@ export interface BlendOpenResult {
export class WebEngineClient {
private readonly timeoutMs: number;
private readonly workerFactory: () => Worker;
private readonly onWorkerFault?: (fault: WorkerFault) => void;
private worker: Worker | null = null;
private workerFaulted = false;
private lastWorkerFault: WorkerFault | null = null;
private requestCounter = 0;
private pending = new Map<string, PendingRequest>();
private geometryBuffers: MeshGeometryBuffer[] = [];
@@ -57,6 +72,7 @@ export class WebEngineClient {
constructor(options: WebEngineClientOptions = {}) {
this.timeoutMs = options.timeoutMs ?? 30_000;
this.workerFactory = options.workerFactory ?? defaultWorkerFactory;
this.onWorkerFault = options.onWorkerFault;
}
async init(): Promise<WebEngineStatus> {
@@ -88,7 +104,7 @@ export class WebEngineClient {
return { status: result.status, delta: result.delta };
}
async applyCommand(payload: WebEngineEditCommand): Promise<BlendOpenResult & { delta: SceneDelta }> {
async applyCommand(payload: WebEngineEditCommand): Promise<BlendOpenResult & { delta: SceneDelta; shaderCompile?: ShaderCompileReport }> {
const result = await this.request({ type: "applyCommand", payload });
if (!result.snapshot || !result.delta) throw this.report("INVALID_ARGUMENT", "WebEngine 未返回命令结果", true);
this.geometryBuffers = result.geometryDelta
@@ -102,9 +118,53 @@ export class WebEngineClient {
nonMeshGeometryBuffers: [...this.nonMeshGeometryBuffers],
delta: result.delta,
simplify: result.simplify,
shaderCompile: result.shaderCompile,
};
}
async beginPaintStroke(session: PaintStrokeSessionBeginIR): Promise<PaintStrokeSessionReceiptIR> {
const result = await this.request({ type: "beginPaintStroke", session });
if (!result.paintStrokeSession) throw this.report("PAINT_SCHEMA_INVALID", "WebEngine did not open the paint pointer session", true);
return result.paintStrokeSession;
}
async appendPaintStrokeChunk(chunk: PaintStrokeSessionChunkIR): Promise<PaintStrokeSessionReceiptIR> {
const result = await this.request({ type: "appendPaintStrokeChunk", chunk });
if (!result.paintStrokeSession) throw this.report("PAINT_SCHEMA_INVALID", "WebEngine did not accept the paint stroke chunk", true);
return result.paintStrokeSession;
}
async commitPaintStroke(session: PaintStrokeSessionCommitIR): Promise<BlendOpenResult & { delta: SceneDelta; paintStrokeSession: PaintStrokeSessionReceiptIR }> {
const result = await this.request({ type: "commitPaintStroke", session });
if (!result.snapshot || !result.delta || !result.paintStrokeSession) {
throw this.report("PAINT_SCHEMA_INVALID", "WebEngine did not return the committed paint stroke", true);
}
this.geometryBuffers = result.geometryDelta
? applyMeshGeometryDelta(this.geometryBuffers, result.geometryDelta)
: result.geometryBuffers ?? this.geometryBuffers;
this.nonMeshGeometryBuffers = result.nonMeshGeometryBuffers ?? this.nonMeshGeometryBuffers;
return {
status: result.status,
snapshot: result.snapshot,
geometryBuffers: [...this.geometryBuffers],
nonMeshGeometryBuffers: [...this.nonMeshGeometryBuffers],
delta: result.delta,
paintStrokeSession: result.paintStrokeSession,
};
}
async cancelPaintStroke(session: PaintStrokeSessionCancelIR): Promise<PaintStrokeSessionReceiptIR> {
const result = await this.request({ type: "cancelPaintStroke", session });
if (!result.paintStrokeSession) throw this.report("PAINT_SCHEMA_INVALID", "WebEngine did not cancel the paint pointer session", true);
return result.paintStrokeSession;
}
async queryPaintPBVHCapability(request: PaintPBVHCapabilityRequest): Promise<CapabilityGateResult> {
const result = await this.request({ type: "queryPaintPBVHCapability", request });
if (!result.capabilityGate) throw this.report("CAPABILITY_MISSING", "WebEngine did not return the PBVH paint capability gate", true);
return result.capabilityGate;
}
async previewCommand(payload: Extract<WebEngineEditCommand, { type: "previewDecimateMesh" }>): Promise<BlendOpenResult> {
const result = await this.request({ type: "applyCommand", payload });
if (!result.snapshot) throw this.report("INVALID_ARGUMENT", "WebEngine 未返回预览结果", true);
@@ -154,21 +214,41 @@ export class WebEngineClient {
return result.openResources;
}
async restart(): Promise<WebEngineStatus> {
this.worker?.terminate();
this.worker = null;
this.workerFaulted = false;
this.lastWorkerFault = null;
this.geometryBuffers = [];
this.nonMeshGeometryBuffers = [];
return this.init();
}
crashForTest(): void {
this.worker?.postMessage({
requestId: `web-engine-crash-${++this.requestCounter}`,
command: { type: "crashForTest" },
} satisfies WebEngineRequest);
}
terminate(): void {
this.failPending(this.report("WORKER_TERMINATED", "WebEngineWorker 已关闭", true));
this.worker?.terminate();
this.worker = null;
this.workerFaulted = true;
this.geometryBuffers = [];
this.nonMeshGeometryBuffers = [];
}
private start(): Worker {
if (this.workerFaulted) {
throw this.lastWorkerFault?.error ?? this.report("WORKER_TERMINATED", "WebEngineWorker 需要重启", true);
}
if (this.worker) return this.worker;
const worker = this.workerFactory();
worker.onmessage = (event: MessageEvent<WebEngineResponse>) => this.handleResponse(event.data);
worker.onerror = (event) => {
this.failPending(this.report("WORKER_TERMINATED", event.message || "WebEngineWorker 发生异常", true));
};
worker.onerror = (event) => this.handleWorkerFault(event.message || "WebEngineWorker 发生异常");
worker.onmessageerror = () => this.handleWorkerFault("WebEngineWorker 消息无法解析");
this.worker = worker;
return worker;
}
@@ -179,7 +259,13 @@ export class WebEngineClient {
onProgress?: (progress: ProgressEvent) => void,
signal?: AbortSignal,
): Promise<WebEngineResult> {
const worker = this.start();
let worker: Worker;
try {
worker = this.start();
}
catch (error) {
return Promise.reject(error);
}
const requestId = `web-engine-${++this.requestCounter}`;
const request = { requestId, command } as WebEngineRequest;
return new Promise<WebEngineResult>((resolve, reject) => {
@@ -189,6 +275,8 @@ export class WebEngineClient {
reject(this.report("WORKER_TERMINATED", `WebEngine 请求超时: ${command.type}`, true));
}, this.timeoutMs);
const abort = (): void => {
const pending = this.pending.get(requestId);
if (pending) pending.aborted = true;
worker.postMessage({
requestId: `web-engine-cancel-${++this.requestCounter}`,
command: { type: "cancelOpen", targetRequestId: requestId },
@@ -214,7 +302,10 @@ export class WebEngineClient {
this.pending.delete(response.requestId);
clearTimeout(pending.timer);
pending.cleanup?.();
if (response.ok) pending.resolve(response.result);
if (response.ok) {
if (pending.aborted) pending.reject(this.report("OPEN_CANCELLED", "WebEngine open cancelled", true));
else pending.resolve(response.result);
}
else pending.reject(response.error);
}
@@ -227,6 +318,18 @@ export class WebEngineClient {
this.pending.clear();
}
private handleWorkerFault(message: string): void {
if (this.workerFaulted) return;
const fault = createWorkerFault("engine", message);
this.workerFaulted = true;
this.lastWorkerFault = fault;
const worker = this.worker;
this.worker = null;
this.failPending(fault.error);
worker?.terminate();
this.onWorkerFault?.(fault);
}
private report(code: ErrorReport["code"], message: string, recoverable: boolean): ErrorReport {
return { code, severity: "error", message, recoverable };
}

View File

@@ -0,0 +1,130 @@
import {
createExternalVFontMainImport,
ExternalVFontValidationError,
validateExternalVFontImport,
validateStoredExternalVFontAsset,
type ExternalVFontImportRequestIR,
} from "../../../protocol/external-vfont";
import type { NonMeshFontLinksIR, SceneSnapshotIR, VFontResourceIR } from "../../../protocol/scene-ir";
import type { StorageAssetPutResult, StorageAssetReadResult, StorageAssetRecord } from "../../../protocol/storage";
import type { WebEngineEditCommand } from "../../../protocol/web-engine";
export interface ExternalVFontStoragePort {
putAsset(projectId: string, data: ArrayBuffer, mimeType: string, sourcePath?: string): Promise<StorageAssetPutResult>;
}
export interface ExternalVFontReadStoragePort {
readAsset(projectId: string, sha256: string): Promise<StorageAssetReadResult>;
}
export interface ExternalVFontEnginePort {
applyCommand(payload: WebEngineEditCommand): Promise<{ snapshot: SceneSnapshotIR }>;
}
export interface ImportExternalVFontOptions {
projectId: string;
request: ExternalVFontImportRequestIR;
storage: ExternalVFontStoragePort;
engine: ExternalVFontEnginePort;
}
export interface ImportExternalVFontResult {
asset: StorageAssetPutResult;
vfont: VFontResourceIR;
snapshot: SceneSnapshotIR;
}
export type ExternalVFontStyleSlot = keyof NonMeshFontLinksIR;
export interface ReplaceExternalVFontStyleOptions {
projectId: string;
sha256: string;
dataId: string;
vfontId: string;
style: ExternalVFontStyleSlot;
snapshot: SceneSnapshotIR;
storage: ExternalVFontReadStoragePort;
engine: ExternalVFontEnginePort;
}
export interface ReplaceExternalVFontStyleResult {
asset: StorageAssetRecord;
vfont: VFontResourceIR;
previousLinks: NonMeshFontLinksIR;
links: NonMeshFontLinksIR;
snapshot: SceneSnapshotIR;
}
function encodeBase64(data: ArrayBuffer): string {
const bytes = new Uint8Array(data);
let binary = "";
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
}
return btoa(binary);
}
export async function importExternalVFontIntoMain(options: ImportExternalVFontOptions): Promise<ImportExternalVFontResult> {
const validated = await validateExternalVFontImport(options.request);
const asset = await options.storage.putAsset(
options.projectId,
validated.data.slice(0),
validated.mimeType,
validated.sourcePath,
);
const mainImport = createExternalVFontMainImport(validated, asset);
const { data, ...proof } = mainImport;
const applied = await options.engine.applyCommand({ type: "importVFont", ...proof, base64: encodeBase64(data) });
const vfont = applied.snapshot.vfonts?.find((candidate) =>
candidate.sourcePath === proof.sourcePath && candidate.name === proof.name && candidate.packed &&
candidate.packedByteLength === proof.byteLength && candidate.sha256 === proof.sha256);
if (!vfont) throw new Error("NON_MESH_RESOURCE_MISSING: imported VFont was not published from Blender Main");
return { asset, vfont, snapshot: applied.snapshot };
}
export async function replaceExternalVFontStyleInMain(
options: ReplaceExternalVFontStyleOptions,
): Promise<ReplaceExternalVFontStyleResult> {
if (!(["regular", "bold", "italic", "boldItalic"] as string[]).includes(options.style)) {
throw new ExternalVFontValidationError("NON_MESH_PROPERTY_INVALID", "external VFont style slot is invalid");
}
const data = options.snapshot.nonMeshData?.find((candidate) => candidate.id === options.dataId);
if (!data || data.type !== "FONT" || !data.fontLinks) {
throw new ExternalVFontValidationError("NON_MESH_DATA_UNSUPPORTED", "font data block or style links are unavailable");
}
const vfont = options.snapshot.vfonts?.find((candidate) => candidate.id === options.vfontId);
if (!vfont || vfont.builtin || !vfont.packed || vfont.sha256 !== options.sha256 ||
!Number.isSafeInteger(vfont.packedByteLength) || (vfont.packedByteLength as number) <= 0) {
throw new ExternalVFontValidationError("NON_MESH_RESOURCE_MISSING", "replacement VFont is not a verified packed Main resource");
}
let stored: StorageAssetReadResult;
try {
stored = await options.storage.readAsset(options.projectId, options.sha256);
}
catch (error) {
if (error && typeof error === "object" && "code" in error &&
(error as { code?: unknown }).code === "NON_MESH_RESOURCE_MISSING") {
throw new ExternalVFontValidationError("NON_MESH_RESOURCE_MISSING", "replacement VFont project asset is missing");
}
throw error;
}
const validated = await validateStoredExternalVFontAsset(options.projectId, options.sha256, stored);
if (vfont.sourcePath !== validated.sourcePath || vfont.packedByteLength !== validated.byteLength ||
vfont.sha256 !== validated.sha256) {
throw new ExternalVFontValidationError("ASSET_SOURCE_HASH_MISMATCH", "packed Main VFont does not match its project asset");
}
const previousLinks = { ...data.fontLinks };
if (previousLinks[options.style] === vfont.id) {
throw new ExternalVFontValidationError("NON_MESH_PROPERTY_INVALID", "font style already uses the requested VFont");
}
const links = { ...previousLinks, [options.style]: vfont.id };
const applied = await options.engine.applyCommand({ type: "setFontLinks", dataId: data.id, links });
const appliedData = applied.snapshot.nonMeshData?.find((candidate) => candidate.id === data.id);
const appliedVFont = applied.snapshot.vfonts?.find((candidate) => candidate.id === vfont.id);
if (!appliedData?.fontLinks || appliedData.fontLinks[options.style] !== vfont.id ||
!appliedVFont?.packed || appliedVFont.sha256 !== validated.sha256 ||
appliedVFont.packedByteLength !== validated.byteLength) {
throw new ExternalVFontValidationError("NON_MESH_RESOURCE_MISSING", "Blender Main did not publish the verified font replacement");
}
return { asset: stored.asset, vfont: appliedVFont, previousLinks, links, snapshot: applied.snapshot };
}

View File

@@ -1,4 +1,18 @@
import type { NanoVDBGridIR, NanoVDBMaterialIR } from "../../../protocol/volume-vdb";
import {
consumeNanoVDBProgressiveRedrawBudget,
NANOVDB_PROGRESSIVE_REDRAW_LIMIT_CODE,
NANOVDB_PROGRESSIVE_REDRAW_MAX_FRAMES,
validateNanoVDBProgressiveRedrawLimit,
} from "../../../protocol/nanovdb-progressive-redraw";
import {
createNanoVDBPageFeedbackBuffer,
nanoVDBPageFeedbackByteLength,
NANOVDB_PAGE_FEEDBACK_RECORD_WGSL,
NANOVDB_PAGE_FEEDBACK_WGSL,
parseNanoVDBPageFeedbackBatch,
type NanoVDBPageFeedbackBatch,
} from "../../../protocol/nanovdb-page-feedback";
export interface NanoVDBWebGPUCapabilityIR {
available: boolean;
@@ -23,6 +37,9 @@ export interface NanoVDBWebGPUGrid {
residentVirtualPages: readonly number[];
uploadPage(pageIndex: number, data?: ArrayBuffer): void;
touchPage(pageIndex: number): boolean;
beginFrame(): void;
pinPage(pageIndex: number): boolean;
endFrame(): void;
evictPage(pageIndex: number): void;
hasResidentPage(pageIndex: number): boolean;
dispose(): void;
@@ -44,6 +61,104 @@ export interface NanoVDBGpuPageAllocatorStatsIR {
keys: string[];
}
export interface NanoVDBProgressiveRedrawStatsIR {
pending: boolean;
scheduledCount: number;
redrawCount: number;
maxRedraws: number;
capped: boolean;
errorCode: "NANOVDB_PROGRESSIVE_REDRAW_LIMIT" | null;
}
export interface NanoVDBProgressiveRedrawOptionsIR {
maxRedraws?: number;
}
export class NanoVDBProgressiveRedrawScheduler {
private pending = false;
private scheduledCount = 0;
private redrawCount = 0;
private capped = false;
private disposed = false;
private generation = 0;
private readonly maxRedraws: number;
constructor(
private readonly requestFrame: (callback: () => void) => void,
private readonly redraw: () => void,
options: NanoVDBProgressiveRedrawOptionsIR = {},
) {
const maxRedraws = options.maxRedraws ?? NANOVDB_PROGRESSIVE_REDRAW_MAX_FRAMES;
this.maxRedraws = validateNanoVDBProgressiveRedrawLimit(maxRedraws);
}
schedule(): boolean {
if (this.disposed || this.pending || this.capped) return false;
const budget = consumeNanoVDBProgressiveRedrawBudget(this.redrawCount, this.maxRedraws);
if (!budget.allowed) {
this.capped = budget.capped;
return false;
}
this.pending = true;
const generation = this.generation;
try {
this.requestFrame(() => {
if (this.disposed || generation !== this.generation || !this.pending) return;
this.pending = false;
const consumed = consumeNanoVDBProgressiveRedrawBudget(this.redrawCount, this.maxRedraws);
this.redrawCount = consumed.redrawCount;
this.capped = consumed.capped;
this.redraw();
});
this.scheduledCount++;
return true;
}
catch (error) {
this.pending = false;
throw error;
}
}
stats(): NanoVDBProgressiveRedrawStatsIR {
return {
pending: this.pending,
scheduledCount: this.scheduledCount,
redrawCount: this.redrawCount,
maxRedraws: this.maxRedraws,
capped: this.capped,
errorCode: this.capped ? NANOVDB_PROGRESSIVE_REDRAW_LIMIT_CODE : null,
};
}
/** Starts a new render epoch and invalidates callbacks queued by the old epoch. */
beginRender(): void {
if (this.disposed) return;
this.generation++;
this.pending = false;
this.scheduledCount = 0;
this.redrawCount = 0;
this.capped = false;
}
dispose(): void {
this.disposed = true;
this.generation++;
this.pending = false;
}
}
export class NanoVDBProgressivePageUploader {
constructor(
private readonly grid: NanoVDBWebGPUGrid,
private readonly redraw: NanoVDBProgressiveRedrawScheduler,
) {}
upload(pageId: number, data: ArrayBuffer): { pageId: number; redrawScheduled: boolean } {
this.grid.uploadPage(pageId, data);
return { pageId, redrawScheduled: this.redraw.schedule() };
}
}
export interface NanoVDBDeviceLossIR { reason?: string; message: string }
const traversalWGSL = /* wgsl */`
@@ -57,7 +172,7 @@ fn word(byte_offset: u32) -> u32 {
let page = byte_offset / params.page_bytes;
if (page >= params.page_count) { return 0u; }
let slot = page_table[page];
if (slot == 0xffffffffu || slot >= params.resident_pages) { return 0u; }
if (slot == 0xffffffffu || slot >= params.resident_pages) { /* NANOVDB_PAGE_FAULT */ return 0u; }
let physical = slot * params.page_bytes + (byte_offset % params.page_bytes);
if (physical > params.resident_pages * params.page_bytes - 4u) { return 0u; }
return grid[physical >> 2u];
@@ -148,6 +263,11 @@ fn sample_density_linear(position: vec3<f32>) -> vec2<f32> {
}
`;
const pageFeedbackTraversalWGSL = traversalWGSL.replaceAll(
"/* NANOVDB_PAGE_FAULT */",
"nanovdb_record_page_fault(page);",
);
function specializeFloatTraversal(prefix: string, gridName: string, pageTableName: string, parameterPrefix: string): string {
let source = traversalWGSL
.replaceAll("grid[", `${gridName}[`)
@@ -255,9 +375,10 @@ export function uploadNanoVDBFloat32Grid(device: GPUDevice, payload: ArrayBuffer
const buffer = device.createBuffer({ label: "NanoVDB Float32 grid", size: payload.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, mappedAtCreation: true });
new Uint8Array(buffer.getMappedRange()).set(new Uint8Array(payload));
buffer.unmap();
const pageTable = device.createBuffer({ label: "NanoVDB direct page table", size: 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, mappedAtCreation: true });
const pageTable = device.createBuffer({ label: "NanoVDB direct page table", size: 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, mappedAtCreation: true });
new Uint32Array(pageTable.getMappedRange())[0] = 0;
pageTable.unmap();
let disposed = false;
return {
buffer,
pageTable,
@@ -275,9 +396,17 @@ export function uploadNanoVDBFloat32Grid(device: GPUDevice, payload: ArrayBuffer
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,
beginFrame: () => undefined,
pinPage: (pageIndex) => pageIndex === 0,
endFrame: () => undefined,
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(); },
dispose: () => {
if (disposed) return;
disposed = true;
buffer.destroy();
pageTable.destroy();
},
};
}
@@ -322,7 +451,7 @@ export function createNanoVDBFloat32GridPaged(
const pageTableBytes = Math.max(4, pageCount * 4);
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 });
pageTable = device.createBuffer({ label: "NanoVDB page table", size: pageTableBytes, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC, mappedAtCreation: true });
new Uint32Array(pageTable.getMappedRange()).fill(0xffffffff);
pageTable.unmap();
}
@@ -335,6 +464,9 @@ export function createNanoVDBFloat32GridPaged(
const lastUsed = new Map<number, number>();
let clock = 0;
let evictions = 0;
let frameActive = false;
let disposed = false;
const framePins = new Set<number>();
const writePageTable = (pageIndex: number, slot: number): void => {
device.queue.writeBuffer(pageTable, pageIndex * 4, new Uint32Array([slot]));
};
@@ -356,8 +488,12 @@ export function createNanoVDBFloat32GridPaged(
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");
const oldest = [...lastUsed]
.filter(([candidate]) => !framePins.has(candidate))
.sort((left, right) => left[1] - right[1] || left[0] - right[0])[0];
if (!oldest) throw new Error(frameActive
? "NANOVDB_GPU_BUDGET_EXCEEDED: all resident NanoVDB pages are pinned"
: "NANOVDB_GPU_BUDGET_EXCEEDED: no resident NanoVDB page slot is available");
slot = resident.get(oldest[0]);
evict(oldest[0], true);
}
@@ -386,9 +522,25 @@ export function createNanoVDBFloat32GridPaged(
lastUsed.set(pageIndex, ++clock);
return true;
},
beginFrame: () => { frameActive = true; framePins.clear(); },
pinPage: (pageIndex) => {
if (!frameActive || !resident.has(pageIndex)) return false;
framePins.add(pageIndex);
return true;
},
endFrame: () => { frameActive = false; framePins.clear(); },
evictPage: evict,
hasResidentPage: (pageIndex) => resident.has(pageIndex),
dispose: () => { resident.clear(); lastUsed.clear(); buffer.destroy(); pageTable.destroy(); },
dispose: () => {
if (disposed) return;
disposed = true;
frameActive = false;
framePins.clear();
resident.clear();
lastUsed.clear();
buffer.destroy();
pageTable.destroy();
},
};
}
@@ -499,7 +651,97 @@ function paramsBuffer(device: GPUDevice, values: Uint32Array): GPUBuffer {
return buffer;
}
export async function readNanoVDBWordsWebGPU(device: GPUDevice, uploaded: NanoVDBWebGPUGrid, byteOffsets: readonly number[]): Promise<number[]> {
export function createNanoVDBPageFeedbackGPUBuffer(device: GPUDevice, capacity = 1024): GPUBuffer {
const initial = createNanoVDBPageFeedbackBuffer(capacity);
let buffer: GPUBuffer | undefined;
try {
buffer = device.createBuffer({
label: "NanoVDB page feedback",
size: initial.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
});
device.queue.writeBuffer(buffer, 0, initial);
return buffer;
}
catch (error) {
buffer?.destroy();
throw error;
}
}
export interface NanoVDBPagedRenderResources {
grid: NanoVDBWebGPUGrid;
feedbackBuffer: GPUBuffer;
feedbackCapacity: number;
dispose(): void;
}
export function createNanoVDBPagedRenderResources(
device: GPUDevice,
byteLength: number,
pageByteLength: number,
maxResidentBytes: number,
feedbackCapacity = 1024,
): NanoVDBPagedRenderResources {
let grid: NanoVDBWebGPUGrid | undefined;
let feedbackBuffer: GPUBuffer | undefined;
try {
grid = createNanoVDBFloat32GridPaged(device, byteLength, pageByteLength, maxResidentBytes);
feedbackBuffer = createNanoVDBPageFeedbackGPUBuffer(device, feedbackCapacity);
}
catch (error) {
feedbackBuffer?.destroy();
grid?.dispose();
throw error;
}
let disposed = false;
return {
grid,
feedbackBuffer,
feedbackCapacity,
dispose: () => {
if (disposed) return;
disposed = true;
feedbackBuffer.destroy();
grid.dispose();
},
};
}
export async function readNanoVDBPageFeedbackGPUBuffer(
device: GPUDevice,
feedback: GPUBuffer,
capacity: number,
pageCount: number,
renderRevision: number,
): Promise<NanoVDBPageFeedbackBatch> {
const byteLength = nanoVDBPageFeedbackByteLength(capacity);
const readback = device.createBuffer({
label: `NanoVDB page feedback readback revision ${renderRevision}`,
size: byteLength,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
});
const encoder = device.createCommandEncoder();
encoder.copyBufferToBuffer(feedback, 0, readback, 0, byteLength);
device.queue.submit([encoder.finish()]);
await readback.mapAsync(GPUMapMode.READ);
const buffer = readback.getMappedRange().slice(0);
readback.unmap();
readback.destroy();
return parseNanoVDBPageFeedbackBatch(buffer, pageCount, renderRevision);
}
function resolveNanoVDBPageFeedbackGPUBuffer(device: GPUDevice, value: GPUBuffer | undefined): { buffer: GPUBuffer; owned: boolean } {
if (value) return { buffer: value, owned: false };
return { buffer: createNanoVDBPageFeedbackGPUBuffer(device, 1), owned: true };
}
export async function readNanoVDBWordsWebGPU(
device: GPUDevice,
uploaded: NanoVDBWebGPUGrid,
byteOffsets: readonly number[],
pageFeedback?: GPUBuffer,
): 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");
}
@@ -510,6 +752,7 @@ export async function readNanoVDBWordsWebGPU(device: GPUDevice, uploaded: NanoVD
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 feedback = resolveNanoVDBPageFeedbackGPUBuffer(device, pageFeedback);
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>;
@@ -517,7 +760,10 @@ struct Params { data_bytes: u32, count: u32, width: u32, height: u32, page_bytes
@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}
${NANOVDB_PAGE_FEEDBACK_WGSL}
@group(0) @binding(5) var<storage, read_write> nanovdb_page_feedback: NanoVDBPageFeedback;
${NANOVDB_PAGE_FEEDBACK_RECORD_WGSL}
${pageFeedbackTraversalWGSL}
@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]); }
@@ -527,6 +773,7 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
{ 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 } },
{ binding: 5, resource: { buffer: feedback.buffer } },
] });
const encoder = device.createCommandEncoder();
const pass = encoder.beginComputePass();
@@ -537,10 +784,16 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
const result = [...new Uint32Array(readback.getMappedRange().slice(0))];
readback.unmap();
offsetBuffer.destroy(); resultBuffer.destroy(); readback.destroy(); params.destroy();
if (feedback.owned) feedback.buffer.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 }>> {
export async function sampleNanoVDBFloat32WebGPU(
device: GPUDevice,
uploaded: NanoVDBWebGPUGrid,
coordinates: Array<readonly [number, number, number]>,
pageFeedback?: GPUBuffer,
): 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);
coordinates.forEach((coord, index) => coordinateData.set(coord, index * 4));
@@ -550,6 +803,7 @@ export async function sampleNanoVDBFloat32WebGPU(device: GPUDevice, uploaded: Na
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, coordinates.length, 0, 0, uploaded.pageByteLength, uploaded.pageCount, uploaded.residentPageCapacity, uploaded.paged ? 1 : 0]));
const feedback = resolveNanoVDBPageFeedbackGPUBuffer(device, pageFeedback);
const module = device.createShaderModule({ label: "NanoVDB Float32 sampler", 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>;
@@ -557,7 +811,10 @@ struct Params { data_bytes: u32, count: u32, width: u32, height: u32, page_bytes
@group(0) @binding(2) var<storage, read_write> results: array<vec4<f32>>;
@group(0) @binding(3) var<uniform> params: Params;
@group(0) @binding(4) var<storage, read> page_table: array<u32>;
${traversalWGSL}
${NANOVDB_PAGE_FEEDBACK_WGSL}
@group(0) @binding(5) var<storage, read_write> nanovdb_page_feedback: NanoVDBPageFeedback;
${NANOVDB_PAGE_FEEDBACK_RECORD_WGSL}
${pageFeedbackTraversalWGSL}
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
if (id.x >= params.count) { return; }
@@ -569,6 +826,7 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
{ binding: 0, resource: { buffer: uploaded.buffer } }, { binding: 1, resource: { buffer: coordinateBuffer } },
{ binding: 2, resource: { buffer: resultBuffer } }, { binding: 3, resource: { buffer: params } },
{ binding: 4, resource: { buffer: uploaded.pageTable } },
{ binding: 5, resource: { buffer: feedback.buffer } },
] });
const encoder = device.createCommandEncoder();
const pass = encoder.beginComputePass();
@@ -579,6 +837,7 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
const values = new Float32Array(readback.getMappedRange().slice(0));
readback.unmap();
coordinateBuffer.destroy(); resultBuffer.destroy(); readback.destroy(); params.destroy();
if (feedback.owned) feedback.buffer.destroy();
return coordinates.map((_coord, index) => ({ value: values[index * 4], active: values[index * 4 + 1] > 0.5, valid: values[index * 4 + 1] >= 0 }));
}

View File

@@ -0,0 +1,232 @@
import {
parseSequencerAudioSessionReport,
SEQUENCER_AUDIO_SESSION_SCHEMA,
type SequencerAudioSessionIssueCode,
type SequencerAudioSessionReportIR,
} from "../../../protocol/sequencer-audio-session";
interface AudioParamLike {
value: number;
cancelScheduledValues?(startTime: number): void;
setValueAtTime(value: number, startTime: number): unknown;
}
interface GainNodeLike {
readonly gain: AudioParamLike;
connect(destination: unknown): unknown;
disconnect(): void;
}
export interface AudioContextLike {
readonly state: "suspended" | "running" | "closed" | string;
readonly currentTime: number;
readonly destination: unknown;
createGain(): GainNodeLike;
resume(): Promise<void>;
suspend(): Promise<void>;
close(): Promise<void>;
}
export interface SequencerAudioSessionOptions {
scope?: object;
contextFactory?: () => AudioContextLike;
outputGain?: number;
}
type AudioContextConstructor = new () => AudioContextLike;
export class SequencerAudioSession {
private readonly scope: object;
private readonly contextFactory?: () => AudioContextLike;
private readonly nominalGain: number;
private context: AudioContextLike | null = null;
private output: GainNodeLike | null = null;
private mutedValue = false;
private disposed = false;
private revisionValue = 0;
private issueCode: SequencerAudioSessionIssueCode | null = null;
constructor(options: SequencerAudioSessionOptions = {}) {
const outputGain = options.outputGain ?? 1;
if (typeof outputGain !== "number" || !Number.isFinite(outputGain) || outputGain <= 0 || outputGain > 1) {
const error = new Error("SEQUENCER_AUDIO_CONTEXT_INVALID: outputGain must be in (0, 1]") as Error & { code: string };
error.code = "SEQUENCER_AUDIO_CONTEXT_INVALID";
throw error;
}
this.scope = options.scope ?? globalThis;
this.contextFactory = options.contextFactory;
this.nominalGain = outputGain;
}
snapshot(): SequencerAudioSessionReportIR {
return this.report();
}
async initialize(): Promise<SequencerAudioSessionReportIR> {
this.revisionValue += 1;
if (this.disposed) return this.report();
if (!this.context || this.context.state === "closed") await this.openContext();
return this.report();
}
async resume(): Promise<SequencerAudioSessionReportIR> {
this.revisionValue += 1;
if (this.disposed) return this.report();
if (!this.context || this.context.state === "closed") await this.openContext();
if (!this.context) return this.report();
try {
if (this.context.state !== "running") await this.context.resume();
if (this.context.state !== "running") throw new Error("AudioContext did not enter running state");
this.issueCode = null;
this.applyGain();
}
catch {
this.issueCode = "SEQUENCER_AUDIO_RESUME_FAILED";
}
return this.report();
}
async suspend(): Promise<SequencerAudioSessionReportIR> {
this.revisionValue += 1;
if (this.disposed) return this.report();
if (!this.context) {
this.issueCode = "SEQUENCER_AUDIO_DEVICE_UNAVAILABLE";
return this.report();
}
try {
if (this.context.state === "running") await this.context.suspend();
if (this.context.state !== "suspended") throw new Error("AudioContext did not enter suspended state");
this.issueCode = null;
}
catch {
this.issueCode = "SEQUENCER_AUDIO_SUSPEND_FAILED";
}
return this.report();
}
setMuted(muted: boolean): SequencerAudioSessionReportIR {
if (typeof muted !== "boolean") {
const error = new Error("SEQUENCER_AUDIO_CONTEXT_INVALID: muted must be boolean") as Error & { code: string };
error.code = "SEQUENCER_AUDIO_CONTEXT_INVALID";
throw error;
}
this.revisionValue += 1;
if (!this.disposed) {
this.mutedValue = muted;
this.applyGain();
}
return this.report();
}
async recoverDevice(): Promise<SequencerAudioSessionReportIR> {
this.revisionValue += 1;
if (this.disposed) return this.report();
await this.releaseContext();
this.issueCode = null;
await this.openContext();
return this.report();
}
async close(): Promise<SequencerAudioSessionReportIR> {
this.revisionValue += 1;
if (!this.disposed) {
this.disposed = true;
await this.releaseContext();
this.issueCode = null;
}
return this.report();
}
private createContext(): AudioContextLike | null {
if (this.contextFactory) return this.contextFactory();
const constructor = (this.scope as { AudioContext?: unknown }).AudioContext;
if (typeof constructor !== "function") return null;
return new (constructor as AudioContextConstructor)();
}
private async openContext(): Promise<void> {
try {
const context = this.createContext();
if (!context || context.state === "closed") {
this.issueCode = "SEQUENCER_AUDIO_DEVICE_UNAVAILABLE";
return;
}
const output = context.createGain();
output.connect(context.destination);
this.context = context;
this.output = output;
this.issueCode = null;
this.applyGain();
}
catch {
this.context = null;
this.output = null;
this.issueCode = "SEQUENCER_AUDIO_DEVICE_UNAVAILABLE";
}
}
private applyGain(): void {
if (!this.context || !this.output || this.context.state === "closed") return;
const value = this.mutedValue ? 0 : this.nominalGain;
this.output.gain.cancelScheduledValues?.(this.context.currentTime);
this.output.gain.setValueAtTime(value, this.context.currentTime);
}
private async releaseContext(): Promise<void> {
const context = this.context;
const output = this.output;
this.context = null;
this.output = null;
if (context && output) {
try {
output.gain.cancelScheduledValues?.(context.currentTime);
output.gain.setValueAtTime(0, context.currentTime);
}
catch { /* Context loss can invalidate AudioParam before release. */ }
try { output.disconnect(); }
catch { /* A disconnected node is already released. */ }
}
if (context && context.state !== "closed") {
try { await context.close(); }
catch { /* The session still drops all references on close failure. */ }
}
}
private report(): SequencerAudioSessionReportIR {
if (this.disposed) {
return parseSequencerAudioSessionReport({
schemaVersion: SEQUENCER_AUDIO_SESSION_SCHEMA,
revision: this.revisionValue,
contextState: "CLOSED",
outputState: "SILENT",
muted: this.mutedValue,
outputGain: 0,
issueCode: null,
});
}
if (!this.context || !this.output || this.context.state === "closed") {
return parseSequencerAudioSessionReport({
schemaVersion: SEQUENCER_AUDIO_SESSION_SCHEMA,
revision: this.revisionValue,
contextState: "UNAVAILABLE",
outputState: "BLOCKED",
muted: this.mutedValue,
outputGain: 0,
issueCode: "SEQUENCER_AUDIO_DEVICE_UNAVAILABLE",
});
}
const contextState = this.context.state === "running" ? "RUNNING" : "SUSPENDED";
const outputGain = this.mutedValue ? 0 : this.nominalGain;
return parseSequencerAudioSessionReport({
schemaVersion: SEQUENCER_AUDIO_SESSION_SCHEMA,
revision: this.revisionValue,
contextState,
outputState: contextState === "RUNNING" && !this.mutedValue && this.issueCode === null ? "ENABLED" : "SILENT",
muted: this.mutedValue,
outputGain,
issueCode: this.issueCode,
});
}
}
export type { SequencerAudioSessionReportIR };

View File

@@ -0,0 +1,145 @@
import {
SEQUENCER_CODEC_PROBE_SCHEMA,
parseSequencerCodecProbeRequest,
type SequencerCodecProbeBackend,
type SequencerCodecProbeBlockReason,
type SequencerCodecProbeRequestIR,
type SequencerCodecProbeResultIR,
} from "../../../protocol/sequencer";
async function sha256(data: ArrayBuffer): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
function blocked(
request: SequencerCodecProbeRequestIR,
backend: SequencerCodecProbeBackend | null,
reason: SequencerCodecProbeBlockReason,
): SequencerCodecProbeResultIR {
return { ...request, status: "BLOCKED", backend, reason, decoded: null };
}
function waitForMedia(video: HTMLVideoElement, eventName: "loadeddata" | "seeked", timeoutMs: number): Promise<void> {
return new Promise((resolve, reject) => {
const timeout = window.setTimeout(() => finish(new Error("timeout")), timeoutMs);
const finish = (error?: Error): void => {
window.clearTimeout(timeout);
video.removeEventListener(eventName, ready);
video.removeEventListener("error", failed);
if (error) reject(error); else resolve();
};
const ready = (): void => finish();
const failed = (): void => finish(new Error("decode"));
video.addEventListener(eventName, ready, { once: true });
video.addEventListener("error", failed, { once: true });
});
}
async function probeImage(request: SequencerCodecProbeRequestIR, data: ArrayBuffer): Promise<SequencerCodecProbeResultIR> {
if (typeof createImageBitmap !== "function") return blocked(request, null, "RUNTIME_UNAVAILABLE");
try {
const bitmap = await createImageBitmap(new Blob([data], { type: request.mimeType }));
try {
if (bitmap.width < 1 || bitmap.height < 1) return blocked(request, "IMAGE_BITMAP", "DECODE_FAILED");
return { ...request, status: "READY", backend: "IMAGE_BITMAP", reason: null, decoded: { width: bitmap.width, height: bitmap.height } };
}
finally { bitmap.close(); }
}
catch { return blocked(request, "IMAGE_BITMAP", "DECODE_FAILED"); }
}
async function probeSound(request: SequencerCodecProbeRequestIR, data: ArrayBuffer): Promise<SequencerCodecProbeResultIR> {
if (typeof OfflineAudioContext === "undefined") return blocked(request, null, "RUNTIME_UNAVAILABLE");
const context = new OfflineAudioContext(1, 1, 48_000);
try {
const decoded = await context.decodeAudioData(data.slice(0));
if (decoded.sampleRate < 1 || decoded.numberOfChannels < 1 || decoded.length < 1) return blocked(request, "WEB_AUDIO", "DECODE_FAILED");
return {
...request,
status: "READY",
backend: "WEB_AUDIO",
reason: null,
decoded: { sampleRate: decoded.sampleRate, channels: decoded.numberOfChannels, durationFrames: decoded.length },
};
}
catch { return blocked(request, "WEB_AUDIO", "DECODE_FAILED"); }
}
async function probeMovie(request: SequencerCodecProbeRequestIR, data: ArrayBuffer): Promise<SequencerCodecProbeResultIR> {
if (typeof document === "undefined" || typeof HTMLMediaElement === "undefined") return blocked(request, null, "RUNTIME_UNAVAILABLE");
const video = document.createElement("video");
if (video.canPlayType(request.mimeType) === "") return blocked(request, "HTML_MEDIA", "MIME_UNSUPPORTED");
const url = URL.createObjectURL(new Blob([data], { type: request.mimeType }));
video.preload = "auto";
video.muted = true;
video.playsInline = true;
video.style.display = "none";
document.body.append(video);
try {
video.src = url;
video.load();
await waitForMedia(video, "loadeddata", 10_000);
if (!Number.isFinite(video.duration) || video.duration <= 0 || video.videoWidth < 1 || video.videoHeight < 1) {
return blocked(request, "HTML_MEDIA", "DECODE_FAILED");
}
const target = Math.min(video.duration / 2, Math.max(0, video.duration - 0.001));
if (target > 0) {
video.currentTime = target;
await waitForMedia(video, "seeked", 10_000);
}
const canvas = document.createElement("canvas");
canvas.width = 1;
canvas.height = 1;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) return blocked(request, "HTML_MEDIA", "RUNTIME_UNAVAILABLE");
context.drawImage(video, 0, 0, 1, 1);
context.getImageData(0, 0, 1, 1);
return {
...request,
status: "READY",
backend: "HTML_MEDIA",
reason: null,
decoded: {
width: video.videoWidth,
height: video.videoHeight,
durationMicros: Math.max(1, Math.round(video.duration * 1_000_000)),
},
};
}
catch { return blocked(request, "HTML_MEDIA", "DECODE_FAILED"); }
finally {
video.removeAttribute("src");
video.load();
video.remove();
URL.revokeObjectURL(url);
}
}
export async function probeSequencerCodec(
requestValue: unknown,
source: ArrayBuffer,
): Promise<SequencerCodecProbeResultIR> {
const request = parseSequencerCodecProbeRequest(requestValue);
if (!(source instanceof ArrayBuffer) || source.byteLength !== request.byteLength || await sha256(source) !== request.sourceSha256) {
return blocked(request, null, "SOURCE_IDENTITY_MISMATCH");
}
if (request.stripType === "IMAGE") return probeImage(request, source);
if (request.stripType === "SOUND") return probeSound(request, source);
return probeMovie(request, source);
}
export function createSequencerCodecProbeRequest(
stripType: SequencerCodecProbeRequestIR["stripType"],
mimeType: string,
byteLength: number,
sourceSha256: string,
): SequencerCodecProbeRequestIR {
return parseSequencerCodecProbeRequest({
schemaVersion: SEQUENCER_CODEC_PROBE_SCHEMA,
stripType,
mimeType,
byteLength,
sourceSha256,
});
}

View File

@@ -0,0 +1,18 @@
import {
routeSequencerFinalExport,
type SequencerFinalExportRequestIR,
type SequencerFinalExportRouteIR,
} from "../../../protocol/sequencer-export";
export async function routeSequencerFinalExportInBrowser(
request: SequencerFinalExportRequestIR,
serverExportAvailable: boolean,
scope: object = globalThis,
): Promise<SequencerFinalExportRouteIR> {
return routeSequencerFinalExport(request, {
serverExportAvailable,
browserVideoEncoderAvailable: "VideoEncoder" in scope,
});
}
export type { SequencerFinalExportRequestIR, SequencerFinalExportRouteIR };

View File

@@ -0,0 +1,201 @@
import {
SEQUENCER_MEDIA_PROXY_MAX_BYTES,
createSequencerMediaCacheManifest,
computeSequencerMediaCacheIdentity,
parseSequencerMediaProxyProfile,
sequencerMediaCacheKey,
verifySequencerMediaCacheEntry,
SequencerMediaCacheValidationError,
type SequencerMediaCacheManifestIR,
type SequencerMediaProxyProfileIR,
} from "../../../protocol/sequencer-media-cache";
import {
gateSequencerCodec,
parseSequencerCodecProbeRequest,
parseSequencerCodecProbeResult,
type SequencerCodecProbeRequestIR,
type SequencerCodecProbeResultIR,
} from "../../../protocol/sequencer";
export interface SequencerMediaProxyCacheStatsIR {
entries: number;
bytes: number;
maxBytes: number;
hits: number;
misses: number;
evictions: number;
keys: string[];
}
export interface SequencerGeneratedProxyFrameIR {
manifest: SequencerMediaCacheManifestIR;
data: ArrayBuffer;
}
interface CacheEntry {
manifest: SequencerMediaCacheManifestIR;
data: ArrayBuffer;
}
async function sha256(data: ArrayBuffer): Promise<string> {
const result = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(result), (value) => value.toString(16).padStart(2, "0")).join("");
}
function waitForLoadedFrame(video: HTMLVideoElement, timeoutMs: number): Promise<void> {
if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return Promise.resolve();
return new Promise((resolve, reject) => {
const timeout = window.setTimeout(() => finish(new Error("timeout")), timeoutMs);
const finish = (error?: Error): void => {
window.clearTimeout(timeout);
video.removeEventListener("loadeddata", ready);
video.removeEventListener("error", failed);
if (error) reject(error); else resolve();
};
const ready = (): void => finish();
const failed = (): void => finish(new Error("decode"));
video.addEventListener("loadeddata", ready, { once: true });
video.addEventListener("error", failed, { once: true });
});
}
export async function generateInitialSequencerMovieProxyFrame(
sourceValue: unknown,
capabilityValue: unknown,
profileValue: unknown,
sourceData: ArrayBuffer,
): Promise<SequencerGeneratedProxyFrameIR> {
const source = parseSequencerCodecProbeRequest(sourceValue);
const capability = parseSequencerCodecProbeResult(capabilityValue);
const profile = parseSequencerMediaProxyProfile(profileValue);
if (source.stripType !== "MOVIE" || capability.status !== "READY" || capability.stripType !== "MOVIE" ||
gateSequencerCodec(source, capability).status !== "READY") {
throw new SequencerMediaCacheValidationError("SEQUENCER_CODEC_UNSUPPORTED", "Movie proxy generation requires a READY movie receipt");
}
if (!(sourceData instanceof ArrayBuffer) || sourceData.byteLength !== source.byteLength || await sha256(sourceData) !== source.sourceSha256) {
throw new SequencerMediaCacheValidationError("SEQUENCER_CACHE_SOURCE_MISMATCH", "Movie proxy source bytes failed identity verification");
}
if (typeof document === "undefined" || typeof HTMLMediaElement === "undefined") {
throw new SequencerMediaCacheValidationError("SEQUENCER_CODEC_UNSUPPORTED", "HTML media proxy generation is unavailable");
}
const video = document.createElement("video");
video.muted = true;
video.playsInline = true;
video.preload = "auto";
const url = URL.createObjectURL(new Blob([sourceData], { type: source.mimeType }));
try {
video.src = url;
video.load();
await waitForLoadedFrame(video, 10_000);
const canvas = document.createElement("canvas");
canvas.width = profile.width;
canvas.height = profile.height;
const context = canvas.getContext("2d", { alpha: true, willReadFrequently: true });
if (!context || video.videoWidth < 1 || video.videoHeight < 1) {
throw new SequencerMediaCacheValidationError("SEQUENCER_CODEC_UNSUPPORTED", "Movie proxy frame could not be decoded");
}
context.clearRect(0, 0, profile.width, profile.height);
context.drawImage(video, 0, 0, profile.width, profile.height);
const pixels = context.getImageData(0, 0, profile.width, profile.height).data;
const data = pixels.buffer.slice(pixels.byteOffset, pixels.byteOffset + pixels.byteLength);
return {
manifest: await createSequencerMediaCacheManifest(source, capability, profile, 0, data),
data,
};
}
catch (error) {
if (error instanceof SequencerMediaCacheValidationError) throw error;
throw new SequencerMediaCacheValidationError("SEQUENCER_CODEC_UNSUPPORTED", "Movie proxy frame decode failed");
}
finally {
video.pause();
video.removeAttribute("src");
video.load();
URL.revokeObjectURL(url);
}
}
export class SequencerMediaProxyCache {
readonly maxBytes: number;
private readonly entries = new Map<string, CacheEntry>();
private currentBytes = 0;
private hitCount = 0;
private missCount = 0;
private evictionCount = 0;
constructor(maxBytes: number) {
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > SEQUENCER_MEDIA_PROXY_MAX_BYTES) {
throw new SequencerMediaCacheValidationError("SEQUENCER_BUDGET_EXCEEDED", "Sequencer proxy cache budget is invalid");
}
this.maxBytes = maxBytes;
}
async put(
manifestValue: unknown,
data: ArrayBuffer,
currentSource: SequencerCodecProbeRequestIR,
currentCapability: SequencerCodecProbeResultIR,
): Promise<string> {
const manifest = await verifySequencerMediaCacheEntry(manifestValue, data, currentSource, currentCapability);
if (data.byteLength > this.maxBytes) {
throw new SequencerMediaCacheValidationError("SEQUENCER_BUDGET_EXCEEDED", "Sequencer proxy entry exceeds the cache budget");
}
const key = sequencerMediaCacheKey(manifest);
const previous = this.entries.get(key);
if (previous) {
this.currentBytes -= previous.data.byteLength;
this.entries.delete(key);
}
while (this.currentBytes + data.byteLength > this.maxBytes) {
const oldest = this.entries.entries().next().value as [string, CacheEntry] | undefined;
if (!oldest) break;
this.entries.delete(oldest[0]);
this.currentBytes -= oldest[1].data.byteLength;
this.evictionCount++;
}
this.entries.set(key, { manifest, data: data.slice(0) });
this.currentBytes += data.byteLength;
return key;
}
async get(
sourceValue: unknown,
capabilityValue: unknown,
profileValue: unknown,
sourceFrame: number,
): Promise<SequencerGeneratedProxyFrameIR | undefined> {
const source = parseSequencerCodecProbeRequest(sourceValue);
const capability = parseSequencerCodecProbeResult(capabilityValue);
const profile = parseSequencerMediaProxyProfile(profileValue);
const identitySha256 = await computeSequencerMediaCacheIdentity(source, capability, profile, sourceFrame);
const key = `sequencer-media-cache:v1:${identitySha256}`;
const entry = this.entries.get(key);
if (!entry) { this.missCount++; return undefined; }
this.hitCount++;
this.entries.delete(key);
this.entries.set(key, entry);
return { manifest: entry.manifest, data: entry.data.slice(0) };
}
clear(): number {
const released = this.currentBytes;
this.entries.clear();
this.currentBytes = 0;
return released;
}
stats(): SequencerMediaProxyCacheStatsIR {
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 type { SequencerMediaCacheManifestIR, SequencerMediaProxyProfileIR };

View File

@@ -0,0 +1,76 @@
import {
SEQUENCER_MEDIA_REVISION_SCHEMA,
gateSequencerMediaRevision,
parseSequencerMediaRevisionRequest,
parseSequencerMediaRevisionState,
SequencerMediaRevisionValidationError,
type SequencerMediaOperation,
type SequencerMediaRevisionDecisionIR,
type SequencerMediaRevisionRequestIR,
type SequencerMediaRevisionResultIR,
type SequencerMediaRevisionStateIR,
} from "../../../protocol/sequencer-media-revision";
export class SequencerMediaRevisionGate {
private stateValue: SequencerMediaRevisionStateIR;
constructor(timelineId: string, timelineRevision: number) {
this.stateValue = parseSequencerMediaRevisionState({
schemaVersion: SEQUENCER_MEDIA_REVISION_SCHEMA,
timelineId,
timelineRevision,
latestRequestRevision: 0,
});
}
state(): SequencerMediaRevisionStateIR {
return { ...this.stateValue };
}
begin(operation: SequencerMediaOperation, frame: number): SequencerMediaRevisionRequestIR {
this.stateValue = {
...this.stateValue,
latestRequestRevision: this.stateValue.latestRequestRevision + 1,
};
return parseSequencerMediaRevisionRequest({
schemaVersion: SEQUENCER_MEDIA_REVISION_SCHEMA,
requestId: `media:${this.stateValue.latestRequestRevision}`,
timelineId: this.stateValue.timelineId,
timelineRevision: this.stateValue.timelineRevision,
requestRevision: this.stateValue.latestRequestRevision,
operation,
frame,
});
}
replaceTimeline(timelineId: string, timelineRevision: number): SequencerMediaRevisionStateIR {
if (timelineId === this.stateValue.timelineId && timelineRevision <= this.stateValue.timelineRevision) {
throw new SequencerMediaRevisionValidationError("REVISION_CONFLICT", "Sequencer timeline revision must advance monotonically");
}
this.stateValue = parseSequencerMediaRevisionState({
schemaVersion: SEQUENCER_MEDIA_REVISION_SCHEMA,
timelineId,
timelineRevision,
latestRequestRevision: this.stateValue.latestRequestRevision + 1,
});
return this.state();
}
resolve(
request: SequencerMediaRevisionRequestIR,
result: SequencerMediaRevisionResultIR,
publish: (result: SequencerMediaRevisionResultIR) => void,
): SequencerMediaRevisionDecisionIR {
const decision = gateSequencerMediaRevision(request, this.stateValue, result);
if (decision.status === "PUBLISH") publish(result);
return decision;
}
}
export type {
SequencerMediaOperation,
SequencerMediaRevisionDecisionIR,
SequencerMediaRevisionRequestIR,
SequencerMediaRevisionResultIR,
SequencerMediaRevisionStateIR,
};

View File

@@ -8,7 +8,54 @@ export {
sequencerSourceFrame,
} from "../../../protocol/sequencer";
export type {
SequencerCodecProbeRequestIR,
SequencerCodecProbeResultIR,
SequencerFrameStripIR,
SequencerTimelineIR,
SequencerTransitionFrameIR,
} from "../../../protocol/sequencer";
export {
computeSequencerMediaCacheIdentity,
createSequencerMediaCacheManifest,
parseSequencerMediaCacheManifest,
parseSequencerMediaProxyProfile,
sequencerMediaCacheKey,
verifySequencerMediaCacheEntry,
} from "../../../protocol/sequencer-media-cache";
export {
gateSequencerMediaRevision,
parseSequencerMediaRevisionRequest,
parseSequencerMediaRevisionResult,
parseSequencerMediaRevisionState,
} from "../../../protocol/sequencer-media-revision";
export {
parseSequencerFinalExportEnvironment,
parseSequencerFinalExportRequest,
routeSequencerFinalExport,
} from "../../../protocol/sequencer-export";
export type {
SequencerFinalExportEnvironmentIR,
SequencerFinalExportRequestIR,
SequencerFinalExportRouteIR,
} from "../../../protocol/sequencer-export";
export {
parseSequencerAudioSessionReport,
SEQUENCER_AUDIO_SESSION_SCHEMA,
} from "../../../protocol/sequencer-audio-session";
export type {
SequencerAudioContextState,
SequencerAudioOutputState,
SequencerAudioSessionIssueCode,
SequencerAudioSessionReportIR,
} from "../../../protocol/sequencer-audio-session";
export type {
SequencerMediaOperation,
SequencerMediaRevisionDecisionIR,
SequencerMediaRevisionRequestIR,
SequencerMediaRevisionResultIR,
SequencerMediaRevisionStateIR,
} from "../../../protocol/sequencer-media-revision";
export type {
SequencerMediaCacheManifestIR,
SequencerMediaProxyProfileIR,
} from "../../../protocol/sequencer-media-cache";

View File

@@ -1,19 +1,59 @@
import type { StorageAssetListResult, StorageAssetPutResult, StorageAssetReadResult, StorageInfoResult, StorageLODManifestListResult, StorageLODManifestResult, StorageLODPruneResult, StorageLODReadResult, StorageLODResult, StorageOperationListResult, StorageOperationPruneResult, StorageOperationResult, StorageProjectReadResult, StorageProjectResult, StorageRecoveryResult, StorageRequest, StorageResponse, StorageSaveResult, StorageSimulationCacheFrameReadResult, StorageSimulationCacheListResult, StorageSimulationCacheReadResult, StorageSimulationCacheResult, StorageSmokeResult, StorageSnapshotListResult, StorageSnapshotReadResult, StorageSnapshotResult } from "../../../protocol/storage";
import type { StorageAssetListResult, StorageAssetPutResult, StorageAssetReadResult, StorageBudgetResult, StorageInfoResult, StorageLODManifestListResult, StorageLODManifestResult, StorageLODPruneResult, StorageLODReadResult, StorageLODResult, StorageOperationListResult, StorageOperationPruneResult, StorageOperationResult, StorageProjectCleanupResult, StorageProjectReadResult, StorageProjectResult, StorageRecoveryResult, StorageRecentProjectsResult, StorageRequest, StorageResponse, StorageSaveResult, StorageSimulationCacheFrameReadResult, StorageSimulationCacheListResult, StorageSimulationCachePlaybackReadyResult, StorageSimulationCachePlaybackReleaseResult, StorageSimulationCachePruneResult, StorageSimulationCacheReadResult, StorageSimulationCacheResult, StorageSmokeResult, StorageSnapshotListResult, StorageSnapshotReadResult, StorageSnapshotResult } from "../../../protocol/storage";
import type { LODCacheRecord } from "../../../protocol/lod";
import type { SimulationCacheManifestIR } from "../../../protocol/simulation-cache";
import type { RecentProjectRecord } from "../../../protocol/recent-projects";
import { createWorkerFault, type WorkerFault } from "../../../protocol/worker-fault";
import type { TexturePaintTileBindingRequestIR, TexturePaintTileBindingResultIR, TexturePaintTileCommitIR, TexturePaintTileCommitResultIR } from "../../../protocol/texture-paint-asset";
export interface StorageClientOptions {
workerFactory?: () => Worker;
onWorkerFault?: (fault: WorkerFault) => void;
}
const defaultWorkerFactory = () => new Worker(new URL("../workers/storage.worker.ts", import.meta.url), { type: "module" });
export class StorageClient {
private readonly worker: Worker;
private readonly workerFactory: () => Worker;
private readonly onWorkerFault?: (fault: WorkerFault) => void;
private worker: Worker | null = null;
private workerFaulted = false;
private lastWorkerFault: WorkerFault | null = null;
private counter = 0;
private readonly pending = new Map<string, { resolve: (result: StorageResponse["result"]) => void; reject: (error: Error) => void; timeout: number }>();
private readonly pending = new Map<string, { resolve: (result: StorageResponse["result"]) => void; reject: (error: Error) => void; timeout: number; cleanupAbort?: () => void }>();
constructor() {
this.worker = new Worker(new URL("../workers/storage.worker.ts", import.meta.url), { type: "module" });
this.worker.onmessage = (event: MessageEvent<StorageResponse>) => {
constructor(options: StorageClientOptions = {}) {
this.workerFactory = options.workerFactory ?? defaultWorkerFactory;
this.onWorkerFault = options.onWorkerFault;
this.start();
}
async restart(): Promise<StorageInfoResult> {
this.worker?.terminate();
this.worker = null;
this.workerFaulted = false;
this.lastWorkerFault = null;
return this.info();
}
crashForTest(): void {
this.worker?.postMessage({
requestId: `storage-crash-${++this.counter}`,
command: { type: "crashForTest" },
} satisfies StorageRequest);
}
private start(): Worker {
if (this.workerFaulted) {
throw this.lastWorkerFault ? this.toError(this.lastWorkerFault) : new Error("StorageWorker 需要重启");
}
if (this.worker) return this.worker;
const worker = this.workerFactory();
worker.onmessage = (event: MessageEvent<StorageResponse>) => {
const pending = this.pending.get(event.data.requestId);
if (!pending) return;
this.pending.delete(event.data.requestId);
window.clearTimeout(pending.timeout);
pending.cleanupAbort?.();
if (event.data.ok && event.data.result) pending.resolve(event.data.result);
else {
const error = new Error(event.data.error ?? "StorageWorker failed") as Error & { code?: string };
@@ -21,14 +61,10 @@ export class StorageClient {
pending.reject(error);
}
};
this.worker.onerror = (event) => {
const error = new Error(event.message || "StorageWorker error");
for (const pending of this.pending.values()) {
window.clearTimeout(pending.timeout);
pending.reject(error);
}
this.pending.clear();
};
worker.onerror = (event) => this.handleWorkerFault(event.message || "StorageWorker error");
worker.onmessageerror = () => this.handleWorkerFault("StorageWorker 消息无法解析");
this.worker = worker;
return worker;
}
smoke(): Promise<StorageSmokeResult> {
@@ -39,10 +75,30 @@ export class StorageClient {
return this.request({ type: "info" }) as Promise<StorageInfoResult>;
}
getBudget(projectId: string): Promise<StorageBudgetResult> {
return this.request({ type: "getBudget", projectId }) as Promise<StorageBudgetResult>;
}
listRecentProjects(): Promise<StorageRecentProjectsResult> {
return this.request({ type: "listRecentProjects" }) as Promise<StorageRecentProjectsResult>;
}
touchRecentProject(project: RecentProjectRecord): Promise<StorageRecentProjectsResult> {
return this.request({ type: "touchRecentProject", project }) as Promise<StorageRecentProjectsResult>;
}
removeRecentProject(projectId: string): Promise<StorageRecentProjectsResult> {
return this.request({ type: "removeRecentProject", projectId }) as Promise<StorageRecentProjectsResult>;
}
ensureProject(projectId: string): Promise<StorageProjectResult> {
return this.request({ type: "ensureProject", projectId }) as Promise<StorageProjectResult>;
}
cleanupProject(projectId: string): Promise<StorageProjectCleanupResult> {
return this.request({ type: "cleanupProject", projectId }) as Promise<StorageProjectCleanupResult>;
}
saveProject(projectId: string, revision: number, buffer: ArrayBuffer, faultAt?: "after-stage" | "after-scene-commit" | "before-metadata-commit" | "quota"): Promise<StorageSaveResult> {
return this.request({ type: "saveProject", projectId, revision, buffer, faultAt }, [buffer]) as Promise<StorageSaveResult>;
}
@@ -91,6 +147,14 @@ export class StorageClient {
return this.request({ type: "listAssets", projectId }) as Promise<StorageAssetListResult>;
}
commitTexturePaintTile(commit: TexturePaintTileCommitIR): Promise<TexturePaintTileCommitResultIR> {
return this.request({ type: "commitTexturePaintTile", commit }) as Promise<TexturePaintTileCommitResultIR>;
}
readTexturePaintTileBinding(request: TexturePaintTileBindingRequestIR): Promise<TexturePaintTileBindingResultIR> {
return this.request({ type: "readTexturePaintTileBinding", request }) as Promise<TexturePaintTileBindingResultIR>;
}
saveLOD(projectId: string, cacheKey: string, data: ArrayBuffer): Promise<StorageLODResult> {
return this.request({ type: "saveLOD", projectId, cacheKey, data }, [data]) as Promise<StorageLODResult>;
}
@@ -119,45 +183,111 @@ export class StorageClient {
return this.request({ type: "pruneLOD", projectId, maxBytes }) as Promise<StorageLODPruneResult>;
}
putSimulationCache(projectId: string, manifest: SimulationCacheManifestIR, data: ArrayBuffer): Promise<StorageSimulationCacheResult> {
return this.request({ type: "putSimulationCache", projectId, manifest, data }, [data]) as Promise<StorageSimulationCacheResult>;
putSimulationCache(projectId: string, manifest: SimulationCacheManifestIR, data: ArrayBuffer, signal?: AbortSignal): Promise<StorageSimulationCacheResult> {
return this.request({ type: "putSimulationCache", projectId, manifest, data }, [data], signal) as Promise<StorageSimulationCacheResult>;
}
readSimulationCache(projectId: string, cacheKey: string): Promise<StorageSimulationCacheReadResult> {
return this.request({ type: "readSimulationCache", projectId, cacheKey }) as Promise<StorageSimulationCacheReadResult>;
prepareSimulationCachePlayback(projectId: string, cacheKey: string, signal?: AbortSignal): Promise<StorageSimulationCachePlaybackReadyResult> {
return this.request({ type: "prepareSimulationCachePlayback", projectId, cacheKey }, [], signal) as Promise<StorageSimulationCachePlaybackReadyResult>;
}
readSimulationCacheFrame(projectId: string, cacheKey: string, frame: number): Promise<StorageSimulationCacheFrameReadResult> {
return this.request({ type: "readSimulationCacheFrame", projectId, cacheKey, frame }) as Promise<StorageSimulationCacheFrameReadResult>;
releaseSimulationCachePlayback(projectId: string, cacheKey: string): Promise<StorageSimulationCachePlaybackReleaseResult> {
return this.request({ type: "releaseSimulationCachePlayback", projectId, cacheKey }) as Promise<StorageSimulationCachePlaybackReleaseResult>;
}
readSimulationCache(projectId: string, cacheKey: string, signal?: AbortSignal): Promise<StorageSimulationCacheReadResult> {
return this.request({ type: "readSimulationCache", projectId, cacheKey }, [], signal) as Promise<StorageSimulationCacheReadResult>;
}
readSimulationCacheFrame(projectId: string, cacheKey: string, frame: number, signal?: AbortSignal): Promise<StorageSimulationCacheFrameReadResult> {
return this.request({ type: "readSimulationCacheFrame", projectId, cacheKey, frame }, [], signal) as Promise<StorageSimulationCacheFrameReadResult>;
}
listSimulationCaches(projectId: string): Promise<StorageSimulationCacheListResult> {
return this.request({ type: "listSimulationCaches", projectId }) as Promise<StorageSimulationCacheListResult>;
}
pruneSimulationCaches(projectId: string, maxBytes: number, protectedCacheKeys: string[] = []): Promise<StorageSimulationCachePruneResult> {
return this.request({ type: "pruneSimulationCaches", projectId, maxBytes, protectedCacheKeys }) as Promise<StorageSimulationCachePruneResult>;
}
getPendingRequestCount(): number {
return this.pending.size;
}
private request(command: StorageRequest["command"], transfer: Transferable[] = []): Promise<NonNullable<StorageResponse["result"]>> {
private request(command: StorageRequest["command"], transfer: Transferable[] = [], signal?: AbortSignal): Promise<NonNullable<StorageResponse["result"]>> {
if (signal?.aborted) return Promise.reject(new DOMException("Storage request aborted", "AbortError"));
let worker: Worker;
try {
worker = this.start();
}
catch (error) {
return Promise.reject(error);
}
const requestId = `storage-${++this.counter}`;
const request: StorageRequest = { requestId, command };
return new Promise((resolve, reject) => {
const timeout = window.setTimeout(() => {
const pending = this.pending.get(requestId);
if (!pending) return;
this.pending.delete(requestId);
pending.cleanupAbort?.();
worker.postMessage({ requestId: `storage-cancel-${++this.counter}`, command: { type: "cancelRequest", targetRequestId: requestId } } satisfies StorageRequest);
reject(new Error("StorageWorker timeout"));
}, 10_000);
this.pending.set(requestId, { resolve: (result) => resolve(result as NonNullable<StorageResponse["result"]>), reject, timeout });
this.worker.postMessage(request, transfer);
const abort = () => {
const pending = this.pending.get(requestId);
if (!pending) return;
this.pending.delete(requestId);
window.clearTimeout(pending.timeout);
pending.cleanupAbort?.();
worker.postMessage({ requestId: `storage-cancel-${++this.counter}`, command: { type: "cancelRequest", targetRequestId: requestId } } satisfies StorageRequest);
pending.reject(new DOMException("Storage request aborted", "AbortError"));
};
const cleanupAbort = signal ? () => signal.removeEventListener("abort", abort) : undefined;
this.pending.set(requestId, { resolve: (result) => resolve(result as NonNullable<StorageResponse["result"]>), reject, timeout, cleanupAbort });
signal?.addEventListener("abort", abort, { once: true });
if (signal?.aborted) {
abort();
return;
}
worker.postMessage(request, transfer);
});
}
private handleWorkerFault(message: string): void {
if (this.workerFaulted) return;
const fault = createWorkerFault("storage", message);
this.workerFaulted = true;
this.lastWorkerFault = fault;
const worker = this.worker;
this.worker = null;
const error = this.toError(fault);
for (const pending of this.pending.values()) {
window.clearTimeout(pending.timeout);
pending.cleanupAbort?.();
pending.reject(error);
}
this.pending.clear();
worker?.terminate();
this.onWorkerFault?.(fault);
}
private toError(fault: WorkerFault): Error {
const error = new Error(fault.error.message) as Error & { code?: string };
error.code = fault.error.code;
return error;
}
terminate(): void {
for (const pending of this.pending.values()) {
window.clearTimeout(pending.timeout);
pending.cleanupAbort?.();
pending.reject(new Error("StorageClient terminated"));
}
this.pending.clear();
this.worker.terminate();
this.worker?.terminate();
this.worker = null;
this.workerFaulted = true;
}
}

View File

@@ -1,7 +1,7 @@
export const STORAGE_DATABASE_NAME = "blender-web-metadata";
export const STORAGE_SCHEMA_VERSION = 6;
export const STORAGE_SCHEMA_VERSION = 7;
export const STORAGE_STORES = ["smoke", "project", "asset", "snapshot", "operation_log", "operation_quarantine", "setting", "lod_manifest", "simulation_manifest", "migration"] as const;
export const STORAGE_STORES = ["smoke", "project", "asset", "snapshot", "operation_log", "operation_quarantine", "setting", "lod_manifest", "simulation_manifest", "simulation_quarantine", "migration"] as const;
export function upgradeStorageSchema(db: IDBDatabase, transaction: IDBTransaction, oldVersion: number): void {
if (oldVersion < 1 && !db.objectStoreNames.contains("smoke")) {
@@ -31,4 +31,8 @@ export function upgradeStorageSchema(db: IDBDatabase, transaction: IDBTransactio
if (!db.objectStoreNames.contains("simulation_manifest")) db.createObjectStore("simulation_manifest", { keyPath: "id" });
transaction.objectStore("migration").put({ id: "schema-6", version: 6, appliedAt: new Date().toISOString() });
}
if (oldVersion < 7) {
if (!db.objectStoreNames.contains("simulation_quarantine")) db.createObjectStore("simulation_quarantine", { keyPath: "id" });
transaction.objectStore("migration").put({ id: "schema-7", version: 7, appliedAt: new Date().toISOString() });
}
}

View File

@@ -203,6 +203,32 @@ export async function ensureProjectLayout(projectId: string, storage: StorageMan
return layout;
}
export async function measureProjectVDBBytes(projectId: string, storage: StorageManager | undefined = typeof navigator === "undefined" ? undefined : navigator.storage): Promise<number> {
const layout = projectLayout(projectId);
const manager = storage as OpfsStorage | undefined;
if (!manager?.getDirectory) return 0;
try {
let current = await manager.getDirectory();
for (const segment of `${layout.cachePath}/vdb`.split("/")) current = await current.getDirectoryHandle(segment);
let total = 0;
const entries = (current as unknown as { entries: () => AsyncIterable<[string, FileSystemHandle]> }).entries();
for await (const [, handle] of entries) {
if (handle.kind !== "directory") continue;
const bundle = handle as FileSystemDirectoryHandle;
const files = (bundle as unknown as { entries: () => AsyncIterable<[string, FileSystemHandle]> }).entries();
for await (const [name, fileHandle] of files) {
if (fileHandle.kind !== "file" || !name.endsWith(".chunk")) continue;
total += (await (fileHandle as FileSystemFileHandle).getFile()).size;
}
}
return Number.isSafeInteger(total) ? total : 0;
}
catch (error) {
if (error instanceof DOMException && error.name === "NotFoundError") return 0;
throw error;
}
}
export async function writeLodCache(projectId: string, cacheKey: string, data: ArrayBuffer, storage?: StorageManager): Promise<OpfsProjectLayout> {
const layout = await ensureProjectLayout(projectId, storage);
if (!/^[A-Za-z0-9_-]{1,128}$/.test(cacheKey)) throw new Error("Invalid LOD cache key");
@@ -310,6 +336,66 @@ export async function readProjectBlend(projectId: string, storage?: StorageManag
return (await file.getFile()).arrayBuffer();
}
function snapshotFileName(revision: number): string {
if (!Number.isInteger(revision) || revision < 0) throw new Error("Invalid snapshot revision");
return `${revision}.blend`;
}
export async function writeProjectSnapshot(
projectId: string,
revision: number,
data: ArrayBuffer,
storage?: StorageManager,
): Promise<{ layout: OpfsProjectLayout; path: string }> {
if (data.byteLength === 0) throw new Error("Project snapshot buffer is empty");
const fileName = snapshotFileName(revision);
const layout = await ensureProjectLayout(projectId, storage);
const manager = (storage ?? navigator.storage) as OpfsStorage;
const root = await manager.getDirectory!();
const snapshots = await ensureDirectory(root, layout.snapshotsPath);
const stageName = `${revision}.${crypto.randomUUID()}.tmp`;
const sha256 = await sha256Hex(data);
try {
await writeFile(snapshots, stageName, data);
if (!await verifyFile(snapshots, stageName, data.byteLength, sha256)) {
throw new Error("SNAPSHOT_STAGE_VERIFY_FAILED: staged snapshot does not match its digest");
}
const stage = await snapshots.getFileHandle(stageName);
const move = (stage as FileSystemFileHandle & { move?: (name: string) => Promise<void> }).move;
if (move) await move.call(stage, fileName);
else {
await writeFile(snapshots, fileName, await (await stage.getFile()).arrayBuffer());
await removeFile(snapshots, stageName);
}
if (!await verifyFile(snapshots, fileName, data.byteLength, sha256)) {
throw new Error("SNAPSHOT_COMMIT_VERIFY_FAILED: committed snapshot does not match its digest");
}
return { layout, path: `${layout.snapshotsPath}/${fileName}` };
}
catch (error) {
await removeFile(snapshots, stageName);
throw error;
}
}
export async function readProjectSnapshot(projectId: string, revision: number, storage?: StorageManager): Promise<ArrayBuffer> {
const layout = projectLayout(projectId);
const manager = (storage ?? navigator.storage) as OpfsStorage;
if (!manager.getDirectory) throw new Error("OPFS is unavailable");
const root = await manager.getDirectory();
const snapshots = await ensureDirectory(root, layout.snapshotsPath);
return readFile(snapshots, snapshotFileName(revision));
}
export async function deleteProjectSnapshot(projectId: string, revision: number, storage?: StorageManager): Promise<void> {
const layout = projectLayout(projectId);
const manager = (storage ?? navigator.storage) as OpfsStorage;
if (!manager.getDirectory) throw new Error("OPFS is unavailable");
const root = await manager.getDirectory();
const snapshots = await ensureDirectory(root, layout.snapshotsPath);
await removeFile(snapshots, snapshotFileName(revision));
}
export async function recoverProjectBlend(projectId: string, storage?: StorageManager): Promise<ProjectBlendRecoveryResult> {
const layout = await ensureProjectLayout(projectId, storage);
const manager = (storage ?? navigator.storage) as OpfsStorage;
@@ -377,6 +463,71 @@ export async function writeContentAsset(projectId: string, sha256: string, data:
return { layout, path, deduplicated: false };
}
export interface OrphanContentAssetCleanupResult {
removed: number;
bytes: number;
paths: string[];
}
export async function removeOrphanContentAssets(projectId: string, referencedSha256: ReadonlySet<string>, storage?: StorageManager): Promise<OrphanContentAssetCleanupResult> {
const layout = projectLayout(projectId);
const manager = (storage ?? (typeof navigator === "undefined" ? undefined : navigator.storage)) as OpfsStorage | undefined;
if (!manager?.getDirectory) return { removed: 0, bytes: 0, paths: [] };
const root = await manager.getDirectory();
let assets: FileSystemDirectoryHandle;
try {
let current = root;
for (const segment of layout.assetsPath.split("/")) current = await current.getDirectoryHandle(segment);
assets = current;
}
catch (error) {
if (error instanceof DOMException && error.name === "NotFoundError") return { removed: 0, bytes: 0, paths: [] };
throw error;
}
let removed = 0;
let bytes = 0;
const paths: string[] = [];
const shaRoot = await assets.getDirectoryHandle("sha256", { create: false }).catch((error) => {
if (error instanceof DOMException && error.name === "NotFoundError") return undefined;
throw error;
});
if (!shaRoot) return { removed, bytes, paths };
const prefixes = (shaRoot as unknown as { entries: () => AsyncIterable<[string, FileSystemHandle]> }).entries();
for await (const [prefix, prefixHandle] of prefixes) {
if (prefixHandle.kind !== "directory" || !/^[a-f0-9]{2}$/.test(prefix)) continue;
const directory = prefixHandle as FileSystemDirectoryHandle;
const files = (directory as unknown as { entries: () => AsyncIterable<[string, FileSystemHandle]> }).entries();
for await (const [name, fileHandle] of files) {
if (fileHandle.kind !== "file") continue;
if (/^[a-f0-9]{64}$/.test(name) && referencedSha256.has(name)) continue;
const size = (await (fileHandle as FileSystemFileHandle).getFile()).size;
await directory.removeEntry(name);
removed += 1;
bytes += size;
paths.push(`${layout.assetsPath}/sha256/${prefix}/${name}`);
}
}
return { removed, bytes, paths };
}
export async function deleteContentAsset(projectId: string, sha256: string, storage?: StorageManager): Promise<void> {
const layout = projectLayout(projectId);
validateSha256(sha256);
const manager = (storage ?? (typeof navigator === "undefined" ? undefined : navigator.storage)) as OpfsStorage | undefined;
if (!manager?.getDirectory) return;
const root = await manager.getDirectory();
try {
let current = root;
for (const segment of `${layout.assetsPath}/sha256/${sha256.slice(0, 2)}`.split("/")) {
current = await current.getDirectoryHandle(segment);
}
await current.removeEntry(sha256);
}
catch (error) {
if (!(error instanceof DOMException) || error.name !== "NotFoundError") throw error;
}
}
export async function readContentAsset(projectId: string, sha256: string, storage?: StorageManager): Promise<ArrayBuffer> {
const layout = projectLayout(projectId);
validateSha256(sha256);

View File

@@ -0,0 +1,275 @@
import {
BoxGeometry,
BufferGeometry,
Float32BufferAttribute,
Line,
LineBasicMaterial,
Mesh,
MeshBasicMaterial,
PerspectiveCamera,
Points,
PointsMaterial,
Scene,
WebGLRenderer,
} from "../vendor/three/three.module.js";
import type { SceneSnapshotIR } from "../../../protocol/scene-ir";
import {
EDITING_DOMAIN_RECOVERY_SCHEMA_VERSION,
EDITING_DOMAINS,
parseEditingDomainRecoveryEvidence,
parseEditingDomainRecoverySuite,
summarizeEditingDomain,
type EditingDomain,
type EditingDomainIdentityIR,
type EditingDomainRecoveryEvidenceIR,
} from "../../../protocol/editing-domain-recovery";
import { WebEngineClient } from "../engine-client/WebEngineClient";
import { OOMFaultSessionAccessError, assertFault, beginOOMFaultSession } from "./oom-fault-session";
type FixtureMap = Partial<Record<EditingDomain, ArrayBuffer>>;
function stable(value: unknown): string {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
return `{${Object.keys(value as Record<string, unknown>).sort().map((key) => `${JSON.stringify(key)}:${stable((value as Record<string, unknown>)[key])}`).join(",")}}`;
}
async function sha256(value: string | ArrayBuffer): Promise<string> {
const bytes = typeof value === "string" ? new TextEncoder().encode(value) : new Uint8Array(value);
return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
function domainPayload(snapshot: SceneSnapshotIR, domain: EditingDomain, identity: EditingDomainIdentityIR): unknown {
const nodeById = new Map(snapshot.nodes.filter((node) => identity.objectIds.includes(node.id)).map((node) => [node.id, node]));
if (domain === "CURVE") {
return {
nodes: [...nodeById.values()].map((node) => ({ id: node.id, dataId: node.dataId, transform: node.transform })),
data: (snapshot.nonMeshData ?? []).filter((data) => identity.dataIds.includes(data.id)).map((data) => ({
id: data.id,
type: data.type,
pointCount: data.pointCount,
splineCount: data.splineCount,
controlPoints: data.controlPoints,
splineOffsets: data.splineOffsets,
splineTypes: data.splineTypes,
cyclicU: data.cyclicU,
cyclicV: data.cyclicV,
handleTypes: data.handleTypes,
handlePoints: data.handlePoints,
})),
};
}
if (domain === "GREASE_PENCIL") {
return {
nodes: [...nodeById.values()].map((node) => ({ id: node.id, dataId: node.dataId, transform: node.transform })),
data: (snapshot.greasePencils ?? []).filter((data) => identity.dataIds.includes(data.id)),
};
}
return {
nodes: [...nodeById.values()].map((node) => ({ id: node.id, dataId: node.dataId, transform: node.transform })),
data: snapshot.meshes.filter((mesh) => identity.dataIds.includes(mesh.id)).map((mesh) => ({
id: mesh.id,
vertexCount: mesh.vertexCount,
edgeCount: mesh.edgeCount,
faceCount: mesh.faceCount,
cornerCount: mesh.cornerCount,
attributes: mesh.attributes,
vertexGroups: mesh.vertexGroups,
})),
};
}
async function identity(snapshot: SceneSnapshotIR, domain: EditingDomain): Promise<EditingDomainIdentityIR & { revision: number; identityHash: string }> {
const summary = summarizeEditingDomain(snapshot, domain);
return { ...summary, revision: snapshot.revision, identityHash: await sha256(stable(domainPayload(snapshot, domain, summary))) };
}
function curveCommand(snapshot: SceneSnapshotIR): Parameters<WebEngineClient["applyCommand"]>[0] {
const data = snapshot.nonMeshData?.find((candidate) => (candidate.type === "CURVE" || candidate.type === "SURFACE") && candidate.controlPoints && candidate.controlPoints.length > 0);
if (data?.controlPoints) return { type: "setCurveControlPoints", dataId: data.id, controlPoints: [...data.controlPoints], splineOffsets: data.splineOffsets, resolution: data.resolution };
const topology = snapshot.nonMeshData?.find((candidate) => (candidate.type === "CURVE" || candidate.type === "SURFACE") && candidate.splineTypes && candidate.cyclicU);
if (!topology || !topology.splineTypes || !topology.cyclicU) throw new Error("EDITING_RECOVERY_DOMAIN_MISSING: CURVE topology");
return {
type: "setCurveTopology",
dataId: topology.id,
baseRevision: snapshot.revision,
splineTypes: [...topology.splineTypes],
cyclicU: [...topology.cyclicU],
cyclicV: topology.cyclicV ? [...topology.cyclicV] : undefined,
handleTypes: topology.handleTypes ? [...topology.handleTypes] : undefined,
handlePoints: topology.handlePoints ? [...topology.handlePoints] : undefined,
};
}
function greasePencilCommand(snapshot: SceneSnapshotIR): Extract<Parameters<WebEngineClient["applyCommand"]>[0], { type: "setGreasePencilStrokes" }> {
const data = snapshot.greasePencils?.[0];
const layer = data?.layers[0];
const frame = layer?.frames[0];
if (!data || !layer || !frame) throw new Error("EDITING_RECOVERY_DOMAIN_MISSING: Grease Pencil drawing");
const strokes = frame.drawing.strokes.map((stroke) => {
const points = stroke.points;
if (!points || points.length === 0) throw new Error("EDITING_RECOVERY_DOMAIN_MISSING: Grease Pencil points");
return {
cyclic: stroke.cyclic,
materialIndex: stroke.materialIndex,
points: points.map((point) => ({ position: [...point.position] as [number, number, number], radius: point.radius, opacity: point.opacity, vertexColor: point.vertexColor })),
};
});
return {
type: "setGreasePencilStrokes",
dataId: data.id,
layerId: layer.id,
frame: frame.frame,
baseRevision: snapshot.revision,
strokes,
};
}
function paintCommand(snapshot: SceneSnapshotIR): Extract<Parameters<WebEngineClient["applyCommand"]>[0], { type: "setVertexColors" }> {
const mesh = snapshot.meshes.find((candidate) => candidate.vertexCount > 0);
if (!mesh) throw new Error("EDITING_RECOVERY_DOMAIN_MISSING: paint mesh");
return { type: "setVertexColors", meshId: mesh.id, attributeName: "M9RecoveryColor", domain: "POINT", indices: [0], colors: [0.2, 0.7, 0.9, 1] };
}
async function renderProbe(domain: EditingDomain): Promise<EditingDomainRecoveryEvidenceIR["gpuRelease"]> {
const render = (canvas: HTMLCanvasElement): { renderer: WebGLRenderer; geometry: BufferGeometry; material: LineBasicMaterial | PointsMaterial | MeshBasicMaterial; visiblePixels: number; hash: Promise<string> } => {
const renderer = new WebGLRenderer({ canvas, antialias: false, preserveDrawingBuffer: true });
renderer.setSize(48, 48, false);
const scene = new Scene();
const camera = new PerspectiveCamera(45, 1, 0.1, 100);
camera.position.z = 4;
let geometry: BufferGeometry;
let material: LineBasicMaterial | PointsMaterial | MeshBasicMaterial;
if (domain === "CURVE") {
geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute([-1, -0.5, 0, 0, 0.75, 0, 1, -0.25, 0], 3));
material = new LineBasicMaterial({ color: 0xf0a23b });
scene.add(new Line(geometry, material));
}
else if (domain === "GREASE_PENCIL") {
geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute([-0.8, -0.5, 0, 0, 0.7, 0, 0.8, -0.2, 0], 3));
material = new PointsMaterial({ color: 0x4db6ff, size: 0.14 });
scene.add(new Points(geometry, material));
}
else {
geometry = new BoxGeometry(1.2, 1.2, 1.2);
material = new MeshBasicMaterial({ color: 0x71d39a });
scene.add(new Mesh(geometry, material));
}
renderer.render(scene, camera);
const gl = renderer.getContext();
const pixels = new Uint8Array(48 * 48 * 4);
gl.readPixels(0, 0, 48, 48, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
let visiblePixels = 0;
for (let index = 0; index < pixels.length; index += 4) if (pixels[index] + pixels[index + 1] + pixels[index + 2] > 12) visiblePixels++;
return { renderer, geometry, material, visiblePixels, hash: sha256(pixels.buffer) };
};
const first = render(document.createElement("canvas"));
const pixelHashBefore = await first.hash;
first.geometry.dispose();
first.material.dispose();
first.renderer.dispose();
const second = render(document.createElement("canvas"));
const pixelHashAfter = await second.hash;
second.geometry.dispose();
second.material.dispose();
second.renderer.dispose();
return {
status: "RECOVERED",
backend: "WEBGL2",
releaseCount: 1,
reinitCount: 1,
disposedResources: 6,
visiblePixels: Math.min(first.visiblePixels, second.visiblePixels),
pixelHashBefore,
pixelHashAfter,
};
}
async function runDomain(domain: EditingDomain, input: ArrayBuffer): Promise<EditingDomainRecoveryEvidenceIR> {
const engine = new WebEngineClient({ timeoutMs: 60_000 });
let restarted: WebEngineClient | undefined;
try {
const started = await engine.init();
if (!started.ready) throw new Error(`EDITING_RECOVERY_ENGINE_UNAVAILABLE: ${domain}`);
const opened = await engine.openBlend(input.slice(0));
const command = domain === "CURVE" ? curveCommand(opened.snapshot) : domain === "GREASE_PENCIL" ? greasePencilCommand(opened.snapshot) : paintCommand(opened.snapshot);
const edited = await engine.applyCommand(command);
const committed = await identity(edited.snapshot, domain);
const saved = await engine.saveBlend();
restarted = new WebEngineClient({ timeoutMs: 60_000 });
const restartedStatus = await restarted.init();
const reopened = await restarted.openBlend(saved.slice(0));
const reopenedIdentity = await identity(reopened.snapshot, domain);
if (reopenedIdentity.identityHash !== committed.identityHash) throw new Error(`EDITING_RECOVERY_WORKER_RESTART_FAILED: ${domain} revision ${committed.revision}/${reopenedIdentity.revision} hash ${committed.identityHash}/${reopenedIdentity.identityHash}`);
const faultSession = beginOOMFaultSession({ point: "GPU_GEOMETRY_UPLOAD", failAfterCount: 0 });
let unauthorized = false;
try { faultSession.session.reserve(`${faultSession.token}-unauthorized`, "GPU_GEOMETRY_UPLOAD", 1, `${domain.toLowerCase()}-unauthorized`); }
catch (error) { unauthorized = error instanceof OOMFaultSessionAccessError; }
const lease = faultSession.session.reserve(faultSession.token, "GPU_TEXTURE_UPLOAD", 4096, `${domain.toLowerCase()}-baseline`);
let observation;
try { faultSession.session.reserve(faultSession.token, "GPU_GEOMETRY_UPLOAD", 16 * 1024, `${domain.toLowerCase()}-geometry`); }
catch (error) { observation = assertFault(error, "GPU_GEOMETRY_UPLOAD"); }
lease.release();
const faultStats = faultSession.session.close(faultSession.token);
if (!observation || !unauthorized || faultStats.liveResources !== 0 || faultStats.releasedBytes < 4096) throw new Error(`EDITING_RECOVERY_OOM_FAILED: ${domain}`);
const afterOOM = await restarted.snapshot();
const afterOOMIdentity = await identity(afterOOM.snapshot, domain);
const gpuRelease = await renderProbe(domain);
const smallScene: EditingDomainRecoveryEvidenceIR["smallScene"] = {
status: "RECOVERED",
revision: reopenedIdentity.revision,
identityHash: reopenedIdentity.identityHash,
objectCount: reopenedIdentity.objectCount,
dataIds: reopenedIdentity.dataIds,
visiblePixels: gpuRelease.visiblePixels,
};
const evidence: EditingDomainRecoveryEvidenceIR = {
schemaVersion: EDITING_DOMAIN_RECOVERY_SCHEMA_VERSION,
domain,
baseline: reopenedIdentity,
workerRestart: {
status: "RECOVERED",
workerGeneration: 2,
revisionBefore: committed.revision,
revisionAfter: reopenedIdentity.revision,
hashBefore: committed.identityHash,
hashAfter: reopenedIdentity.identityHash,
liveHandles: restartedStatus.liveHandles,
temporaryResourcesAfter: 0,
},
oom: {
status: "RECOVERED",
faultPoint: "GPU_GEOMETRY_UPLOAD",
code: "GPU_GEOMETRY_BUDGET_EXCEEDED",
revisionBefore: afterOOMIdentity.revision,
revisionAfter: afterOOMIdentity.revision,
hashBefore: committed.identityHash,
hashAfter: afterOOMIdentity.identityHash,
releasedBytes: faultStats.releasedBytes,
temporaryResourcesAfter: 0,
},
gpuRelease,
smallScene,
};
return parseEditingDomainRecoveryEvidence(evidence);
}
finally {
restarted?.terminate();
engine.terminate();
}
}
export async function runEditingDomainRecoverySuite(fixtures: FixtureMap): Promise<EditingDomainRecoveryEvidenceIR[]> {
const reports: EditingDomainRecoveryEvidenceIR[] = [];
for (const domain of EDITING_DOMAINS) {
const input = fixtures[domain];
if (!(input instanceof ArrayBuffer) || input.byteLength === 0) throw new Error(`EDITING_RECOVERY_FIXTURE_INVALID: ${domain}`);
reports.push(await runDomain(domain, input));
}
return parseEditingDomainRecoverySuite(reports);
}

View File

@@ -11,7 +11,7 @@ import {
recoverProjectBlend,
writeProjectBlend,
} from "../storage/opfs-files";
import { createNanoVDBFloat32GridPaged } from "../render/nanovdb-volume-renderer";
import { createNanoVDBFloat32GridPaged, createNanoVDBPagedRenderResources } from "../render/nanovdb-volume-renderer";
import {
BoxGeometry,
BufferGeometry,
@@ -411,34 +411,126 @@ async function runNanoVdbScenario(): Promise<OOMRecoveryReportIR> {
},
});
let observation: OOMFaultObservationIR | undefined;
let pageTableObservation: 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");
pageTableObservation = 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");
const feedbackStarted = beginOOMFaultSession({ point: "NANOVDB_FEEDBACK_BUFFER", failAfterCount: 1 });
const feedbackIsolated = proveTokenIsolation(feedbackStarted.session, feedbackStarted.token, "NANOVDB_FEEDBACK_BUFFER");
const feedbackDestroyed = new Map<string, number>();
const bufferLabels = new WeakMap<GPUBuffer, string>();
const feedbackTrackedDevice = new Proxy(device, {
get(target, property) {
if (property === "createBuffer") {
return (descriptor: TestGPUBufferDescriptor): GPUBuffer => {
const label = descriptor.label ?? "unlabeled";
const point: OOMFaultPoint = label.includes("page feedback")
? "NANOVDB_FEEDBACK_BUFFER"
: label.includes("page table") ? "NANOVDB_PAGE_TABLE" : "NANOVDB_RESIDENT_BUFFER";
const lease = feedbackStarted.session.reserve(feedbackStarted.token, point, Number(descriptor.size), `nanovdb-feedback-${label}`);
const buffer = target.createBuffer(descriptor);
let released = false;
const tracked = new Proxy(buffer, {
get(bufferTarget, bufferProperty) {
if (bufferProperty === "destroy") {
return () => {
feedbackDestroyed.set(label, (feedbackDestroyed.get(label) ?? 0) + 1);
if (!released) { released = true; lease.release(); }
bufferTarget.destroy();
};
}
const current = Reflect.get(bufferTarget, bufferProperty, bufferTarget);
return typeof current === "function" ? current.bind(bufferTarget) : current;
},
});
bufferLabels.set(tracked, label);
return tracked;
};
}
if (property === "queue") {
return new Proxy(target.queue, {
get(queueTarget, queueProperty) {
if (queueProperty === "writeBuffer") {
return (buffer: GPUBuffer, offset: number, data: ArrayBuffer | ArrayBufferView): void => {
if (bufferLabels.get(buffer) === "NanoVDB page feedback") {
feedbackStarted.session.reserve(
feedbackStarted.token,
"NANOVDB_FEEDBACK_BUFFER",
1,
"nanovdb-feedback-initialization",
);
}
queueTarget.writeBuffer(buffer, offset, data);
};
}
const current = Reflect.get(queueTarget, queueProperty, queueTarget);
return typeof current === "function" ? current.bind(queueTarget) : current;
},
});
}
const current = Reflect.get(target, property, target);
return typeof current === "function" ? current.bind(target) : current;
},
});
let feedbackObservation: OOMFaultObservationIR | undefined;
try {
createNanoVDBPagedRenderResources(feedbackTrackedDevice, 128 * 1024, 64 * 1024, 64 * 1024, 4);
throw new Error("NanoVDB feedback OOM did not reject the resource group");
}
catch (error) {
feedbackObservation = assertFault(error, "NANOVDB_FEEDBACK_BUFFER");
}
const feedbackStats = feedbackStarted.session.close(feedbackStarted.token);
for (const label of ["NanoVDB paged Float32 grid", "NanoVDB page table", "NanoVDB page feedback"]) {
if (feedbackDestroyed.get(label) !== 1) throw new Error(`NanoVDB feedback OOM did not release ${label} exactly once`);
}
if (!feedbackStats.triggered || feedbackStats.liveResources !== 0) throw new Error("NanoVDB feedback OOM leaked GPU resources");
const recovered = createNanoVDBPagedRenderResources(device, 128 * 1024, 64 * 1024, 64 * 1024, 4);
recovered.grid.uploadPage(0, new ArrayBuffer(64 * 1024));
recovered.grid.uploadPage(1, new ArrayBuffer(64 * 1024));
if (!recovered.grid.hasResidentPage(1) || recovered.grid.hasResidentPage(0) || recovered.grid.evictionCount !== 1) throw new Error("NanoVDB resident allocator did not recover on the same device");
recovered.dispose();
recovered.dispose();
device.destroy();
if (!observation) throw new Error("NanoVDB OOM observation is missing");
if (!pageTableObservation || !feedbackObservation) 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"],
faults: [pageTableObservation, feedbackObservation],
memory: {
beforeBytes: 0,
peakBytes: Math.max(stats.peakBytes, feedbackStats.peakBytes),
afterBytes: stats.currentBytes + feedbackStats.currentBytes,
releasedBytes: stats.releasedBytes + feedbackStats.releasedBytes,
},
state: {
revisionBefore: 0,
revisionAfter: 0,
temporaryResourcesBefore: 0,
temporaryResourcesPeak: Math.max(stats.peakResources, feedbackStats.peakResources),
temporaryResourcesAfter: stats.liveResources + feedbackStats.liveResources,
},
recovery: { recovered: true, sameSession: true, restartedSession: false, tokenIsolated: isolated && feedbackIsolated },
checks: [
"resident-buffer-destroyed-once",
"page-table-not-published",
"page-table-destroyed-once",
"feedback-buffer-destroyed-once",
"resource-group-dispose-idempotent",
"resident-pages-recreated",
"same-device-recovers",
"lru-eviction-recovers",
],
};
}

View File

@@ -1,5 +1,6 @@
import {
BufferGeometry,
type Camera,
Color,
Float32BufferAttribute,
Group,
@@ -7,9 +8,15 @@ import {
LineBasicMaterial,
Points,
PointsMaterial,
Vector3,
type Object3D,
} from "../vendor/three/three.module.js";
import type { GreasePencilDataIR, GreasePencilDrawingIR, GreasePencilFrameIR, GreasePencilLayerIR } from "../../../protocol/grease-pencil";
import type {
GreasePencilDrawingScopeIR,
GreasePencilMarqueeCandidateIR,
GreasePencilStablePointRefIR,
} from "../../../protocol/grease-pencil-marquee";
import type { SceneNodeIR } from "../../../protocol/scene-ir";
interface DrawingPreview {
@@ -18,13 +25,7 @@ interface DrawingPreview {
onion: "NONE" | "PREVIOUS" | "NEXT";
}
export interface GreasePencilPointRef {
dataId: string;
layerId: string;
frame: number;
strokeIndex: number;
pointIndex: number;
}
export type GreasePencilPointRef = GreasePencilStablePointRefIR;
export interface GreasePencilPointPreview extends GreasePencilPointRef {
position: [number, number, number];
@@ -55,7 +56,7 @@ function layerDrawings(layer: GreasePencilLayerIR, frame: number): DrawingPrevie
}
function pointKey(point: GreasePencilPointRef): string {
return `${point.dataId}\u0000${point.layerId}\u0000${point.frame}\u0000${point.strokeIndex}\u0000${point.pointIndex}`;
return `${point.drawingId}\u0000${point.strokeId}\u0000${point.pointId}`;
}
function addDrawing(group: Group, dataId: string, layer: GreasePencilLayerIR, preview: DrawingPreview): number {
@@ -101,6 +102,8 @@ function addDrawing(group: Group, dataId: string, layer: GreasePencilLayerIR, pr
line.userData.greasePencilPreviewDataId = dataId;
line.userData.greasePencilPreviewLayerId = layer.id;
line.userData.greasePencilPreviewFrame = preview.frame;
line.userData.greasePencilPreviewDrawingId = preview.drawing.id;
line.userData.greasePencilPreviewStrokeId = stroke.id;
line.userData.greasePencilPreviewStrokeIndex = strokeIndex;
line.userData.greasePencilPointIndexMap = Array.from({ length: linePointCount }, (_, index) => index % stroke.points!.length);
line.userData.greasePencilPointBasePositions = new Float32Array(positions.subarray(0, linePointCount * 3));
@@ -114,8 +117,11 @@ function addDrawing(group: Group, dataId: string, layer: GreasePencilLayerIR, pr
points.userData.greasePencilPointDataId = dataId;
points.userData.greasePencilPointLayerId = layer.id;
points.userData.greasePencilPointFrame = preview.frame;
points.userData.greasePencilPointDrawingId = preview.drawing.id;
points.userData.greasePencilPointStrokeId = stroke.id;
points.userData.greasePencilPointStrokeIndex = strokeIndex;
points.userData.greasePencilPointIndexMap = Array.from({ length: stroke.points.length }, (_, index) => index);
points.userData.greasePencilPointIdMap = stroke.points.map((point) => point.id);
points.userData.greasePencilPointBasePositions = new Float32Array(positions.subarray(0, stroke.points.length * 3));
points.visible = false;
(strokeObject ?? group).add(points);
@@ -147,9 +153,49 @@ export function greasePencilPointRef(object: Object3D, pointIndex: number): Grea
const dataId = object.userData.greasePencilPointDataId;
const layerId = object.userData.greasePencilPointLayerId;
const frame = object.userData.greasePencilPointFrame;
const drawingId = object.userData.greasePencilPointDrawingId;
const strokeId = object.userData.greasePencilPointStrokeId;
const strokeIndex = object.userData.greasePencilPointStrokeIndex;
if (typeof dataId !== "string" || typeof layerId !== "string" || !Number.isSafeInteger(frame) || !Number.isSafeInteger(strokeIndex) || !Number.isSafeInteger(pointIndex) || pointIndex < 0) return null;
return { dataId, layerId, frame, strokeIndex, pointIndex };
const pointIndexMap = object.userData.greasePencilPointIndexMap as number[] | undefined;
const pointIdMap = object.userData.greasePencilPointIdMap as string[] | undefined;
const stablePointIndex = pointIndexMap?.[pointIndex];
const pointId = pointIdMap?.[pointIndex];
if (typeof dataId !== "string" || typeof layerId !== "string" || typeof drawingId !== "string" ||
typeof strokeId !== "string" || typeof pointId !== "string" || !Number.isSafeInteger(frame) ||
!Number.isSafeInteger(strokeIndex) || !Number.isSafeInteger(stablePointIndex) ||
(stablePointIndex ?? -1) < 0) return null;
return { dataId, layerId, frame, drawingId, strokeId, pointId, strokeIndex, pointIndex: stablePointIndex! };
}
export function greasePencilMarqueeCandidates(
root: Object3D,
drawing: GreasePencilDrawingScopeIR,
camera: Camera,
): GreasePencilMarqueeCandidateIR[] {
const candidates: GreasePencilMarqueeCandidateIR[] = [];
const worldPosition = new Vector3();
root.updateMatrixWorld(true);
camera.updateMatrixWorld(true);
root.traverse((object) => {
if (!(object instanceof Points) || object.userData.greasePencilPointDrawingId !== drawing.drawingId ||
object.userData.greasePencilPointDataId !== drawing.dataId ||
object.userData.greasePencilPointLayerId !== drawing.layerId ||
object.userData.greasePencilPointFrame !== drawing.frame) return;
const positions = object.geometry.getAttribute("position");
for (let index = 0; index < positions.count; index++) {
const point = greasePencilPointRef(object, index);
if (!point) continue;
worldPosition.fromBufferAttribute(positions, index);
object.localToWorld(worldPosition);
worldPosition.project(camera);
if (worldPosition.z < -1 || worldPosition.z > 1) continue;
candidates.push({
...point,
viewportPosition: [(worldPosition.x + 1) / 2, (1 - worldPosition.y) / 2],
});
}
});
return candidates;
}
export function applyGreasePencilPointSelection(root: Object3D, selection: readonly GreasePencilPointRef[]): void {
@@ -157,11 +203,10 @@ export function applyGreasePencilPointSelection(root: Object3D, selection: reado
root.traverse((object) => {
if (!(object instanceof Points) || !(object.material instanceof PointsMaterial)) return;
const count = object.geometry.getAttribute("position")?.count ?? 0;
const first = greasePencilPointRef(object, 0);
if (!first) return;
const colors = new Float32Array(count * 3);
for (let pointIndex = 0; pointIndex < count; pointIndex++) {
const active = selected.has(pointKey({ ...first, pointIndex }));
const point = greasePencilPointRef(object, pointIndex);
const active = point ? selected.has(pointKey(point)) : false;
colors.set(active ? [1, 0.38, 0.08] : [0.46, 0.73, 1], pointIndex * 3);
}
object.geometry.setAttribute("color", new Float32BufferAttribute(colors, 3));

View File

@@ -2,10 +2,14 @@ import type { SceneSnapshotIR } from "../../../protocol/scene-ir";
import type { MeshElementMode, MeshGeometryBuffer } from "../../../protocol/web-engine";
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
import type { GPUTextureAsset } from "../../../protocol/render-assets";
import type { PBRLightingBudgetReport, PBRTextureBudgetReport } from "../../../protocol/render-budget";
import type { NonMeshElementKind } from "./nonmesh";
import type { GreasePencilPointPreview, GreasePencilPointRef } from "./grease-pencil";
import type { CurveGizmoFrameIR, CurveGizmoHandleIR, CurveGizmoScreenFrameIR } from "../../../protocol/nonmesh-interaction";
import type { NanoVDBViewportAssetIR } from "../volume/nanovdb-viewport";
import type { ViewportCameraState } from "../../../protocol/viewport-camera";
import type { GreasePencilDrawingScopeIR, GreasePencilMarqueeBoxIR, GreasePencilMarqueeResultIR } from "../../../protocol/grease-pencil-marquee";
import type { PaintDepthVisibilityRequestIR, PaintDepthVisibilityResultIR } from "../../../protocol/paint-depth-visibility";
export type OffscreenViewportRequest =
| { type: "init"; canvas: OffscreenCanvas; width: number; height: number; pixelRatio: number }
@@ -13,23 +17,29 @@ export type OffscreenViewportRequest =
| { type: "textureAssets"; assets: GPUTextureAsset[] }
| { type: "volumeAssets"; assets: NanoVDBViewportAssetIR[] }
| { type: "resize"; width: number; height: number; pixelRatio: number }
| { type: "selection"; objectIds: string[]; elements: Array<{ dataId: string; kind: NonMeshElementKind; index: number }>; greasePencilPoints: GreasePencilPointRef[] }
| { type: "selection"; objectIds: string[]; elements: Array<{ dataId: string; kind: NonMeshElementKind; index: number }>; greasePencilPoints: GreasePencilPointRef[]; greasePencilSelectionRevision: number }
| { type: "interaction"; editMode: boolean; selectionMode: MeshElementMode }
| { type: "curveHandlePreview"; dataId: string; handles: CurveGizmoHandleIR[] | null }
| { type: "greasePencilPointPreview"; dataId: string; layerId: string; frame: number; points: GreasePencilPointPreview[] | null }
| { type: "curveGizmoFrame"; dataId: string | null; frame: CurveGizmoFrameIR | null }
| { type: "orbit"; deltaX: number; deltaY: number; zoom: number }
| { type: "pick"; x: number; y: number; additive: boolean }
| { type: "pick"; x: number; y: number; additive: boolean; baseSelectionRevision: number }
| { type: "greasePencilMarquee"; drawing: GreasePencilDrawingScopeIR; box: GreasePencilMarqueeBoxIR; baseRevision: number; baseSelectionRevision: number; additive: boolean }
| { type: "paintDepthVisibility"; requestId: string; request: PaintDepthVisibilityRequestIR }
| { type: "dispose" };
export type OffscreenViewportResponse =
| { type: "ready" }
| { type: "frame"; visiblePixels: number }
| { type: "snapshotStatus"; nonMeshCount: number; nonMeshBlockedCount: number; greasePencilCount: number; greasePencilBlockedCount: number; greasePencilOnionStrokeCount: number }
| { type: "textureStatus"; loaded: number; rejected: number; bytes: number; errors: string[]; errorCodes: string[] }
| { type: "frame"; visiblePixels: number; camera?: ViewportCameraState }
| { type: "snapshotStatus"; nonMeshCount: number; nonMeshBlockedCount: number; greasePencilCount: number; greasePencilBlockedCount: number; greasePencilOnionStrokeCount: number; renderBudget: PBRLightingBudgetReport }
| { type: "textureStatus"; loaded: number; rejected: number; bytes: number; errors: string[]; errorCodes: string[]; budget: PBRTextureBudgetReport }
| { type: "volumeStatus"; status: "none" | "loading" | "ready" | "blocked"; count: number; errorCode?: string }
| { type: "selected"; objectId: string; additive: boolean }
| { type: "elementSelected"; meshId: string; mode: MeshElementMode; index: number; additive: boolean; nonMeshKind?: NonMeshElementKind }
| { type: "greasePencilPointSelected"; point: GreasePencilPointRef; additive: boolean }
| { type: "greasePencilPointSelected"; point: GreasePencilPointRef; additive: boolean; baseSelectionRevision: number }
| { type: "greasePencilSelectionStatus"; selectionRevision: number; pointIds: string[] }
| { type: "greasePencilMarqueeSelected"; result: GreasePencilMarqueeResultIR; additive: boolean }
| { type: "paintDepthVisibilityResult"; requestId: string; result: PaintDepthVisibilityResultIR }
| { type: "paintDepthVisibilityError"; requestId: string; message: string }
| { type: "curveGizmoScreenFrame"; frame: CurveGizmoScreenFrameIR | null }
| { type: "error"; message: string };

View File

@@ -11,13 +11,22 @@ import type { NonMeshElementKind } from "./nonmesh";
import type { GreasePencilPointPreview, GreasePencilPointRef } from "./grease-pencil";
import type { CurveGizmoFrameIR, CurveGizmoHandleIR } from "../../../protocol/nonmesh-interaction";
import { cloneNanoVDBViewportAssets, nanoVDBViewportAssetTransferables, type NanoVDBViewportAssetIR } from "../volume/nanovdb-viewport";
import type { GreasePencilDrawingScopeIR, GreasePencilMarqueeBoxIR, GreasePencilMarqueeResultIR } from "../../../protocol/grease-pencil-marquee";
import {
validatePaintDepthVisibilityRequest,
validatePaintDepthVisibilityResult,
type PaintDepthVisibilityRequestIR,
type PaintDepthVisibilityResultIR,
} from "../../../protocol/paint-depth-visibility";
export interface ViewportBackend {
setSnapshot(snapshot: SceneSnapshotIR, geometryBuffers?: MeshGeometryBuffer[], nonMeshGeometryBuffers?: NonMeshGeometryChunk[]): void;
setTextureAssets(assets: readonly GPUTextureAsset[]): void;
setVolumeAssets(assets: readonly NanoVDBViewportAssetIR[]): void;
setSelection(objectIds: ReadonlySet<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>, greasePencilPoints?: readonly GreasePencilPointRef[]): void;
setSelection(objectIds: ReadonlySet<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>, greasePencilPoints?: readonly GreasePencilPointRef[], greasePencilSelectionRevision?: number): void;
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void;
samplePaintVisibility(request: PaintDepthVisibilityRequestIR): Promise<PaintDepthVisibilityResultIR>;
selectGreasePencilMarquee(drawing: GreasePencilDrawingScopeIR, box: GreasePencilMarqueeBoxIR, baseRevision: number, baseSelectionRevision: number, additive: boolean): void;
setCurveHandlePreview(dataId: string, handles: readonly CurveGizmoHandleIR[] | null): void;
setGreasePencilPointPreview(dataId: string, layerId: string, frame: number, points: readonly GreasePencilPointPreview[] | null): void;
setCurveGizmoFrame(dataId: string | null, frame: CurveGizmoFrameIR | null): void;
@@ -41,7 +50,8 @@ export function acquireOffscreenViewportRenderer(
canvas: HTMLCanvasElement,
onSelect?: (objectId: string, additive: boolean) => void,
onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void,
onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean) => void,
onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean, baseSelectionRevision: number) => void,
onGreasePencilMarqueeSelect?: (result: GreasePencilMarqueeResultIR, additive: boolean) => void,
): OffscreenViewportRenderer {
const existing = sharedBackends.get(canvas);
if (existing) {
@@ -50,7 +60,7 @@ export function acquireOffscreenViewportRenderer(
existing.references += 1;
return existing.renderer;
}
const renderer = new OffscreenViewportRenderer(canvas, onSelect, onElementSelect, onGreasePencilPointSelect);
const renderer = new OffscreenViewportRenderer(canvas, onSelect, onElementSelect, onGreasePencilPointSelect, onGreasePencilMarqueeSelect);
sharedBackends.set(canvas, { renderer, references: 1 });
return renderer;
}
@@ -73,23 +83,33 @@ export class OffscreenViewportRenderer implements ViewportBackend {
private readonly resizeObserver: ResizeObserver;
private readonly onSelect?: (objectId: string, additive: boolean) => void;
private readonly onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void;
private readonly onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean) => void;
private readonly onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean, baseSelectionRevision: number) => void;
private readonly onGreasePencilMarqueeSelect?: (result: GreasePencilMarqueeResultIR, additive: boolean) => void;
private pointer: { id: number; x: number; y: number; moved: boolean } | null = null;
private lastSnapshot: SceneSnapshotIR | null = null;
private lastGeometryBuffers: MeshGeometryBuffer[] | null = null;
private lastNonMeshGeometryBuffers: NonMeshGeometryChunk[] | null = null;
private greasePencilSelectionRevision = 0;
private paintDepthRequestSequence = 0;
private readonly pendingPaintDepth = new Map<string, {
request: PaintDepthVisibilityRequestIR;
resolve: (result: PaintDepthVisibilityResultIR) => void;
reject: (error: Error) => void;
}>();
constructor(
canvas: HTMLCanvasElement,
onSelect?: (objectId: string, additive: boolean) => void,
onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void,
onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean) => void,
onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean, baseSelectionRevision: number) => void,
onGreasePencilMarqueeSelect?: (result: GreasePencilMarqueeResultIR, additive: boolean) => void,
) {
if (!supportsOffscreenViewport(canvas)) throw new Error("OffscreenCanvas viewport is unavailable");
this.canvas = canvas;
this.onSelect = onSelect;
this.onElementSelect = onElementSelect;
this.onGreasePencilPointSelect = onGreasePencilPointSelect;
this.onGreasePencilMarqueeSelect = onGreasePencilMarqueeSelect;
this.worker = new Worker(new URL("../workers/viewport-render.worker.ts", import.meta.url), { type: "module" });
this.worker.onmessage = (event: MessageEvent<OffscreenViewportResponse>) => this.handleMessage(event.data);
const offscreen = canvas.transferControlToOffscreen();
@@ -151,6 +171,11 @@ export class OffscreenViewportRenderer implements ViewportBackend {
this.canvas.dataset.textureStatus = "none";
this.canvas.dataset.textureLoaded = "0";
this.canvas.dataset.textureBytes = "0";
this.canvas.dataset.textureBudgetStatus = "ready";
this.canvas.dataset.textureBudgetCode = "";
this.canvas.dataset.textureBudgetAssets = "0";
this.canvas.dataset.textureBudgetPayloadBytes = "0";
this.canvas.dataset.textureBudgetGpuBytes = "0";
return;
}
const cloned = assets.map((asset) => ({ ...asset, data: asset.data.slice(0) }));
@@ -163,15 +188,35 @@ export class OffscreenViewportRenderer implements ViewportBackend {
this.worker.postMessage({ type: "volumeAssets", assets: cloned } satisfies OffscreenViewportRequest, nanoVDBViewportAssetTransferables(cloned));
}
setSelection(objectIds: ReadonlySet<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>, greasePencilPoints: readonly GreasePencilPointRef[] = []): void {
setSelection(objectIds: ReadonlySet<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>, greasePencilPoints: readonly GreasePencilPointRef[] = [], greasePencilSelectionRevision = 0): void {
const elements = [...(elementSelection ?? new Map())].flatMap(([dataId, kinds]) => [...kinds].flatMap(([kind, indices]) => [...indices].map((index) => ({ dataId, kind, index }))));
this.worker.postMessage({ type: "selection", objectIds: [...objectIds], elements, greasePencilPoints: [...greasePencilPoints] } satisfies OffscreenViewportRequest);
this.greasePencilSelectionRevision = greasePencilSelectionRevision;
this.worker.postMessage({ type: "selection", objectIds: [...objectIds], elements, greasePencilPoints: [...greasePencilPoints], greasePencilSelectionRevision } satisfies OffscreenViewportRequest);
}
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void {
this.worker.postMessage({ type: "interaction", editMode, selectionMode } satisfies OffscreenViewportRequest);
}
samplePaintVisibility(requestValue: PaintDepthVisibilityRequestIR): Promise<PaintDepthVisibilityResultIR> {
const request = validatePaintDepthVisibilityRequest(requestValue, this.lastSnapshot?.revision ?? -1);
const requestId = `paint-depth-${++this.paintDepthRequestSequence}`;
return new Promise((resolve, reject) => {
this.pendingPaintDepth.set(requestId, { request, resolve, reject });
this.worker.postMessage({ type: "paintDepthVisibility", requestId, request } satisfies OffscreenViewportRequest);
});
}
selectGreasePencilMarquee(
drawing: GreasePencilDrawingScopeIR,
box: GreasePencilMarqueeBoxIR,
baseRevision: number,
baseSelectionRevision: number,
additive: boolean,
): void {
this.worker.postMessage({ type: "greasePencilMarquee", drawing, box, baseRevision, baseSelectionRevision, additive } satisfies OffscreenViewportRequest);
}
setCurveHandlePreview(dataId: string, handles: readonly CurveGizmoHandleIR[] | null): void {
this.canvas.dataset.curveGizmoPreview = handles ? String(handles.length) : "0";
this.worker.postMessage({ type: "curveHandlePreview", dataId, handles: handles ? handles.map((handle) => ({ ...handle, position: [...handle.position] as [number, number, number] })) : null } satisfies OffscreenViewportRequest);
@@ -202,7 +247,12 @@ export class OffscreenViewportRenderer implements ViewportBackend {
private pointerDown = (event: PointerEvent): void => {
this.pointer = { id: event.pointerId, x: event.clientX, y: event.clientY, moved: false };
this.canvas.setPointerCapture(event.pointerId);
try {
this.canvas.setPointerCapture(event.pointerId);
}
catch {
// Synthetic test events and browsers without pointer capture still support picking.
}
};
private pointerMove = (event: PointerEvent): void => {
@@ -221,7 +271,7 @@ export class OffscreenViewportRenderer implements ViewportBackend {
const bounds = this.canvas.getBoundingClientRect();
const x = ((event.clientX - bounds.left) / Math.max(1, bounds.width)) * 2 - 1;
const y = -((event.clientY - bounds.top) / Math.max(1, bounds.height)) * 2 + 1;
this.worker.postMessage({ type: "pick", x, y, additive: event.shiftKey || event.ctrlKey || event.metaKey } satisfies OffscreenViewportRequest);
this.worker.postMessage({ type: "pick", x, y, additive: event.shiftKey || event.ctrlKey || event.metaKey, baseSelectionRevision: this.greasePencilSelectionRevision } satisfies OffscreenViewportRequest);
}
this.pointer = null;
};
@@ -234,20 +284,69 @@ export class OffscreenViewportRenderer implements ViewportBackend {
private handleMessage(message: OffscreenViewportResponse): void {
if (message.type === "selected") this.onSelect?.(message.objectId, message.additive);
else if (message.type === "elementSelected") this.onElementSelect?.(message.meshId, message.mode, message.index, message.additive, message.nonMeshKind);
else if (message.type === "greasePencilPointSelected") this.onGreasePencilPointSelect?.(message.point, message.additive);
else if (message.type === "frame") this.canvas.dataset.rendererPixels = String(message.visiblePixels);
else if (message.type === "greasePencilPointSelected") this.onGreasePencilPointSelect?.(message.point, message.additive, message.baseSelectionRevision);
else if (message.type === "greasePencilSelectionStatus") {
this.canvas.dataset.greasePencilSelectionRevision = String(message.selectionRevision);
this.canvas.dataset.greasePencilSelectionPointIds = message.pointIds.join(",");
}
else if (message.type === "greasePencilMarqueeSelected") {
this.canvas.dataset.greasePencilMarqueeSelectionRevision = String(message.result.baseSelectionRevision);
this.canvas.dataset.greasePencilMarqueeDrawingId = message.result.drawing.drawingId;
this.canvas.dataset.greasePencilMarqueePointIds = message.result.selectedPoints.map((point) => point.pointId).join(",");
this.canvas.dataset.greasePencilMarqueeStrokeIds = message.result.selectedStrokeIds.join(",");
this.canvas.dataset.greasePencilMarqueeCount = String(message.result.selectedPoints.length);
this.onGreasePencilMarqueeSelect?.(message.result, message.additive);
}
else if (message.type === "paintDepthVisibilityResult") {
const pending = this.pendingPaintDepth.get(message.requestId);
if (!pending) return;
this.pendingPaintDepth.delete(message.requestId);
try { pending.resolve(validatePaintDepthVisibilityResult(message.result, pending.request)); }
catch (error) { pending.reject(error instanceof Error ? error : new Error(String(error))); }
}
else if (message.type === "paintDepthVisibilityError") {
const pending = this.pendingPaintDepth.get(message.requestId);
if (!pending) return;
this.pendingPaintDepth.delete(message.requestId);
pending.reject(new Error(message.message));
}
else if (message.type === "frame") {
this.canvas.dataset.rendererPixels = String(message.visiblePixels);
if (message.camera) {
this.canvas.dataset.cameraPosition = message.camera.position.map((value) => Number(value.toFixed(6))).join(",");
this.canvas.dataset.cameraTarget = message.camera.target.map((value) => Number(value.toFixed(6))).join(",");
this.canvas.dataset.cameraYaw = message.camera.yaw.toFixed(6);
this.canvas.dataset.cameraPitch = message.camera.pitch.toFixed(6);
this.canvas.dataset.cameraDistance = message.camera.distance.toFixed(6);
}
}
else if (message.type === "snapshotStatus") {
this.canvas.dataset.nonMeshCount = String(message.nonMeshCount);
this.canvas.dataset.nonMeshBlockedCount = String(message.nonMeshBlockedCount);
this.canvas.dataset.greasePencilCount = String(message.greasePencilCount);
this.canvas.dataset.greasePencilBlockedCount = String(message.greasePencilBlockedCount);
this.canvas.dataset.greasePencilOnionStrokeCount = String(message.greasePencilOnionStrokeCount);
this.canvas.dataset.renderBudgetBackend = message.renderBudget.backend;
this.canvas.dataset.renderBudgetStatus = message.renderBudget.status.toLowerCase();
this.canvas.dataset.renderBudgetCode = message.renderBudget.issues[0]?.code ?? "";
this.canvas.dataset.renderBudgetLights = String(message.renderBudget.requestedLights);
this.canvas.dataset.renderBudgetRenderedLights = String(message.renderBudget.renderedLightNodeIds.length);
this.canvas.dataset.renderBudgetDroppedLights = String(message.renderBudget.droppedLightNodeIds.length);
this.canvas.dataset.renderBudgetShadows = String(message.renderBudget.requestedShadowMaps);
this.canvas.dataset.renderBudgetRenderedShadows = String(message.renderBudget.shadowLightNodeIds.length);
this.canvas.dataset.renderBudgetBlockedShadows = String(message.renderBudget.shadowBlockedLightNodeIds.length);
this.canvas.dataset.renderBudgetShadowMapDimension = String(message.renderBudget.budget.shadowMapDimension);
}
else if (message.type === "textureStatus") {
this.canvas.dataset.textureStatus = message.rejected > 0 ? "blocked" : "ready";
this.canvas.dataset.textureLoaded = String(message.loaded);
this.canvas.dataset.textureBytes = String(message.bytes);
this.canvas.dataset.textureErrorCode = message.errorCodes[0] ?? "";
this.canvas.dataset.textureBudgetStatus = message.budget.status.toLowerCase();
this.canvas.dataset.textureBudgetCode = message.budget.issues[0]?.code ?? "";
this.canvas.dataset.textureBudgetAssets = String(message.budget.requestedAssets);
this.canvas.dataset.textureBudgetPayloadBytes = String(message.budget.payloadBytes);
this.canvas.dataset.textureBudgetGpuBytes = String(message.budget.decodedGPUBytes);
}
else if (message.type === "volumeStatus") {
this.canvas.dataset.volumeStatus = message.status;
@@ -269,5 +368,7 @@ export class OffscreenViewportRenderer implements ViewportBackend {
this.canvas.removeEventListener("wheel", this.wheel);
this.worker.postMessage({ type: "dispose" } satisfies OffscreenViewportRequest);
this.worker.terminate();
for (const pending of this.pendingPaintDepth.values()) pending.reject(new Error("PAINT_DEPTH_UNAVAILABLE: Offscreen viewport disposed"));
this.pendingPaintDepth.clear();
}
}

View File

@@ -0,0 +1,171 @@
import {
Color,
InstancedMesh,
Matrix4,
MeshDepthMaterial,
RGBADepthPacking,
Vector2,
Vector3,
WebGLRenderTarget,
type Object3D,
type PerspectiveCamera,
type Scene,
type WebGLRenderer,
} from "../vendor/three/three.module.js";
import {
PAINT_DEPTH_VISIBILITY_BUDGET,
PaintDepthVisibilityError,
validatePaintDepthVisibilityResult,
type PaintDepthVisibilityBackend,
type PaintDepthVisibilityRequestIR,
type PaintDepthVisibilityResultIR,
} from "../../../protocol/paint-depth-visibility";
const PACK_DOWNSCALE = 255 / 256;
const DEPTH_TOLERANCE_RATIO = 0.002;
function unpackRGBADepth(pixels: Uint8Array, offset: number): number {
return (pixels[offset] / 255) * PACK_DOWNSCALE +
(pixels[offset + 1] / 255) * (PACK_DOWNSCALE / 256) +
(pixels[offset + 2] / 255) * (PACK_DOWNSCALE / 65_536) +
(pixels[offset + 3] / 255) / 16_777_216;
}
function perspectiveDistance(depth: number, near: number, far: number): number {
const viewZ = (near * far) / ((far - near) * depth - far);
return -viewZ;
}
function depthTargetSize(renderer: WebGLRenderer): { width: number; height: number } {
const size = renderer.getDrawingBufferSize(new Vector2());
const sourceWidth = Math.max(1, Math.floor(size.x));
const sourceHeight = Math.max(1, Math.floor(size.y));
const maxPixels = Math.floor(PAINT_DEPTH_VISIBILITY_BUDGET.maxReadbackBytes / 4);
const scale = Math.min(
1,
PAINT_DEPTH_VISIBILITY_BUDGET.maxDimension / sourceWidth,
PAINT_DEPTH_VISIBILITY_BUDGET.maxDimension / sourceHeight,
Math.sqrt(maxPixels / (sourceWidth * sourceHeight)),
);
return {
width: Math.max(1, Math.floor(sourceWidth * scale)),
height: Math.max(1, Math.floor(sourceHeight * scale)),
};
}
function candidateMatrix(object: Object3D, objectId: string): Matrix4 {
object.updateWorldMatrix(true, false);
if (!(object instanceof InstancedMesh)) return object.matrixWorld.clone();
const instanceIds = object.userData.instanceNodeIds as string[] | undefined;
const instanceIndex = instanceIds?.indexOf(objectId) ?? -1;
if (instanceIndex < 0) throw new PaintDepthVisibilityError("PAINT_DEPTH_UNAVAILABLE", "Paint object instance is unavailable");
const instance = new Matrix4();
object.getMatrixAt(instanceIndex, instance);
return new Matrix4().multiplyMatrices(object.matrixWorld, instance);
}
export function samplePaintDepthVisibilityGPU({
renderer,
scene,
camera,
object,
request,
backend,
}: {
renderer: WebGLRenderer;
scene: Scene;
camera: PerspectiveCamera;
object: Object3D;
request: PaintDepthVisibilityRequestIR;
backend: PaintDepthVisibilityBackend;
}): PaintDepthVisibilityResultIR {
if (!renderer.capabilities.isWebGL2) throw new PaintDepthVisibilityError("PAINT_DEPTH_UNAVAILABLE", "Paint visibility requires a WebGL2 depth pass");
const meshId = object.userData.meshId;
const sourcePositions = object.userData.sourcePositions as number[] | undefined;
if (meshId !== request.meshId || !sourcePositions || sourcePositions.length % 3 !== 0) {
throw new PaintDepthVisibilityError("PAINT_DEPTH_UNAVAILABLE", "Paint source geometry is unavailable");
}
const vertexCount = sourcePositions.length / 3;
if (request.vertexIndices.some((index) => index >= vertexCount)) {
throw new PaintDepthVisibilityError("PAINT_SCHEMA_INVALID", "Paint depth request references an unknown vertex");
}
const { width, height } = depthTargetSize(renderer);
const target = new WebGLRenderTarget(width, height, { depthBuffer: true, stencilBuffer: false });
const depthMaterial = new MeshDepthMaterial({ depthPacking: RGBADepthPacking });
const pixels = new Uint8Array(width * height * 4);
const previousTarget = renderer.getRenderTarget();
const previousOverride = scene.overrideMaterial;
const previousBackground = scene.background;
const previousClearColor = renderer.getClearColor(new Color()).clone();
const previousClearAlpha = renderer.getClearAlpha();
const hidden: Object3D[] = [];
try {
scene.traverse((candidate) => {
const renderable = candidate as Object3D & { isLine?: boolean; isPoints?: boolean; isSprite?: boolean };
if (candidate.visible && (renderable.isLine || renderable.isPoints || renderable.isSprite || candidate.userData.nanoVDBVolume)) {
hidden.push(candidate);
candidate.visible = false;
}
});
scene.background = null;
scene.overrideMaterial = depthMaterial;
renderer.setClearColor(0xffffff, 1);
renderer.setRenderTarget(target);
renderer.clear(true, true, true);
camera.updateMatrixWorld(true);
scene.updateMatrixWorld(true);
renderer.render(scene, camera);
renderer.readRenderTargetPixels(target, 0, 0, width, height, pixels);
}
catch (error) {
throw new PaintDepthVisibilityError("PAINT_DEPTH_UNAVAILABLE", error instanceof Error ? error.message : "GPU depth readback failed");
}
finally {
renderer.setRenderTarget(previousTarget);
renderer.setClearColor(previousClearColor, previousClearAlpha);
scene.overrideMaterial = previousOverride;
scene.background = previousBackground;
for (const candidate of hidden) candidate.visible = true;
depthMaterial.dispose();
target.dispose();
}
let occluderPixelCount = 0;
for (let offset = 0; offset < pixels.length; offset += 4) {
if (unpackRGBADepth(pixels, offset) < 1 - 1e-7) occluderPixelCount++;
}
if (occluderPixelCount === 0) throw new PaintDepthVisibilityError("PAINT_DEPTH_UNAVAILABLE", "GPU depth pass produced no mesh coverage");
const modelMatrix = candidateMatrix(object, request.objectId);
const visibleVertexIndices: number[] = [];
for (const index of request.vertexIndices) {
const sourceOffset = index * 3;
const world = new Vector3(
sourcePositions[sourceOffset],
sourcePositions[sourceOffset + 2],
-sourcePositions[sourceOffset + 1],
).applyMatrix4(modelMatrix);
const viewDistance = -world.clone().applyMatrix4(camera.matrixWorldInverse).z;
const projected = world.project(camera);
if (projected.x < -1 || projected.x > 1 || projected.y < -1 || projected.y > 1 || projected.z < -1 || projected.z > 1 || viewDistance <= 0) continue;
const x = Math.max(0, Math.min(width - 1, Math.round((projected.x * 0.5 + 0.5) * (width - 1))));
const y = Math.max(0, Math.min(height - 1, Math.round((projected.y * 0.5 + 0.5) * (height - 1))));
const depth = unpackRGBADepth(pixels, (y * width + x) * 4);
const sampledDistance = perspectiveDistance(depth, camera.near, camera.far);
const tolerance = Math.max(1e-4, viewDistance * DEPTH_TOLERANCE_RATIO);
if (viewDistance <= sampledDistance + tolerance) visibleVertexIndices.push(index);
}
return validatePaintDepthVisibilityResult({
...request,
backend,
source: "GPU_RGBA_DEPTH_READBACK",
width,
height,
depthReadbackBytes: pixels.byteLength,
occluderPixelCount,
visibleVertexIndices,
}, request);
}

View File

@@ -6,6 +6,7 @@ import {
MeshPhysicalMaterial,
Object3D,
PCFShadowMap,
PerspectiveCamera,
PointLight,
RectAreaLight,
SRGBColorSpace,
@@ -14,11 +15,14 @@ import {
type Light,
type WebGLRenderer,
} from "../vendor/three/three.module.js";
import type { LightIR, MaterialIR, SceneNodeIR } from "../../../protocol/scene-ir";
import type { CameraIR, LightIR, MaterialIR, SceneNodeIR } from "../../../protocol/scene-ir";
import { compileMaterialGraph, type ShaderCompileContext, type ShaderCompileReport } from "../../../protocol/shader-compiler";
import { PBR_RENDER_BUDGETS } from "../../../protocol/render-budget";
export const PBR_PROFILE = "physical-v1";
export const PBR_TONE_MAPPING = "aces";
export const PBR_SHADOW_PROFILE = "pcf-1024";
export const PBR_SHADOW_MAP_DIMENSION = PBR_RENDER_BUDGETS.THREE_WEBGL2.shadowMapDimension;
function clamp(value: number | undefined, minimum: number, maximum: number, fallback: number): number {
return Number.isFinite(value) ? Math.min(maximum, Math.max(minimum, value as number)) : fallback;
@@ -32,23 +36,36 @@ export function configurePBRRenderer(renderer: WebGLRenderer, exposure = 0): voi
renderer.shadowMap.type = PCFShadowMap;
}
export function createPBRMaterial(definition?: MaterialIR, active = false): MeshPhysicalMaterial {
const baseColor = definition?.baseColor ?? (active ? [0.83, 0.48, 0.29, 1] : [0.55, 0.62, 0.69, 1]);
const emission = definition?.emissionColor ?? [0, 0, 0, 1];
const alpha = clamp(definition?.alpha ?? baseColor[3], 0, 1, 1);
const transmission = clamp(definition?.transmissionWeight, 0, 1, 0);
export function configurePBRCamera(camera: PerspectiveCamera, definition: CameraIR): void {
const sensor = definition.sensorFit === 2 ? definition.sensorHeightMm : definition.sensorWidthMm;
const fov = (2 * Math.atan((sensor / Math.max(0.001, definition.lensMm)) / 2) * 180) / Math.PI;
camera.fov = definition.projection === "ORTHOGRAPHIC" ? 45 : fov;
camera.near = Math.max(0.0001, definition.near);
camera.far = Math.max(camera.near + 0.001, definition.far);
camera.filmGauge = sensor;
camera.filmOffset = definition.shift[0] * sensor;
camera.updateProjectionMatrix();
}
export function createPBRMaterial(definition?: MaterialIR, active = false, shaderContext: ShaderCompileContext = {}): MeshPhysicalMaterial {
const compileReport = definition?.nodes?.length ? compileMaterialGraph(definition, shaderContext) : undefined;
const compiled = compileReport?.status === "COMPILED" ? compileReport.material : undefined;
const baseColor = compiled?.baseColor ?? definition?.baseColor ?? (active ? [0.83, 0.48, 0.29, 1] : [0.55, 0.62, 0.69, 1]);
const emission = compiled?.emissionColor ?? definition?.emissionColor ?? [0, 0, 0, 1];
const alpha = clamp(compiled?.alpha ?? definition?.alpha ?? baseColor[3], 0, 1, 1);
const transmission = clamp(compiled?.transmissionWeight ?? definition?.transmissionWeight, 0, 1, 0);
const material = new MeshPhysicalMaterial({
color: new Color().setRGB(baseColor[0], baseColor[1], baseColor[2]),
roughness: clamp(definition?.roughness, 0, 1, 0.45),
metalness: clamp(definition?.metallic, 0, 1, 0.05),
ior: clamp(definition?.ior, 1, 2.333, 1.45),
roughness: clamp(compiled?.roughness ?? definition?.roughness, 0, 1, 0.45),
metalness: clamp(compiled?.metallic ?? definition?.metallic, 0, 1, 0.05),
ior: clamp(compiled?.ior ?? definition?.ior, 1, 2.333, 1.45),
// Blender's neutral Specular IOR Level is 0.5; Three's neutral multiplier is 1.0.
specularIntensity: clamp((definition?.specularIORLevel ?? 0.5) * 2, 0, 1, 1),
clearcoat: clamp(definition?.coatWeight, 0, 1, 0),
clearcoatRoughness: clamp(definition?.coatRoughness, 0, 1, 0.03),
specularIntensity: clamp((compiled?.specularIORLevel ?? definition?.specularIORLevel ?? 0.5) * 2, 0, 1, 1),
clearcoat: clamp(compiled?.coatWeight ?? definition?.coatWeight, 0, 1, 0),
clearcoatRoughness: clamp(compiled?.coatRoughness ?? definition?.coatRoughness, 0, 1, 0.03),
transmission,
emissive: new Color().setRGB(emission[0], emission[1], emission[2]),
emissiveIntensity: clamp(definition?.emissionStrength, 0, 1_000_000, 1),
emissiveIntensity: clamp(compiled?.emissionStrength ?? definition?.emissionStrength, 0, 1_000_000, 1),
opacity: alpha,
transparent: alpha < 0.999,
depthWrite: alpha >= 0.999,
@@ -58,9 +75,47 @@ export function createPBRMaterial(definition?: MaterialIR, active = false): Mesh
material.userData.baseEmissive = material.emissive.getHex();
material.userData.baseEmissiveIntensity = material.emissiveIntensity;
material.userData.pbrProfile = PBR_PROFILE;
if (compileReport) {
material.userData.shaderCompile = compileReport as ShaderCompileReport;
material.userData.shaderCompileTextures = compileReport.status === "COMPILED" ? compileReport.textureBindings : [];
}
return material;
}
export interface PBRMaterialPipelineUpdate {
material: MeshPhysicalMaterial;
report?: ShaderCompileReport;
replaced: boolean;
}
/** Keeps the last compiled material alive when a later graph fails closed. */
export class PBRMaterialPipeline {
private current: MeshPhysicalMaterial | null = null;
get material(): MeshPhysicalMaterial | null {
return this.current;
}
update(definition?: MaterialIR, active = false, shaderContext: ShaderCompileContext = {}): PBRMaterialPipelineUpdate {
const candidate = createPBRMaterial(definition, active, shaderContext);
const report = candidate.userData.shaderCompile as ShaderCompileReport | undefined;
if (report?.status === "BLOCKED" && this.current) {
this.current.userData.shaderCompileFailure = report;
candidate.dispose();
return { material: this.current, report, replaced: false };
}
const previous = this.current;
this.current = candidate;
previous?.dispose();
return { material: candidate, report, replaced: previous !== null };
}
dispose(): void {
this.current?.dispose();
this.current = null;
}
}
export function setPBRMaterialSelected(material: MeshPhysicalMaterial, selected: boolean): void {
const baseEmissive = typeof material.userData.baseEmissive === "number" ? material.userData.baseEmissive : 0;
const baseIntensity = typeof material.userData.baseEmissiveIntensity === "number" ? material.userData.baseEmissiveIntensity : 1;
@@ -108,7 +163,12 @@ export function createPBRLight(definition: LightIR): Light {
return light;
}
export function configurePBRLight(light: Light, node: SceneNodeIR, parent: Object3D): void {
export function configurePBRLight(
light: Light,
node: SceneNodeIR,
parent: Object3D,
options: { shadowEnabled?: boolean; shadowMapDimension?: number } = {},
): void {
const [x, y, z] = node.transform.translation;
light.position.set(x, z, -y);
light.rotation.set(node.transform.rotationEuler[0], node.transform.rotationEuler[2], -node.transform.rotationEuler[1]);
@@ -117,13 +177,15 @@ export function configurePBRLight(light: Light, node: SceneNodeIR, parent: Objec
light.decay = 2;
}
light.userData.blenderCastsShadow = definitionCastsShadow(light);
if ((light instanceof DirectionalLight || light instanceof SpotLight) && light.userData.blenderCastsShadow) {
const shadowEnabled = options.shadowEnabled ?? light.userData.blenderCastsShadow;
light.userData.pbrShadowBudgetBlocked = light.userData.blenderCastsShadow && !shadowEnabled;
if ((light instanceof DirectionalLight || light instanceof SpotLight) && shadowEnabled) {
const target = new Object3D();
const forward = new Vector3(0, -1, 0).applyEuler(light.rotation);
target.position.copy(light.position).add(forward);
light.target = target;
light.castShadow = true;
light.shadow.mapSize.set(1024, 1024);
light.shadow.mapSize.set(options.shadowMapDimension ?? PBR_SHADOW_MAP_DIMENSION, options.shadowMapDimension ?? PBR_SHADOW_MAP_DIMENSION);
light.shadow.bias = -0.0005;
light.shadow.normalBias = 0.03;
light.shadow.camera.near = 0.05;
@@ -136,9 +198,9 @@ export function configurePBRLight(light: Light, node: SceneNodeIR, parent: Objec
}
parent.add(target);
}
else if (light instanceof PointLight && light.userData.blenderCastsShadow) {
else if (light instanceof PointLight && shadowEnabled) {
light.castShadow = true;
light.shadow.mapSize.set(1024, 1024);
light.shadow.mapSize.set(options.shadowMapDimension ?? PBR_SHADOW_MAP_DIMENSION, options.shadowMapDimension ?? PBR_SHADOW_MAP_DIMENSION);
light.shadow.bias = -0.0005;
light.shadow.normalBias = 0.03;
light.shadow.camera.near = 0.05;

View File

@@ -0,0 +1,9 @@
export {
compareRenderImages,
MAX_RENDER_COMPARISON_DIMENSION,
MAX_RENDER_COMPARISON_PIXELS,
RENDER_IMAGE_COMPARISON_SCHEMA_VERSION,
RENDER_REFERENCE_MISMATCH_CODE,
type RenderImageComparisonIR,
type RenderImageComparisonThresholdsIR,
} from "../../../protocol/render-image-comparison";

View File

@@ -0,0 +1,10 @@
export {
routeRenderExecution,
RENDER_ROUTING_SCHEMA_VERSION,
type RenderRoutingBackend,
type RenderRoutingContextIR,
type RenderRoutingEngine,
type RenderRoutingRequestIR,
type RenderRoutingResultIR,
type RenderRoutingTarget,
} from "../../../protocol/render-routing";

View File

@@ -11,6 +11,7 @@ import {
} from "../vendor/three/three.module.js";
import type { MaterialIR, SceneSnapshotIR, WorldIR } from "../../../protocol/scene-ir";
import { RenderAssetValidationError, validateGPUTextureAsset, type GPUTextureAsset, type GPUTextureColorSpace, type GPUTextureUsage } from "../../../protocol/render-assets";
import { planPBRTextureBudget, type PBRDeviceLimits, type PBRRenderBackend, type PBRTextureBudgetReport } from "../../../protocol/render-budget";
export interface TextureUploadStatus {
loaded: number;
@@ -18,6 +19,7 @@ export interface TextureUploadStatus {
bytes: number;
errors: string[];
errorCodes: string[];
budget: PBRTextureBudgetReport;
}
function key(imageId: string, usage: GPUTextureUsage): string {
@@ -48,6 +50,11 @@ export class GPUTextureStore {
private readonly udimTileCounts = new Map<string, number>();
private revision = 0;
constructor(
private readonly backend: PBRRenderBackend = "THREE_WEBGL2",
private readonly deviceLimits: PBRDeviceLimits = {},
) {}
getRevision(): number {
return this.revision;
}
@@ -61,7 +68,20 @@ export class GPUTextureStore {
}
async upload(assets: readonly GPUTextureAsset[]): Promise<TextureUploadStatus> {
const status: TextureUploadStatus = { loaded: 0, rejected: 0, bytes: 0, errors: [], errorCodes: [] };
const candidate = new Map(this.assets);
for (const asset of assets) {
const lookupUsage = asset.usage === "UDIM_TILE" ? "BASE_COLOR" : asset.usage;
candidate.set(key(asset.imageId, lookupUsage), asset);
}
const budget = planPBRTextureBudget([...candidate.values()], this.backend, this.deviceLimits);
const status: TextureUploadStatus = { loaded: 0, rejected: 0, bytes: 0, errors: [], errorCodes: [], budget };
if (budget.status === "BLOCKED") {
status.rejected = assets.length;
status.errors.push(...budget.issues.map((issue) => issue.message));
status.errorCodes.push(...budget.issues.map((issue) => issue.code));
this.revision += 1;
return status;
}
const incomingUDIMCounts = new Map<string, number>();
for (const asset of assets) if (asset.usage === "UDIM_TILE") incomingUDIMCounts.set(asset.imageId, (incomingUDIMCounts.get(asset.imageId) ?? 0) + 1);
for (const asset of assets) {
@@ -92,12 +112,17 @@ export class GPUTextureStore {
applyMaterial(material: MeshPhysicalMaterial, definition?: MaterialIR): void {
if (!definition) return;
const baseImageId = definition.imageIds?.find((imageId) => imageId !== definition.normalImageId) ?? definition.nodes?.find((node) => node.type === "IMAGE_TEXTURE" && node.imageId && node.imageId !== definition.normalImageId)?.imageId;
const compile = material.userData.shaderCompile as { status?: string; textureBindings?: Array<{ imageId: string; usage: "BASE_COLOR" | "NORMAL" }> } | undefined;
const baseImageId = compile?.status === "COMPILED"
? compile.textureBindings?.find((binding) => binding.usage === "BASE_COLOR")?.imageId
: definition.imageIds?.find((imageId) => imageId !== definition.normalImageId) ?? definition.nodes?.find((node) => node.type === "IMAGE_TEXTURE" && node.imageId && node.imageId !== definition.normalImageId)?.imageId;
if (baseImageId) {
const texture = this.get(baseImageId, "BASE_COLOR");
if (texture) material.map = texture;
}
const normalImageId = definition.normalImageId ?? definition.nodes?.find((node) => node.type === "IMAGE_TEXTURE" && node.imageId)?.imageId;
const normalImageId = compile?.status === "COMPILED"
? compile.textureBindings?.find((binding) => binding.usage === "NORMAL")?.imageId
: definition.normalImageId ?? definition.nodes?.find((node) => node.type === "IMAGE_TEXTURE" && node.imageId)?.imageId;
if (normalImageId) {
const texture = this.get(normalImageId, "NORMAL");
if (texture) material.normalMap = texture;

View File

@@ -30,10 +30,12 @@ import type { MeshElementMode, MeshGeometryBuffer, WebEngineLODLevelResult } fro
import { ThreeLODAdapter, type ThreeLODLevel, type LODSelectionResult } from "./lod";
import {
configurePBRLight,
configurePBRCamera,
configurePBRRenderer,
createPBRLight,
createPBRMaterial,
PBR_PROFILE,
PBR_SHADOW_MAP_DIMENSION,
PBR_SHADOW_PROFILE,
PBR_TONE_MAPPING,
setPBRMaterialSelected,
@@ -41,6 +43,7 @@ import {
import { GPUTextureStore } from "./texture-assets";
import type { GPUTextureAsset } from "../../../protocol/render-assets";
import { gateEnvironmentImage, gateUDIMImage } from "../../../protocol/render-assets";
import { planPBRLightingBudget, type PBRLightingBudgetReport } from "../../../protocol/render-budget";
import { applyCurveHandlePreview, applyNonMeshElementSelection, applyNonMeshTransform, createNonMeshObject, type NonMeshElementKind } from "./nonmesh";
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
import type { CurveGizmoFrameIR, CurveGizmoHandleIR, CurveGizmoScreenFrameIR } from "../../../protocol/nonmesh-interaction";
@@ -52,10 +55,20 @@ import {
applyGreasePencilPointPreview,
applyGreasePencilTransform,
createGreasePencilObject,
greasePencilMarqueeCandidates,
greasePencilPointRef,
type GreasePencilPointRef,
type GreasePencilPointPreview,
} from "./grease-pencil";
import {
selectGreasePencilMarquee,
type GreasePencilDrawingScopeIR,
type GreasePencilMarqueeBoxIR,
type GreasePencilMarqueeResultIR,
} from "../../../protocol/grease-pencil-marquee";
import { VIEWPORT_DEFAULT_ORBIT, VIEWPORT_ORBIT_MAX_DISTANCE, VIEWPORT_ORBIT_MIN_DISTANCE, VIEWPORT_ORBIT_ROTATE_SENSITIVITY, VIEWPORT_ORBIT_ZOOM_SENSITIVITY, orbitPosition, orbitStateFromPosition } from "../../../protocol/viewport-camera";
import { validatePaintDepthVisibilityRequest, type PaintDepthVisibilityRequestIR, type PaintDepthVisibilityResultIR } from "../../../protocol/paint-depth-visibility";
import { samplePaintDepthVisibilityGPU } from "./paint-depth-visibility";
export function collectMeshInstanceGroups(snapshot: SceneSnapshotIR, minimumSize = 2): Map<string, string[]> {
const groups = new Map<string, string[]>();
@@ -86,14 +99,16 @@ export class ViewportRenderer {
private readonly objectByBlenderId = new Map<string, Object3D>();
private readonly instanceIndexByBlenderId = new Map<string, number>();
private readonly lodAdapter = new ThreeLODAdapter();
private readonly textureStore = new GPUTextureStore();
private readonly textureStore = new GPUTextureStore("THREE_WEBGL2");
private readonly raycaster = new Raycaster();
private readonly pointer = new Vector2();
private readonly onSelect?: (objectId: string, additive: boolean) => void;
private readonly onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void;
private readonly onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean) => void;
private readonly onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean, baseSelectionRevision: number) => void;
private readonly onGreasePencilMarqueeSelect?: (result: GreasePencilMarqueeResultIR, additive: boolean) => void;
private editMode = false;
private selectionMode: MeshElementMode = "FACE";
private greasePencilSelectionRevision = 0;
private curveGizmoFrame: { dataId: string; frame: CurveGizmoFrameIR } | null = null;
private curveGizmoScreenFrame = "";
private volumeAssets: NanoVDBViewportAssetIR[] = [];
@@ -105,12 +120,14 @@ export class ViewportRenderer {
canvas: HTMLCanvasElement,
onSelect?: (objectId: string, additive: boolean) => void,
onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void,
onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean) => void,
onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean, baseSelectionRevision: number) => void,
onGreasePencilMarqueeSelect?: (result: GreasePencilMarqueeResultIR, additive: boolean) => void,
) {
this.canvas = canvas;
this.onSelect = onSelect;
this.onElementSelect = onElementSelect;
this.onGreasePencilPointSelect = onGreasePencilPointSelect;
this.onGreasePencilMarqueeSelect = onGreasePencilMarqueeSelect;
this.volumeRenderSession = new NanoVDBViewportRenderSession(() => {
if (this.disposed) return;
this.volumeRenderCache.clear();
@@ -129,16 +146,20 @@ export class ViewportRenderer {
this.canvas.dataset.deviceStatus = "ready";
this.scene = new Scene();
this.camera = new PerspectiveCamera(45, 1, 0.01, 1000);
this.camera.position.set(4.5, -4.5, 3.5);
this.camera.position.set(...orbitPosition(VIEWPORT_DEFAULT_ORBIT));
this.controls = new OrbitControls(this.camera, canvas);
this.controls.target.set(0, 0, 0);
this.controls.enableDamping = true;
this.controls.target.set(...VIEWPORT_DEFAULT_ORBIT.target);
this.controls.enableDamping = false;
this.controls.enablePan = false;
this.controls.minDistance = VIEWPORT_ORBIT_MIN_DISTANCE;
this.controls.maxDistance = VIEWPORT_ORBIT_MAX_DISTANCE;
this.controls.zoomSpeed = VIEWPORT_ORBIT_ZOOM_SENSITIVITY / (0.01 * -Math.log(0.95));
this.scene.add(new HemisphereLight(0xf2f5ff, 0x3a4149, 0.55));
const keyLight = new DirectionalLight(0xffffff, 2.5);
keyLight.position.set(4, -5, 8);
keyLight.castShadow = true;
keyLight.shadow.mapSize.set(1024, 1024);
keyLight.shadow.mapSize.set(PBR_SHADOW_MAP_DIMENSION, PBR_SHADOW_MAP_DIMENSION);
keyLight.shadow.bias = -0.0005;
keyLight.shadow.normalBias = 0.03;
this.scene.add(keyLight, keyLight.target);
@@ -152,6 +173,8 @@ export class ViewportRenderer {
this.canvas.addEventListener("webglcontextlost", this.handleContextLost);
this.canvas.addEventListener("webglcontextrestored", this.handleContextRestored);
this.resize();
this.controls.update();
this.publishCameraState();
this.renderLoop();
}
@@ -176,7 +199,9 @@ export class ViewportRenderer {
this.clearImportedScene();
this.applyWorld(snapshot);
this.applyCamera(snapshot);
this.populateLights(snapshot);
const lightingBudget = planPBRLightingBudget(snapshot, "THREE_WEBGL2");
this.publishLightingBudget(lightingBudget);
this.populateLights(snapshot, lightingBudget);
this.populateNonMesh(snapshot, nonMeshGeometryBuffers);
this.populateGreasePencils(snapshot);
const meshes = new Map(snapshot.meshes.map((mesh) => [mesh.id, mesh]));
@@ -340,6 +365,11 @@ export class ViewportRenderer {
this.canvas.dataset.textureStatus = "none";
this.canvas.dataset.textureLoaded = "0";
this.canvas.dataset.textureBytes = "0";
this.canvas.dataset.textureBudgetStatus = "ready";
this.canvas.dataset.textureBudgetCode = "";
this.canvas.dataset.textureBudgetAssets = "0";
this.canvas.dataset.textureBudgetPayloadBytes = "0";
this.canvas.dataset.textureBudgetGpuBytes = "0";
return;
}
void this.textureStore.upload(assets).then((status) => {
@@ -347,6 +377,11 @@ export class ViewportRenderer {
this.canvas.dataset.textureLoaded = String(status.loaded);
this.canvas.dataset.textureBytes = String(status.bytes);
this.canvas.dataset.textureErrorCode = status.errorCodes[0] ?? "";
this.canvas.dataset.textureBudgetStatus = status.budget.status.toLowerCase();
this.canvas.dataset.textureBudgetCode = status.budget.issues[0]?.code ?? "";
this.canvas.dataset.textureBudgetAssets = String(status.budget.requestedAssets);
this.canvas.dataset.textureBudgetPayloadBytes = String(status.budget.payloadBytes);
this.canvas.dataset.textureBudgetGpuBytes = String(status.budget.decodedGPUBytes);
if (!this.currentSnapshot) return;
this.textureStore.applySnapshotMaterials(this.importedRoot, this.currentSnapshot);
const worldId = this.currentSnapshot.scenes[0]?.worldId;
@@ -449,6 +484,7 @@ export class ViewportRenderer {
objectIds: ReadonlySet<string>,
elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>,
greasePencilPoints: readonly GreasePencilPointRef[] = [],
greasePencilSelectionRevision = 0,
): void {
const visitedInstances = new Set<InstancedMesh>();
for (const [objectId, object] of this.objectByBlenderId) {
@@ -471,6 +507,9 @@ export class ViewportRenderer {
}
applyNonMeshElementSelection(this.importedRoot, elementSelection ?? new Map());
applyGreasePencilPointSelection(this.importedRoot, greasePencilPoints);
this.greasePencilSelectionRevision = greasePencilSelectionRevision;
this.canvas.dataset.greasePencilSelectionRevision = String(greasePencilSelectionRevision);
this.canvas.dataset.greasePencilSelectionPointIds = greasePencilPoints.map((point) => point.pointId).join(",");
}
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void {
@@ -481,6 +520,44 @@ export class ViewportRenderer {
});
}
async samplePaintVisibility(requestValue: PaintDepthVisibilityRequestIR): Promise<PaintDepthVisibilityResultIR> {
const request = validatePaintDepthVisibilityRequest(requestValue, this.currentSnapshot?.revision ?? -1);
const node = this.currentSnapshot?.nodes.find((candidate) => candidate.id === request.objectId && candidate.dataId === request.meshId && candidate.visible);
const object = node ? this.objectByBlenderId.get(node.id) : undefined;
if (!object) throw new Error("PAINT_DEPTH_UNAVAILABLE: Paint object is not available in the current viewport");
return samplePaintDepthVisibilityGPU({
renderer: this.renderer,
scene: this.scene,
camera: this.camera,
object,
request,
backend: "MAIN_THREAD_WEBGL2",
});
}
selectGreasePencilMarquee(
drawing: GreasePencilDrawingScopeIR,
box: GreasePencilMarqueeBoxIR,
baseRevision: number,
baseSelectionRevision: number,
additive: boolean,
): void {
const result = selectGreasePencilMarquee({
schemaVersion: 1,
baseRevision,
baseSelectionRevision,
drawing,
box,
candidates: greasePencilMarqueeCandidates(this.importedRoot, drawing, this.camera),
}, this.currentSnapshot?.revision ?? -1);
this.canvas.dataset.greasePencilMarqueeSelectionRevision = String(result.baseSelectionRevision);
this.canvas.dataset.greasePencilMarqueeDrawingId = result.drawing.drawingId;
this.canvas.dataset.greasePencilMarqueePointIds = result.selectedPoints.map((point) => point.pointId).join(",");
this.canvas.dataset.greasePencilMarqueeStrokeIds = result.selectedStrokeIds.join(",");
this.canvas.dataset.greasePencilMarqueeCount = String(result.selectedPoints.length);
this.onGreasePencilMarqueeSelect?.(result, additive);
}
setCurveHandlePreview(dataId: string, handles: readonly CurveGizmoHandleIR[] | null): void {
applyCurveHandlePreview(this.importedRoot, dataId, handles);
this.canvas.dataset.curveGizmoPreview = handles ? String(handles.length) : "0";
@@ -675,24 +752,35 @@ export class ViewportRenderer {
const cameraNode = snapshot.nodes.find((node) => node.id === cameraObjectId && node.type === "CAMERA");
const definition = snapshot.cameras.find((camera) => camera.id === cameraNode?.dataId);
if (!cameraNode || !definition) return;
const sensor = definition.sensorFit === 2 ? definition.sensorHeightMm : definition.sensorWidthMm;
const fov = (2 * Math.atan((sensor / Math.max(0.001, definition.lensMm)) / 2) * 180) / Math.PI;
this.camera.fov = definition.projection === "ORTHOGRAPHIC" ? 45 : fov;
this.camera.near = Math.max(0.0001, definition.near);
this.camera.far = Math.max(this.camera.near + 0.001, definition.far);
this.camera.filmGauge = sensor;
this.camera.filmOffset = definition.shift[0] * sensor;
this.camera.updateProjectionMatrix();
configurePBRCamera(this.camera, definition);
}
private populateLights(snapshot: SceneSnapshotIR): void {
private publishLightingBudget(report: PBRLightingBudgetReport): void {
this.canvas.dataset.renderBudgetBackend = report.backend;
this.canvas.dataset.renderBudgetStatus = report.status.toLowerCase();
this.canvas.dataset.renderBudgetCode = report.issues[0]?.code ?? "";
this.canvas.dataset.renderBudgetLights = String(report.requestedLights);
this.canvas.dataset.renderBudgetRenderedLights = String(report.renderedLightNodeIds.length);
this.canvas.dataset.renderBudgetDroppedLights = String(report.droppedLightNodeIds.length);
this.canvas.dataset.renderBudgetShadows = String(report.requestedShadowMaps);
this.canvas.dataset.renderBudgetRenderedShadows = String(report.shadowLightNodeIds.length);
this.canvas.dataset.renderBudgetBlockedShadows = String(report.shadowBlockedLightNodeIds.length);
this.canvas.dataset.renderBudgetShadowMapDimension = String(report.budget.shadowMapDimension);
}
private populateLights(snapshot: SceneSnapshotIR, budget = planPBRLightingBudget(snapshot, "THREE_WEBGL2")): void {
const lights = new Map(snapshot.lights.map((light) => [light.id, light]));
const rendered = new Set(budget.renderedLightNodeIds);
const shadowed = new Set(budget.shadowLightNodeIds);
for (const node of snapshot.nodes) {
if (node.type !== "LIGHT" || !node.visible || !node.dataId) continue;
if (node.type !== "LIGHT" || !node.visible || !node.dataId || !rendered.has(node.id)) continue;
const definition = lights.get(node.dataId);
if (!definition) continue;
const light = createPBRLight(definition);
configurePBRLight(light, node, this.importedLights);
configurePBRLight(light, node, this.importedLights, {
shadowEnabled: shadowed.has(node.id),
shadowMapDimension: budget.budget.shadowMapDimension,
});
light.name = node.name;
light.userData.sceneNodeId = node.id;
light.userData.blenderId = node.id;
@@ -707,7 +795,18 @@ export class ViewportRenderer {
const height = Math.max(1, this.canvas.clientHeight);
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.controls.rotateSpeed = VIEWPORT_ORBIT_ROTATE_SENSITIVITY * height / (2 * Math.PI);
this.renderer.setSize(width, height, false);
this.publishCameraState();
}
private publishCameraState(): void {
const orbit = orbitStateFromPosition(this.camera.position.toArray(), this.controls.target.toArray());
this.canvas.dataset.cameraPosition = this.camera.position.toArray().map((value) => Number(value.toFixed(6))).join(",");
this.canvas.dataset.cameraTarget = orbit.target.map((value) => Number(value.toFixed(6))).join(",");
this.canvas.dataset.cameraYaw = orbit.yaw.toFixed(6);
this.canvas.dataset.cameraPitch = orbit.pitch.toFixed(6);
this.canvas.dataset.cameraDistance = orbit.distance.toFixed(6);
}
private handleClick = (event: MouseEvent): void => {
@@ -724,7 +823,7 @@ export class ViewportRenderer {
: undefined;
if (greasePencilHit?.index !== undefined) {
const point = greasePencilPointRef(greasePencilHit.object, greasePencilHit.index);
if (point) this.onGreasePencilPointSelect?.(point, event.shiftKey || event.ctrlKey || event.metaKey);
if (point) this.onGreasePencilPointSelect?.(point, event.shiftKey || event.ctrlKey || event.metaKey, this.greasePencilSelectionRevision);
return;
}
const preferredNonMeshHit = this.editMode ? hits.find((intersection) => intersection.index !== undefined && Array.isArray(intersection.object.userData.nonMeshPointKindMap)) : undefined;
@@ -791,6 +890,7 @@ export class ViewportRenderer {
if (this.disposed) return;
if (!this.contextLost) {
this.controls.update();
this.publishCameraState();
this.lodAdapter.update(this.camera, Math.max(1, this.canvas.clientHeight));
this.renderer.render(this.scene, this.camera);
this.publishCurveGizmoFrame();

File diff suppressed because one or more lines are too long

BIN
web/app/src/vendor/blender/web_engine.wasm vendored Executable file → Normal file

Binary file not shown.

View File

@@ -2,6 +2,10 @@ import type { NanoVDBFloat32TreeLayoutIR, NanoVDBGridIR } from "../../../protoco
export interface NanoVDBSampleIR { value: number; active: boolean }
type NanoVDBNearestLocation =
| { sample: NanoVDBSampleIR }
| { leaf: number; voxel: number };
export class NanoVDBFloat32Sampler {
private readonly view: DataView;
private readonly layout: NanoVDBFloat32TreeLayoutIR;
@@ -24,6 +28,20 @@ export class NanoVDBFloat32Sampler {
}
nearest(coord: readonly [number, number, number]): NanoVDBSampleIR {
const location = this.nearestLocation(coord);
if ("sample" in location) return location.sample;
return {
value: this.f32(location.leaf + this.layout.leafValuesOffset + location.voxel * 4),
active: this.mask(location.leaf + this.layout.leafValueMaskOffset, location.voxel),
};
}
leafByteOffset(coord: readonly [number, number, number]): number | null {
const location = this.nearestLocation(coord);
return "leaf" in location ? location.leaf : null;
}
private nearestLocation(coord: readonly [number, number, number]): NanoVDBNearestLocation {
if (coord.some((value) => !Number.isSafeInteger(value) || value < -0x8000_0000 || value > 0x7fff_ffff)) throw new Error("NANOVDB_MANIFEST_INVALID: sample coordinate is outside int32");
const tableSize = this.u32(this.root + this.layout.rootTableSizeOffset);
const key = this.rootKey(coord);
@@ -39,20 +57,20 @@ export class NanoVDBFloat32Sampler {
if (candidate > key) low = middle + 1;
else high = middle - 1;
}
if (tile < 0) return { value: this.view.getFloat32(this.root + 28, true), active: false };
if (tile < 0) return { sample: { value: this.view.getFloat32(this.root + 28, true), active: false } };
const child = this.i64(tile + this.layout.rootTileChildOffset);
if (child === 0n) return { value: this.f32(tile + this.layout.rootTileValueOffset), active: this.u32(tile + this.layout.rootTileStateOffset) !== 0 };
if (child === 0n) return { sample: { value: this.f32(tile + this.layout.rootTileValueOffset), active: this.u32(tile + this.layout.rootTileStateOffset) !== 0 } };
const upper = this.child(this.root, child, this.layout.upperNodeBytes);
const upperOffset = (((coord[0] >>> 0 & 4095) >>> 7) << 10) | (((coord[1] >>> 0 & 4095) >>> 7) << 5) | ((coord[2] >>> 0 & 4095) >>> 7);
const upperSample = this.internal(upper, upperOffset, this.layout.upperValueMaskOffset, this.layout.upperChildMaskOffset, this.layout.upperTableOffset, this.layout.lowerNodeBytes);
if ("sample" in upperSample) return upperSample.sample;
if ("sample" in upperSample) return upperSample;
const lower = upperSample.child;
const lowerOffset = (((coord[0] >>> 0 & 127) >>> 3) << 8) | (((coord[1] >>> 0 & 127) >>> 3) << 4) | ((coord[2] >>> 0 & 127) >>> 3);
const lowerSample = this.internal(lower, lowerOffset, this.layout.lowerValueMaskOffset, this.layout.lowerChildMaskOffset, this.layout.lowerTableOffset, this.layout.leafNodeBytes);
if ("sample" in lowerSample) return lowerSample.sample;
if ("sample" in lowerSample) return lowerSample;
const leaf = lowerSample.child;
const voxel = ((coord[0] >>> 0 & 7) << 6) | ((coord[1] >>> 0 & 7) << 3) | (coord[2] >>> 0 & 7);
return { value: this.f32(leaf + this.layout.leafValuesOffset + voxel * 4), active: this.mask(leaf + this.layout.leafValueMaskOffset, voxel) };
return { leaf, voxel };
}
linear(coord: readonly [number, number, number]): NanoVDBSampleIR {

View File

@@ -7,6 +7,16 @@ import {
type NanoVDBRangeIR,
} from "../../../protocol/volume-vdb";
import {
dispatchNanoVDBPageFeedbackBatch,
type NanoVDBPageFeedbackBatch,
type NanoVDBPageFeedbackDispatchResult,
} from "../../../protocol/nanovdb-page-feedback";
import {
planNanoVDBDeviceLossReplay,
type NanoVDBDeviceLossReplayPlanIR,
} from "../../../protocol/nanovdb-device-recovery";
import {
createNanoVDBFloat32GridPaged,
NanoVDBWebGPUDeviceSession,
probeNanoVDBWebGPU,
renderNanoVDBFloat32WebGPU,
@@ -267,6 +277,177 @@ export async function loadNanoVDBGridPage(
return output.buffer;
}
export interface NanoVDBFeedbackPageIR {
pageId: number;
renderRevision: number;
data: ArrayBuffer;
}
export interface NanoVDBDeviceLossGridReplayIR {
grid: NanoVDBWebGPUGrid;
plan: NanoVDBDeviceLossReplayPlanIR;
residentBeforeReplay: readonly number[];
}
export async function rebuildNanoVDBGridAfterDeviceLoss(
device: GPUDevice,
manifestValue: NanoVDBBundleManifestIR,
gridName: string,
source: NanoVDBRangeSource,
visiblePageIds: readonly number[],
signal: AbortSignal,
): Promise<NanoVDBDeviceLossGridReplayIR> {
const manifest = validateNanoVDBBundleManifest(manifestValue);
const gridDefinition = manifest.grids.find((candidate) => candidate.name === gridName);
if (!gridDefinition) throw new Error(`NANOVDB_MANIFEST_INVALID: grid ${gridName} is missing`);
const rebuilt = createNanoVDBFloat32GridPaged(
device,
gridDefinition.byteLength,
manifest.gpu.pageByteLength,
manifest.gpu.maxResidentBytes,
);
const residentBeforeReplay = [...rebuilt.residentVirtualPages];
const plan = planNanoVDBDeviceLossReplay(visiblePageIds, rebuilt.pageCount, rebuilt.residentPageCapacity);
try {
for (const pageId of plan.replayedPageIds) {
if (signal.aborted) throw new DOMException("NanoVDB device-loss replay cancelled", "AbortError");
const data = await loadNanoVDBGridPage(manifest, gridName, pageId, source, signal);
rebuilt.uploadPage(pageId, data);
}
return { grid: rebuilt, plan, residentBeforeReplay };
}
catch (error) {
rebuilt.dispose();
throw error;
}
}
interface NanoVDBPageRequestSubscriber {
signal: AbortSignal;
onAbort: () => void;
resolve: (data: ArrayBuffer) => void;
reject: (error: unknown) => void;
}
interface NanoVDBPendingPageRequest {
controller: AbortController;
subscribers: Map<symbol, NanoVDBPageRequestSubscriber>;
}
export interface NanoVDBPageRequestCoordinatorStatsIR {
pendingPages: number;
subscribers: number;
pageIds: number[];
}
function pageRequestCancelled(): DOMException {
return new DOMException("NanoVDB page request cancelled", "AbortError");
}
export class NanoVDBGridPageRequestCoordinator {
private readonly manifest: NanoVDBBundleManifestIR;
private readonly pending = new Map<number, NanoVDBPendingPageRequest>();
private disposed = false;
constructor(
manifestValue: NanoVDBBundleManifestIR,
private readonly gridName: string,
private readonly source: NanoVDBRangeSource,
) {
this.manifest = validateNanoVDBBundleManifest(manifestValue);
if (!this.manifest.grids.some((grid) => grid.name === gridName)) {
throw new Error(`NANOVDB_MANIFEST_INVALID: grid ${gridName} is missing`);
}
}
request(pageId: number, signal: AbortSignal): Promise<ArrayBuffer> {
if (this.disposed || signal.aborted) return Promise.reject(pageRequestCancelled());
let request = this.pending.get(pageId);
if (!request) {
request = { controller: new AbortController(), subscribers: new Map() };
this.pending.set(pageId, request);
const current = request;
void loadNanoVDBGridPage(this.manifest, this.gridName, pageId, this.source, current.controller.signal).then(
(data) => this.resolve(pageId, current, data),
(error) => this.reject(pageId, current, error),
);
}
const current = request;
return new Promise<ArrayBuffer>((resolve, reject) => {
const token = Symbol("NanoVDB page subscriber");
const onAbort = (): void => {
const subscriber = current.subscribers.get(token);
if (!subscriber) return;
current.subscribers.delete(token);
signal.removeEventListener("abort", onAbort);
reject(pageRequestCancelled());
if (current.subscribers.size === 0) {
if (this.pending.get(pageId) === current) this.pending.delete(pageId);
current.controller.abort();
}
};
current.subscribers.set(token, { signal, onAbort, resolve, reject });
signal.addEventListener("abort", onAbort, { once: true });
if (signal.aborted) onAbort();
});
}
stats(): NanoVDBPageRequestCoordinatorStatsIR {
return {
pendingPages: this.pending.size,
subscribers: [...this.pending.values()].reduce((total, request) => total + request.subscribers.size, 0),
pageIds: [...this.pending.keys()].sort((left, right) => left - right),
};
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
for (const [pageId, request] of this.pending) {
this.pending.delete(pageId);
request.controller.abort();
for (const subscriber of request.subscribers.values()) {
subscriber.signal.removeEventListener("abort", subscriber.onAbort);
subscriber.reject(pageRequestCancelled());
}
request.subscribers.clear();
}
}
private resolve(pageId: number, request: NanoVDBPendingPageRequest, data: ArrayBuffer): void {
if (this.pending.get(pageId) === request) this.pending.delete(pageId);
for (const subscriber of request.subscribers.values()) {
subscriber.signal.removeEventListener("abort", subscriber.onAbort);
subscriber.resolve(data.slice(0));
}
request.subscribers.clear();
}
private reject(pageId: number, request: NanoVDBPendingPageRequest, error: unknown): void {
if (this.pending.get(pageId) === request) this.pending.delete(pageId);
for (const subscriber of request.subscribers.values()) {
subscriber.signal.removeEventListener("abort", subscriber.onAbort);
subscriber.reject(error);
}
request.subscribers.clear();
}
}
export async function loadNanoVDBFeedbackPages(
batch: NanoVDBPageFeedbackBatch,
currentRenderRevision: number,
requests: NanoVDBGridPageRequestCoordinator,
signal: AbortSignal,
consume: (page: NanoVDBFeedbackPageIR) => Promise<void> | void,
): Promise<NanoVDBPageFeedbackDispatchResult> {
return dispatchNanoVDBPageFeedbackBatch(batch, currentRenderRevision, async (pageId, renderRevision) => {
if (signal.aborted) throw new DOMException("NanoVDB feedback page load cancelled", "AbortError");
const data = await requests.request(pageId, signal);
if (signal.aborted) throw new DOMException("NanoVDB feedback page load cancelled", "AbortError");
await consume({ pageId, renderRevision, data });
});
}
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);

View File

@@ -0,0 +1,43 @@
import {
CURVE_TOPOLOGY_EDITOR_BUDGET,
createCurveTopologyEditorManifest,
parseCurveTopologyOperationClaim,
} from "../../../protocol/curve-topology-editor";
const scope = self as unknown as { onmessage: (() => void) | null; postMessage(value: unknown): void };
scope.onmessage = (): void => {
const manifest = createCurveTopologyEditorManifest();
const accepted = parseCurveTopologyOperationClaim({
schemaVersion: 1,
operator: "ADD_SPLINE",
dataId: "curve:BrowserCurve",
baseRevision: 9,
inputSplineCount: 1,
inputPointCount: 2,
selectedSplineIndices: [],
selectedPointIndices: [],
addedSplineCount: 1,
addedPointCount: 2,
outputSplineCount: 2,
outputPointCount: 4,
payloadBytes: 256,
}, 9);
const failure = (value: unknown, revision = 9): string => {
try { parseCurveTopologyOperationClaim(value, revision); }
catch (error) { return (error as { code?: string }).code ?? ""; }
return "";
};
scope.postMessage({
operatorCount: manifest.operators.length,
blockedCount: manifest.operators.filter((operator) => operator.gate.status === "BLOCKED").length,
readyOperators: manifest.operators.filter((operator) => operator.gate.status === "READY").map((operator) => operator.id),
sourceAuthority: manifest.sourceAuthority,
atomicMainTransaction: manifest.atomicMainTransaction,
accepted,
unknownCode: failure({ ...accepted, operator: "SPIN" }),
staleCode: failure(accepted, 10),
budgetCode: failure({ ...accepted, payloadBytes: CURVE_TOPOLOGY_EDITOR_BUDGET.maxPayloadBytes + 1 }),
stage: "ONE_VERIFIED_OPERATOR",
});
};

View File

@@ -0,0 +1,45 @@
import { validateExternalVFontImport } from "../../../protocol/external-vfont";
interface Request {
data: ArrayBuffer;
sha256: string;
}
const scope = self as unknown as { onmessage: ((event: MessageEvent<Request>) => void) | null; postMessage: (value: unknown) => void };
scope.onmessage = (event): void => {
void (async () => {
const validated = await validateExternalVFontImport({
sourcePath: "//fonts/browser-bfont.pfb",
mimeType: "application/x-font-type1",
byteLength: event.data.data.byteLength,
sha256: event.data.sha256,
data: event.data.data,
});
let spoofCode = "";
try {
await validateExternalVFontImport({
sourcePath: "//fonts/browser-bfont.otf",
mimeType: "font/otf",
byteLength: validated.data.byteLength,
sha256: event.data.sha256,
data: validated.data,
});
}
catch (error) { spoofCode = (error as { code?: string }).code ?? ""; }
scope.postMessage({
metadata: {
schemaVersion: validated.schemaVersion,
sourcePath: validated.sourcePath,
fileName: validated.fileName,
format: validated.format,
mimeType: validated.mimeType,
byteLength: validated.byteLength,
sha256: validated.sha256,
},
copied: validated.data !== event.data.data && new Uint8Array(validated.data).every((value, index) => value === new Uint8Array(event.data.data)[index]),
spoofCode,
stage: "VALIDATED_BEFORE_STORAGE_OR_MAIN",
});
})().catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) }));
};

View File

@@ -1,17 +1,18 @@
import { GREASE_PENCIL_BUDGET, parseGreasePencilData } from "../../../protocol/grease-pencil";
const point = (x: number) => ({ position: [x, 0, 0], radius: 0.05, opacity: 1 });
const point = (x: number, index: number) => ({ id: `grease-pencil-point:SchemaFixture:0:0:${index}`, position: [x, 0, 0], radius: 0.05, opacity: 1 });
const valid = {
id: "gp:SchemaFixture",
id: "grease-pencil:SchemaFixture",
name: "SchemaFixture",
geometryStatus: "available",
layerCount: 1,
frameCount: 1,
strokeCount: 1,
pointCount: 2,
activeLayerId: "grease-pencil-layer:SchemaFixture:Lines",
layers: [{
id: "layer:Lines",
id: "grease-pencil-layer:SchemaFixture:Lines",
name: "Lines",
visible: true,
locked: false,
@@ -19,10 +20,10 @@ const valid = {
frames: [{
frame: 1,
drawing: {
id: "drawing:1",
id: "grease-pencil-drawing:SchemaFixture:0",
strokeCount: 1,
pointCount: 2,
strokes: [{ id: "stroke:1", cyclic: false, pointCount: 2, points: [point(0), point(1)] }],
strokes: [{ id: "grease-pencil-stroke:SchemaFixture:0:0", cyclic: false, pointCount: 2, points: [point(0, 0), point(1, 1)] }],
},
}],
}],
@@ -49,13 +50,8 @@ function result(): Record<string, unknown> {
...valid,
geometryStatus: "blocked",
pointCount: GREASE_PENCIL_BUDGET.maxPoints + 1,
layers: [{
...valid.layers[0],
frames: [{
...valid.layers[0].frames[0],
drawing: { ...valid.layers[0].frames[0].drawing, pointCount: GREASE_PENCIL_BUDGET.maxPoints + 1, strokes: [{ cyclic: false, pointCount: GREASE_PENCIL_BUDGET.maxPoints + 1 }] },
}],
}],
activeLayerId: undefined,
layers: [],
strokeCount: 1,
frameCount: 1,
errorCode: "GREASE_PENCIL_BUDGET_EXCEEDED",

View File

@@ -23,10 +23,10 @@ self.onmessage = () => {
id: "grease-pencil-drawing:Viewport",
strokeCount: 1,
pointCount: 3,
strokes: [{ id: "grease-pencil-stroke:Viewport", cyclic: false, pointCount: 3, points: [
{ position: [-1, 0, 0], radius: 0.1, opacity: 1 },
{ position: [0, 0, 0], radius: 0.1, opacity: 1 },
{ position: [1, 0, 0], radius: 0.1, opacity: 1 },
strokes: [{ id: "grease-pencil-stroke:Viewport:0:0", cyclic: false, pointCount: 3, points: [
{ id: "grease-pencil-point:Viewport:0:0:0", position: [-1, 0, 0], radius: 0.1, opacity: 1 },
{ id: "grease-pencil-point:Viewport:0:0:1", position: [0, 0, 0], radius: 0.1, opacity: 1 },
{ id: "grease-pencil-point:Viewport:0:0:2", position: [1, 0, 0], radius: 0.1, opacity: 1 },
] }],
} }] }],
};

View File

@@ -0,0 +1,285 @@
import {
GEOMETRY_NODE_GRAPH_BUDGET,
gateGeometryNodeGraph,
parseGeometryNodeGraph,
type GeometryNodeGraphIR,
validateGeometryNodeGraph,
} from "../../../protocol/geometry-nodes";
import { NLA_STACK_BUDGET, gateNlaTracks } from "../../../protocol/nla";
import {
SHADER_COMPILE_BUDGET,
compileMaterialGraph,
} from "../../../protocol/shader-compiler";
import {
SIMULATION_CACHE_BUDGET,
computeSimulationCacheRevisionHash,
parseSimulationCacheManifest,
verifySimulationCache,
verifySimulationCacheFrame,
} from "../../../protocol/simulation-cache";
import type { MaterialIR } from "../../../protocol/scene-ir";
type Domain = "GN" | "SHADER" | "NLA" | "SIMULATION";
type GateResult = {
domain: Domain;
performanceMs: number;
workUnits: number;
oomCode: string;
maliciousCode: string;
recovered: boolean;
performanceStatus: string;
oomPreventedBeforeAllocation: boolean;
};
function geometryGraph(nodeCount: number): GeometryNodeGraphIR {
return {
schemaVersion: 1,
id: "node-group:M10Gate",
name: "M10 Gate",
interfaceInputs: [],
interfaceOutputs: [],
nodes: Array.from({ length: nodeCount }, (_value, index) => ({
id: `node:${index}`,
type: "ShaderNodeValue",
name: `Value ${index}`,
sockets: [],
})),
links: [],
groupReferences: [],
};
}
function runGeometryNodes(): GateResult {
const graph = geometryGraph(512);
const workUnits = 10;
const started = performance.now();
for (let index = 0; index < workUnits; index += 1) {
const parsed = parseGeometryNodeGraph(graph);
if (validateGeometryNodeGraph(parsed).status !== "SUPPORTED") throw new Error("GN performance fixture was blocked");
}
const oversized = geometryGraph(GEOMETRY_NODE_GRAPH_BUDGET.maxNodesPerGraph + 1);
const oom = gateGeometryNodeGraph(oversized);
const malicious = geometryGraph(2);
malicious.nodes[0].sockets = [
{ id: "in", name: "In", direction: "INPUT", dataType: "FLOAT" },
{ id: "out", name: "Out", direction: "OUTPUT", dataType: "FLOAT" },
];
malicious.nodes[1].sockets = structuredClone(malicious.nodes[0].sockets);
malicious.links = [
{ fromNodeId: "node:0", fromSocketId: "out", toNodeId: "node:1", toSocketId: "in" },
{ fromNodeId: "node:1", fromSocketId: "out", toNodeId: "node:0", toSocketId: "in" },
];
const maliciousGate = gateGeometryNodeGraph(malicious);
return {
domain: "GN",
performanceMs: Math.round(performance.now() - started),
workUnits,
oomCode: oom.issues[0]?.code ?? "",
maliciousCode: maliciousGate.issues.find((issue) => issue.code === "GN_DEPENDENCY_CYCLE")?.code ?? "",
recovered: gateGeometryNodeGraph(geometryGraph(1)).status === "READY",
performanceStatus: "SUPPORTED",
oomPreventedBeforeAllocation: oom.status === "BLOCKED",
};
}
function shaderMaterial(): MaterialIR {
return {
id: "material:M10Gate",
name: "M10 Gate",
baseColor: [0.2, 0.3, 0.4, 1],
roughness: 0.5,
metallic: 0.1,
emissionColor: [0, 0, 0, 1],
alpha: 1,
ior: 1.45,
shaderGraphHash: "a".repeat(64),
nodes: [
{ id: "value-a", type: "VALUE", name: "A", defaultValue: [0.2] },
{ id: "value-b", type: "VALUE", name: "B", defaultValue: [0.3] },
{ id: "math", type: "MATH", name: "Add", properties: { operation: "ADD" } },
{ id: "principled", type: "PRINCIPLED", name: "Principled" },
{ id: "output", type: "OUTPUT", name: "Output" },
],
links: [
{ fromNodeId: "value-a", fromSocket: "Value", toNodeId: "math", toSocket: "Value" },
{ fromNodeId: "value-b", fromSocket: "Value", toNodeId: "math", toSocket: "Value_001" },
{ fromNodeId: "math", fromSocket: "Value", toNodeId: "principled", toSocket: "Roughness" },
{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" },
],
};
}
function runShader(): GateResult {
const material = shaderMaterial();
const workUnits = 100;
const started = performance.now();
for (let index = 0; index < workUnits; index += 1) {
if (compileMaterialGraph(material).status !== "COMPILED") throw new Error("Shader performance fixture was blocked");
}
const oversized = shaderMaterial();
oversized.nodes = Array.from({ length: SHADER_COMPILE_BUDGET.maxNodes + 1 }, (_value, index) => ({
id: `value:${index}`, type: "VALUE", name: `Value ${index}`, defaultValue: [0],
}));
oversized.links = [];
const oom = compileMaterialGraph(oversized);
const cyclic = shaderMaterial();
cyclic.links = [
{ fromNodeId: "value-a", fromSocket: "Value", toNodeId: "math", toSocket: "Value" },
{ fromNodeId: "math", fromSocket: "Value", toNodeId: "math", toSocket: "Value_001" },
{ fromNodeId: "math", fromSocket: "Value", toNodeId: "principled", toSocket: "Roughness" },
{ fromNodeId: "principled", fromSocket: "BSDF", toNodeId: "output", toSocket: "Surface" },
];
const malicious = compileMaterialGraph(cyclic);
return {
domain: "SHADER",
performanceMs: Math.round(performance.now() - started),
workUnits,
oomCode: oom.issues[0]?.code ?? "",
maliciousCode: malicious.issues.find((issue) => issue.code === "SHADER_GRAPH_CYCLE")?.code ?? "",
recovered: compileMaterialGraph(shaderMaterial()).status === "COMPILED",
performanceStatus: "COMPILED",
oomPreventedBeforeAllocation: oom.status === "BLOCKED",
};
}
function nlaTracks(trackCount: number, stripsPerTrack: number) {
return Array.from({ length: trackCount }, (_trackValue, trackIndex) => ({
schemaVersion: 1,
id: `track:${trackIndex}`,
ownerId: "object:M10Gate",
name: `Track ${trackIndex}`,
muted: false,
solo: false,
selected: false,
strips: Array.from({ length: stripsPerTrack }, (_stripValue, stripIndex) => ({
id: `strip:${trackIndex}:${stripIndex}`,
actionId: "action:M10Gate",
frameStart: stripIndex * 12,
frameEnd: stripIndex * 12 + 10,
actionFrameStart: 0,
actionFrameEnd: 10,
scale: 1,
repeat: 1,
blendIn: 0,
blendOut: 0,
influence: 1,
blendMode: "REPLACE",
extrapolation: "NOTHING",
muted: false,
selected: false,
stripType: "CLIP",
})),
}));
}
function runNla(): GateResult {
const context = { actionIds: new Set(["action:M10Gate"]), ownerId: "object:M10Gate" };
const tracks = nlaTracks(64, 4);
const workUnits = 20;
const started = performance.now();
for (let index = 0; index < workUnits; index += 1) {
if (gateNlaTracks(tracks, context).status !== "READY") throw new Error("NLA performance fixture was blocked");
}
const oversized = new Array(NLA_STACK_BUDGET.maxTracks + 1).fill(tracks[0]);
const oom = gateNlaTracks(oversized, context);
const malicious = nlaTracks(1, 1) as Array<Record<string, unknown> & { strips: Array<Record<string, unknown>> }>;
malicious[0].strips[0].proxySuccess = true;
const maliciousGate = gateNlaTracks(malicious, context);
return {
domain: "NLA",
performanceMs: Math.round(performance.now() - started),
workUnits,
oomCode: oom.issues[0]?.code ?? "",
maliciousCode: maliciousGate.issues[0]?.code ?? "",
recovered: gateNlaTracks(nlaTracks(1, 1), context).status === "READY",
performanceStatus: "SUPPORTED",
oomPreventedBeforeAllocation: oom.status === "BLOCKED",
};
}
async function digest(value: ArrayBuffer): Promise<string> {
const hash = await crypto.subtle.digest("SHA-256", value);
return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
async function simulationFixture(frameCount: number, frameBytes: number) {
const payload = new Uint8Array(frameCount * frameBytes);
for (let index = 0; index < payload.length; index += 1) payload[index] = index % 251;
const binding = {
graphId: "node-group:M10Gate",
graphHash: await digest(Uint8Array.from([1]).buffer),
sourceBlendSha256: await digest(Uint8Array.from([2]).buffer),
sourceRevision: 15,
inputHash: await digest(Uint8Array.from([3]).buffer),
blenderVersion: "5.2.0",
frameStart: 1,
frameEnd: frameCount,
};
const frames = [];
for (let frame = 1; frame <= frameCount; frame += 1) {
const byteOffset = (frame - 1) * frameBytes;
frames.push({ frame, byteOffset, byteLength: frameBytes, sha256: await digest(payload.buffer.slice(byteOffset, byteOffset + frameBytes)) });
}
return {
payload: payload.buffer,
manifest: {
schemaVersion: 2,
...binding,
revisionHash: await computeSimulationCacheRevisionHash(binding),
cacheSha256: await digest(payload.buffer),
byteLength: payload.byteLength,
frames,
},
};
}
async function simulationError(operation: () => unknown | Promise<unknown>): Promise<string> {
try { await operation(); return ""; }
catch (error) { return String((error as Error & { code?: string }).code ?? ""); }
}
async function runSimulation(): Promise<GateResult> {
const fixture = await simulationFixture(64, 16);
const workUnits = 64;
const started = performance.now();
await verifySimulationCache(fixture.manifest, fixture.payload);
const performanceMs = Math.round(performance.now() - started);
const oomCode = await simulationError(() => parseSimulationCacheManifest({
...fixture.manifest,
byteLength: SIMULATION_CACHE_BUDGET.maxCacheBytes + 1,
}));
const maliciousCode = await simulationError(() => parseSimulationCacheManifest({
...fixture.manifest,
proxySuccess: true,
}));
const recoveredFrame = await verifySimulationCacheFrame(
fixture.manifest,
1,
fixture.payload.slice(0, 16),
);
return {
domain: "SIMULATION",
performanceMs,
workUnits,
oomCode,
maliciousCode,
recovered: recoveredFrame.frame === 1,
performanceStatus: "VERIFIED",
oomPreventedBeforeAllocation: oomCode === "SIMULATION_CACHE_BUDGET_EXCEEDED",
};
}
self.onmessage = async (event: MessageEvent<{ domain?: Domain }>) => {
try {
const result = event.data.domain === "GN" ? runGeometryNodes()
: event.data.domain === "SHADER" ? runShader()
: event.data.domain === "NLA" ? runNla()
: event.data.domain === "SIMULATION" ? await runSimulation()
: undefined;
if (!result) throw new Error("Unknown M10 gate domain");
self.postMessage({ ok: true, result });
}
catch (error) {
self.postMessage({ ok: false, error: error instanceof Error ? error.message : String(error) });
}
};

View File

@@ -0,0 +1,83 @@
import { validateNanoVDBBundleManifest } from "../../../protocol/volume-vdb";
import { NanoVDBFloat32Sampler } from "../volume/nanovdb-float32";
import {
createNanoVDBFloat32GridPaged,
createNanoVDBPageFeedbackGPUBuffer,
NanoVDBProgressivePageUploader,
NanoVDBProgressiveRedrawScheduler,
NanoVDBWebGPUDeviceSession,
readNanoVDBPageFeedbackGPUBuffer,
renderNanoVDBFloat32WebGPU,
sampleNanoVDBFloat32WebGPU,
} from "../render/nanovdb-volume-renderer";
const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void };
async function digest(pixels: Uint8Array): Promise<string> {
const bytes = new Uint8Array(pixels.byteLength);
bytes.set(pixels);
return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes.buffer))).map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
scope.onmessage = (): void => {
void (async () => {
const manifest = validateNanoVDBBundleManifest(await (await fetch("/__vdb_fixture__/manifest", { cache: "no-store" })).json());
const density = manifest.grids.find((grid) => grid.name === manifest.material.densityGrid);
if (!density || !manifest.gpu.float32TreeLayout) throw new Error("NANOVDB_MANIFEST_INVALID: offscreen fixture is incomplete");
const response = await fetch("/__vdb_fixture__/bundle", {
cache: "no-store",
headers: { Range: `bytes=${density.byteOffset}-${density.byteOffset + density.byteLength - 1}` },
});
if (response.status !== 206) throw new Error("NANOVDB_STREAM_INCOMPLETE: offscreen fixture range was not served");
const payload = await response.arrayBuffer();
const sampler = new NanoVDBFloat32Sampler(payload, density, manifest.gpu.float32TreeLayout);
const coordinate = [0, 0, 0] as const;
const leafByteOffset = sampler.leafByteOffset(coordinate);
if (leafByteOffset === null) throw new Error("NANOVDB_GRID_UNSUPPORTED: offscreen fixture has no leaf");
const pageByteLength = 256 * 1024;
const pageCount = Math.ceil(payload.byteLength / pageByteLength);
const leafPageId = Math.floor(leafByteOffset / pageByteLength);
if (leafPageId <= 0 || leafPageId >= pageCount) throw new Error("NANOVDB_GRID_UNSUPPORTED: offscreen leaf page is not pageable");
const session = new NanoVDBWebGPUDeviceSession();
const device = await session.open(pageCount * pageByteLength, 6);
const grid = createNanoVDBFloat32GridPaged(device, payload.byteLength, pageByteLength, pageCount * pageByteLength);
for (let pageId = 0; pageId < pageCount; pageId++) {
if (pageId === leafPageId) continue;
grid.uploadPage(pageId, payload.slice(pageId * pageByteLength, Math.min(payload.byteLength, (pageId + 1) * pageByteLength)));
}
const feedback = createNanoVDBPageFeedbackGPUBuffer(device, 4);
const beforeLoad = await sampleNanoVDBFloat32WebGPU(device, grid, [coordinate], feedback);
const missing = await readNanoVDBPageFeedbackGPUBuffer(device, feedback, 4, pageCount, 17);
const start = density.byteOffset + missing.pageIds[0] * pageByteLength;
const end = Math.min(density.byteOffset + density.byteLength, start + pageByteLength) - 1;
const pageResponse = await fetch("/__vdb_fixture__/bundle", { cache: "no-store", headers: { Range: `bytes=${start}-${end}` } });
if (pageResponse.status !== 206) throw new Error(`NANOVDB_STREAM_INCOMPLETE: offscreen page range returned ${pageResponse.status}`);
const frames: Array<() => void> = [];
let redraws = 0;
const scheduler = new NanoVDBProgressiveRedrawScheduler((callback) => frames.push(callback), () => { redraws++; });
const uploader = new NanoVDBProgressivePageUploader(grid, scheduler);
const upload = uploader.upload(missing.pageIds[0], await pageResponse.arrayBuffer());
const queuedBeforeRedraw = frames.length;
frames.shift()?.();
const afterLoad = await sampleNanoVDBFloat32WebGPU(device, grid, [coordinate]);
const pixelsA = await renderNanoVDBFloat32WebGPU(device, grid, density, { ...manifest.material, interpolation: "LINEAR" }, 64, 64);
const pixelsB = await renderNanoVDBFloat32WebGPU(device, grid, density, { ...manifest.material, interpolation: "LINEAR" }, 64, 64);
const result = {
pageCount,
leafPageId,
beforeLoad,
missing,
upload,
queuedBeforeRedraw,
redraws,
afterLoad,
pixels: { bytes: pixelsA.byteLength, sha256A: await digest(pixelsA), sha256B: await digest(pixelsB) },
scheduler: scheduler.stats(),
};
scheduler.dispose();
grid.dispose();
feedback.destroy();
session.dispose();
scope.postMessage(result);
})().catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) }));
};

View File

@@ -0,0 +1,126 @@
import { validateNanoVDBBundleManifest, type NanoVDBBundleManifestIR } from "../../../protocol/volume-vdb";
import { createNanoVDBFloat32GridPaged, NanoVDBWebGPUDeviceSession } from "../render/nanovdb-volume-renderer";
import { commitNanoVDBToOPFS, openNanoVDBFromOPFS, pruneNanoVDBOPFS } from "../volume/nanovdb-opfs";
import { loadNanoVDBGridPage } from "../volume/nanovdb-viewport";
interface RestartState {
projectId: string;
bundleSha256: string;
claimedResidentPages: number[];
}
const scope = self as unknown as { onmessage: ((event: MessageEvent<{ action: "prepare" | "reopen"; state?: RestartState }>) => void) | null; postMessage: (value: unknown) => void };
const projectId = "m8-14-worker-restart";
function buffer(seed: number): ArrayBuffer {
const bytes = Uint8Array.from({ length: 128 * 1024 }, (_value, index) => (index * 29 + seed) & 0xff);
return bytes.buffer;
}
async function digest(value: ArrayBuffer): Promise<string> {
const hash = await crypto.subtle.digest("SHA-256", value.slice(0));
return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
async function manifestFor(data: ArrayBuffer): Promise<NanoVDBBundleManifestIR> {
const bytes = new Uint8Array(data);
const first = bytes.slice(0, 64 * 1024).buffer;
const second = bytes.slice(64 * 1024).buffer;
return validateNanoVDBBundleManifest({
schemaVersion: 1,
projectId,
sourcePath: "//volumes/m8-14.vdb",
sourceSha256: "a".repeat(64),
conversionRequestSha256: "b".repeat(64),
bundlePath: "//volumes/m8-14.nvdb",
bundleByteLength: data.byteLength,
bundleSha256: await digest(data),
converter: { target: "SERVER", blenderVersion: "5.2.0", openVDBVersion: "13.0.0", nanoVDBVersion: "32.9.0", executableSha256: "c".repeat(64) },
grids: [{
name: "density", valueType: "FLOAT32", gridClass: "FOG_VOLUME", semantic: "DENSITY", activeVoxelCount: 0,
segmentByteOffset: 0, segmentByteLength: data.byteLength, byteOffset: 0, byteLength: data.byteLength,
indexBounds: { min: [0, 0, 0], max: [1, 1, 1] }, worldBounds: { min: [0, 0, 0], max: [1, 1, 1] }, voxelSize: [1, 1, 1],
indexToWorld: [1, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1],
}],
chunks: [
{ index: 0, byteOffset: 0, byteLength: first.byteLength, sha256: await digest(first) },
{ index: 1, byteOffset: first.byteLength, byteLength: second.byteLength, sha256: await digest(second) },
],
material: { densityGrid: "density", densityScale: 1, emissionScale: 0, temperatureScale: 1, anisotropy: 0, interpolation: "LINEAR" },
gpu: { representation: "NANOVDB_STORAGE_BUFFER", byteAlignment: 32, pageByteLength: 64 * 1024, maxResidentBytes: 128 * 1024, shaderSemanticVersion: "volume-wgsl-v1" },
});
}
async function prepare(): Promise<unknown> {
await pruneNanoVDBOPFS(projectId, 0);
const data = buffer(19);
const manifest = await manifestFor(data);
await commitNanoVDBToOPFS(manifest, async (range) => data.slice(range.start, range.endExclusive), new AbortController().signal);
const session = new NanoVDBWebGPUDeviceSession();
const device = await session.open(manifest.gpu.maxResidentBytes, 5);
const grid = createNanoVDBFloat32GridPaged(device, manifest.grids[0].byteLength, manifest.gpu.pageByteLength, manifest.gpu.maxResidentBytes);
grid.uploadPage(0, data.slice(0, 64 * 1024));
const residentBeforeRestart = [...grid.residentVirtualPages];
grid.dispose();
session.dispose();
return { projectId, bundleSha256: manifest.bundleSha256, residentBeforeRestart, claimedResidentPages: residentBeforeRestart };
}
async function overwriteFirstChunk(state: RestartState, data: ArrayBuffer): Promise<void> {
let directory = await navigator.storage.getDirectory();
for (const name of ["projects", state.projectId, "cache", "vdb", state.bundleSha256]) {
directory = await directory.getDirectoryHandle(name);
}
const writer = await (await directory.getFileHandle("00000.chunk")).createWritable();
await writer.write(data);
await writer.close();
}
async function reopen(state: RestartState): Promise<unknown> {
const opened = await openNanoVDBFromOPFS(state.projectId, state.bundleSha256);
const session = new NanoVDBWebGPUDeviceSession();
const device = await session.open(opened.manifest.gpu.maxResidentBytes, 5);
const grid = createNanoVDBFloat32GridPaged(device, opened.manifest.grids[0].byteLength, opened.manifest.gpu.pageByteLength, opened.manifest.gpu.maxResidentBytes);
const residentBeforeRestore = [...grid.residentVirtualPages];
const verificationGrid = createNanoVDBFloat32GridPaged(device, opened.manifest.grids[0].byteLength, opened.manifest.gpu.pageByteLength, opened.manifest.gpu.maxResidentBytes);
try {
const page = await loadNanoVDBGridPage(opened.manifest, "density", 0, opened.source, new AbortController().signal);
grid.uploadPage(0, page);
const residentAfterRestore = [...grid.residentVirtualPages];
const originalChunk = page.slice(0);
const tamperedChunk = new Uint8Array(originalChunk.slice(0));
tamperedChunk[0] ^= 0xff;
await overwriteFirstChunk(state, tamperedChunk.buffer);
let tamperedError = "";
try {
const unverified = await loadNanoVDBGridPage(opened.manifest, "density", 0, opened.source, new AbortController().signal);
verificationGrid.uploadPage(0, unverified);
}
catch (error) {
tamperedError = error instanceof Error ? error.message : String(error);
}
await overwriteFirstChunk(state, originalChunk);
return {
manifestSha256: opened.manifest.bundleSha256,
claimedResidentPages: state.claimedResidentPages,
residentBeforeRestore,
residentAfterRestore,
pageBytes: page.byteLength,
tamperedError,
residentAfterTamperedRestore: [...verificationGrid.residentVirtualPages],
};
}
finally {
grid.dispose();
verificationGrid.dispose();
session.dispose();
await pruneNanoVDBOPFS(state.projectId, 0);
}
}
scope.onmessage = (event): void => {
void (event.data.action === "prepare" ? prepare() : reopen(event.data.state!))
.then((result) => scope.postMessage(result))
.catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) }));
};

View File

@@ -0,0 +1,450 @@
import {
createNanoVDBPageFeedbackBuffer,
dispatchNanoVDBPageFeedbackBatch,
} from "../../../protocol/nanovdb-page-feedback";
import { validateNanoVDBBundleManifest, type NanoVDBBundleManifestIR } from "../../../protocol/volume-vdb";
import {
createNanoVDBFloat32GridPaged,
NanoVDBProgressivePageUploader,
NanoVDBProgressiveRedrawScheduler,
NanoVDBWebGPUDeviceSession,
readNanoVDBPageFeedbackGPUBuffer,
readNanoVDBWordsWebGPU,
sampleNanoVDBFloat32WebGPU,
} from "../render/nanovdb-volume-renderer";
import { NanoVDBFloat32Sampler } from "../volume/nanovdb-float32";
import { createResumableHttpNanoVDBRangeSource, type NanoVDBRangeSource } from "../volume/nanovdb-stream";
import { loadNanoVDBFeedbackPages, NanoVDBGridPageRequestCoordinator } from "../volume/nanovdb-viewport";
const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void };
const FEEDBACK_STRESS_PAGE_BYTES = 256 * 1024;
const GUARD_WORDS = [0x13579bdf, 0x2468ace0] as const;
function createGuardedFeedback(device: GPUDevice, capacity: number): { buffer: GPUBuffer; logicalBytes: number; totalBytes: number } {
const initial = new Uint32Array(createNanoVDBPageFeedbackBuffer(capacity));
const guarded = new Uint32Array(initial.length + GUARD_WORDS.length);
guarded.set(initial);
guarded.set(GUARD_WORDS, initial.length);
const buffer = device.createBuffer({
label: `NanoVDB guarded page feedback ${capacity}`,
size: guarded.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
});
device.queue.writeBuffer(buffer, 0, guarded);
return { buffer, logicalBytes: initial.byteLength, totalBytes: guarded.byteLength };
}
async function readWords(device: GPUDevice, buffer: GPUBuffer, byteLength: number): Promise<Uint32Array> {
const readback = device.createBuffer({ size: byteLength, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
const encoder = device.createCommandEncoder();
encoder.copyBufferToBuffer(buffer, 0, readback, 0, byteLength);
device.queue.submit([encoder.finish()]);
await readback.mapAsync(GPUMapMode.READ);
const words = new Uint32Array(readback.getMappedRange().slice(0));
readback.unmap();
readback.destroy();
return words;
}
async function waitFor(condition: () => boolean, label: string): Promise<void> {
for (let attempt = 0; attempt < 100; attempt++) {
if (condition()) return;
await new Promise((resolve) => setTimeout(resolve, 0));
}
throw new Error(`NanoVDB page request test timed out waiting for ${label}`);
}
function outcome<T>(promise: Promise<T>): Promise<{ status: "RESOLVED"; value: T } | { status: "REJECTED"; name: string }> {
return promise.then(
(value) => ({ status: "RESOLVED" as const, value }),
(error: unknown) => ({ status: "REJECTED" as const, name: error instanceof DOMException ? error.name : "Error" }),
);
}
scope.onmessage = (): void => {
void (async () => {
const manifest = validateNanoVDBBundleManifest(
await (await fetch("/__vdb_fixture__/manifest", { cache: "no-store" })).json() as NanoVDBBundleManifestIR,
);
const density = manifest.grids.find((grid) => grid.name === manifest.material.densityGrid);
if (!density || !manifest.gpu.float32TreeLayout) throw new Error("NanoVDB page feedback fixture is incomplete");
const response = await fetch("/__vdb_fixture__/bundle", {
cache: "no-store",
headers: { Range: `bytes=${density.byteOffset}-${density.byteOffset + density.byteLength - 1}` },
});
if (response.status !== 206) throw new Error("NanoVDB page feedback fixture range was not served");
const payload = await response.arrayBuffer();
const sampler = new NanoVDBFloat32Sampler(payload, density, manifest.gpu.float32TreeLayout);
const activeCoordinate = [0, 0, 0] as const;
const leafByteOffset = sampler.leafByteOffset(activeCoordinate);
if (leafByteOffset === null) throw new Error("NanoVDB page feedback fixture does not reach a leaf");
const manifestPageBytes = manifest.gpu.pageByteLength;
const manifestPageCount = Math.ceil(payload.byteLength / manifestPageBytes);
const stressPageCount = Math.ceil(payload.byteLength / FEEDBACK_STRESS_PAGE_BYTES);
const leafPageId = Math.floor(leafByteOffset / FEEDBACK_STRESS_PAGE_BYTES);
if (stressPageCount < 4 || leafPageId < 1 || leafPageId >= stressPageCount || manifestPageCount < 1) throw new Error("NanoVDB page feedback fixture page layout is insufficient");
const session = new NanoVDBWebGPUDeviceSession();
const device = await session.open(Math.max(manifestPageBytes, payload.byteLength + FEEDBACK_STRESS_PAGE_BYTES), 6);
const leafGrid = createNanoVDBFloat32GridPaged(device, payload.byteLength, FEEDBACK_STRESS_PAGE_BYTES, (stressPageCount - 1) * FEEDBACK_STRESS_PAGE_BYTES);
for (let pageId = 0; pageId < stressPageCount; pageId++) {
if (pageId === leafPageId) continue;
leafGrid.uploadPage(pageId, payload.slice(pageId * FEEDBACK_STRESS_PAGE_BYTES, Math.min(payload.byteLength, (pageId + 1) * FEEDBACK_STRESS_PAGE_BYTES)));
}
const leafFeedback = createGuardedFeedback(device, 4);
const leafSamples = await sampleNanoVDBFloat32WebGPU(
device,
leafGrid,
Array.from({ length: 256 }, () => activeCoordinate),
leafFeedback.buffer,
);
const leafResult = await readNanoVDBPageFeedbackGPUBuffer(device, leafFeedback.buffer, 4, stressPageCount, 17);
const leafWords = await readWords(device, leafFeedback.buffer, leafFeedback.totalBytes);
const leafGuard = [...leafWords.slice(leafFeedback.logicalBytes / 4)];
const pageIoRequests: Array<{ pageId: number; renderRevision: number }> = [];
const staleDispatch = await dispatchNanoVDBPageFeedbackBatch(leafResult, 18, async (pageId, renderRevision) => {
pageIoRequests.push({ pageId, renderRevision });
});
const stalePageIoCount = pageIoRequests.length;
const currentDispatch = await dispatchNanoVDBPageFeedbackBatch(leafResult, 17, async (pageId, renderRevision) => {
pageIoRequests.push({ pageId, renderRevision });
});
const manifestGrid = createNanoVDBFloat32GridPaged(device, payload.byteLength, manifestPageBytes, manifestPageBytes);
const manifestFeedback = createGuardedFeedback(device, 4);
const manifestWords = await readNanoVDBWordsWebGPU(device, manifestGrid, [leafByteOffset], manifestFeedback.buffer);
const manifestFeedbackResult = await readNanoVDBPageFeedbackGPUBuffer(device, manifestFeedback.buffer, 4, manifestPageCount, 19);
const manifestRanges: Parameters<NanoVDBRangeSource>[0][] = [];
const httpPageSource = createResumableHttpNanoVDBRangeSource("/__vdb_fixture__/bundle", manifest.bundleByteLength, {
retries: 0,
requireStableEtag: true,
});
const pageSource: NanoVDBRangeSource = async (range, signal) => {
manifestRanges.push({ ...range });
return httpPageSource(range, signal);
};
const pageRequests = new NanoVDBGridPageRequestCoordinator(manifest, density.name, pageSource);
const manifestPageIoRequests: Array<{ pageId: number; renderRevision: number }> = [];
const manifestStaleDispatch = await loadNanoVDBFeedbackPages(
manifestFeedbackResult,
20,
pageRequests,
new AbortController().signal,
async ({ pageId, renderRevision }) => { manifestPageIoRequests.push({ pageId, renderRevision }); },
);
const staleManifestRangeCount = manifestRanges.length;
let manifestPageMatchesPayload = false;
const manifestDispatch = await loadNanoVDBFeedbackPages(
manifestFeedbackResult,
19,
pageRequests,
new AbortController().signal,
async ({ pageId, renderRevision, data }) => {
manifestPageIoRequests.push({ pageId, renderRevision });
const expected = new Uint8Array(payload, pageId * manifestPageBytes, data.byteLength);
const loaded = new Uint8Array(data);
manifestPageMatchesPayload = loaded.length === expected.length && loaded.every((byte, index) => byte === expected[index]);
},
);
pageRequests.dispose();
const declaredManifestRanges = manifestRanges.map((range) => {
const chunk = manifest.chunks[range.chunkIndex];
return {
chunkIndex: chunk.index,
start: chunk.byteOffset,
endExclusive: chunk.byteOffset + chunk.byteLength,
sha256: chunk.sha256,
};
});
let coalescedRangeCalls = 0;
let coalescedUnderlyingAborts = 0;
let releaseCoalescedRange: (() => void) | undefined;
const coalescedSource: NanoVDBRangeSource = async (range, signal) => {
coalescedRangeCalls++;
await new Promise<void>((resolve, reject) => {
const onAbort = (): void => {
coalescedUnderlyingAborts++;
reject(new DOMException("coalesced range cancelled", "AbortError"));
};
signal.addEventListener("abort", onAbort, { once: true });
releaseCoalescedRange = () => {
signal.removeEventListener("abort", onAbort);
resolve();
};
});
return httpPageSource(range, signal);
};
const coalescedRequests = new NanoVDBGridPageRequestCoordinator(manifest, density.name, coalescedSource);
const firstSubscriber = new AbortController();
const secondSubscriber = new AbortController();
const coalescedConsumers: string[] = [];
const firstCoalesced = outcome(loadNanoVDBFeedbackPages(
manifestFeedbackResult,
19,
coalescedRequests,
firstSubscriber.signal,
async ({ pageId }) => { coalescedConsumers.push(`first:${pageId}`); },
));
const secondCoalesced = outcome(loadNanoVDBFeedbackPages(
manifestFeedbackResult,
19,
coalescedRequests,
secondSubscriber.signal,
async ({ pageId }) => { coalescedConsumers.push(`second:${pageId}`); },
));
await waitFor(() => coalescedRangeCalls === 1 && coalescedRequests.stats().subscribers === 2, "two coalesced subscribers");
const coalescedBeforeCancel = coalescedRequests.stats();
firstSubscriber.abort();
const coalescedAfterFirstCancel = coalescedRequests.stats();
releaseCoalescedRange?.();
const coalescedOutcomes = await Promise.all([firstCoalesced, secondCoalesced]);
const coalescedAfterResolve = coalescedRequests.stats();
coalescedRequests.dispose();
let lastCancelRangeCalls = 0;
let lastCancelUnderlyingAborts = 0;
const lastCancelSource: NanoVDBRangeSource = async (_range, signal) => {
lastCancelRangeCalls++;
return new Promise<ArrayBuffer>((_resolve, reject) => {
signal.addEventListener("abort", () => {
lastCancelUnderlyingAborts++;
reject(new DOMException("last subscriber cancelled range", "AbortError"));
}, { once: true });
});
};
const lastCancelRequests = new NanoVDBGridPageRequestCoordinator(manifest, density.name, lastCancelSource);
const thirdSubscriber = new AbortController();
const fourthSubscriber = new AbortController();
const thirdCancelled = outcome(lastCancelRequests.request(0, thirdSubscriber.signal));
const fourthCancelled = outcome(lastCancelRequests.request(0, fourthSubscriber.signal));
await waitFor(() => lastCancelRangeCalls === 1 && lastCancelRequests.stats().subscribers === 2, "last-cancel subscribers");
thirdSubscriber.abort();
const lastCancelAfterFirst = {
stats: lastCancelRequests.stats(),
underlyingAborts: lastCancelUnderlyingAborts,
};
fourthSubscriber.abort();
const lastCancelOutcomes = await Promise.all([thirdCancelled, fourthCancelled]);
await waitFor(() => lastCancelUnderlyingAborts === 1, "underlying range abort");
const lastCancelAfterLast = lastCancelRequests.stats();
lastCancelRequests.dispose();
let tamperedRangeCalls = 0;
let tamperedResidentWrites = 0;
const tamperedSource: NanoVDBRangeSource = async (range, signal) => {
tamperedRangeCalls++;
const data = new Uint8Array(await httpPageSource(range, signal));
data[0] ^= 0xff;
return data.buffer;
};
const tamperedRequests = new NanoVDBGridPageRequestCoordinator(manifest, density.name, tamperedSource);
const tamperedResidentGrid = createNanoVDBFloat32GridPaged(device, payload.byteLength, manifestPageBytes, manifestPageBytes);
let tamperedErrorCode = "";
let tamperedErrorMessage = "";
try {
await loadNanoVDBFeedbackPages(
manifestFeedbackResult,
19,
tamperedRequests,
new AbortController().signal,
async ({ pageId, data }) => {
tamperedResidentWrites++;
tamperedResidentGrid.uploadPage(pageId, data);
},
);
}
catch (error) {
tamperedErrorCode = error && typeof error === "object" && "code" in error ? String(error.code) : "";
tamperedErrorMessage = error instanceof Error ? error.message : String(error);
}
const tamperedCache = {
residentPageCount: tamperedResidentGrid.residentPageCount,
residentBytes: tamperedResidentGrid.residentBytes,
residentVirtualPages: [...tamperedResidentGrid.residentVirtualPages],
coordinator: tamperedRequests.stats(),
};
tamperedResidentGrid.dispose();
tamperedRequests.dispose();
const pinPageBytes = 64 * 1024;
const pinGrid = createNanoVDBFloat32GridPaged(device, 4 * pinPageBytes, pinPageBytes, 2 * pinPageBytes);
const pinPage = new ArrayBuffer(pinPageBytes);
pinGrid.uploadPage(0, pinPage.slice(0));
pinGrid.uploadPage(1, pinPage.slice(0));
pinGrid.beginFrame();
const pinnedPage0 = pinGrid.pinPage(0);
const pinnedMissingPage = pinGrid.pinPage(3);
pinGrid.uploadPage(2, pinPage.slice(0));
const afterPinnedEviction = {
residentVirtualPages: [...pinGrid.residentVirtualPages],
evictionCount: pinGrid.evictionCount,
page0: pinGrid.hasResidentPage(0),
page1: pinGrid.hasResidentPage(1),
page2: pinGrid.hasResidentPage(2),
};
pinGrid.beginFrame();
pinGrid.pinPage(0);
pinGrid.pinPage(2);
let allPinnedError = "";
try { pinGrid.uploadPage(3, pinPage.slice(0)); }
catch (error) { allPinnedError = error instanceof Error ? error.message : String(error); }
const afterAllPinned = {
residentVirtualPages: [...pinGrid.residentVirtualPages],
evictionCount: pinGrid.evictionCount,
page3: pinGrid.hasResidentPage(3),
};
pinGrid.endFrame();
pinGrid.beginFrame();
pinGrid.pinPage(2);
pinGrid.uploadPage(3, pinPage.slice(0));
pinGrid.endFrame();
const afterNextFrame = {
residentVirtualPages: [...pinGrid.residentVirtualPages],
evictionCount: pinGrid.evictionCount,
page0: pinGrid.hasResidentPage(0),
page2: pinGrid.hasResidentPage(2),
page3: pinGrid.hasResidentPage(3),
};
const pinPageTable = [...await readWords(device, pinGrid.pageTable, 4 * Uint32Array.BYTES_PER_ELEMENT)];
pinGrid.dispose();
const redrawGrid = createNanoVDBFloat32GridPaged(device, 3 * pinPageBytes, pinPageBytes, 3 * pinPageBytes);
const redrawFrames: Array<() => void> = [];
let redrawCallbacks = 0;
const redrawScheduler = new NanoVDBProgressiveRedrawScheduler(
(callback) => { redrawFrames.push(callback); },
() => { redrawCallbacks++; },
);
const redrawUploader = new NanoVDBProgressivePageUploader(redrawGrid, redrawScheduler);
const firstRedrawUpload = redrawUploader.upload(0, pinPage.slice(0));
const secondRedrawUpload = redrawUploader.upload(1, pinPage.slice(0));
let failedRedrawUpload = "";
try { redrawUploader.upload(3, pinPage.slice(0)); }
catch (error) { failedRedrawUpload = error instanceof Error ? error.message : String(error); }
const beforeFirstRedraw = { queuedFrames: redrawFrames.length, callbacks: redrawCallbacks, stats: redrawScheduler.stats() };
redrawFrames.shift()?.();
const afterFirstRedraw = { queuedFrames: redrawFrames.length, callbacks: redrawCallbacks, stats: redrawScheduler.stats() };
const thirdRedrawUpload = redrawUploader.upload(2, pinPage.slice(0));
const beforeSecondRedraw = { queuedFrames: redrawFrames.length, callbacks: redrawCallbacks, stats: redrawScheduler.stats() };
redrawFrames.shift()?.();
const afterSecondRedraw = { queuedFrames: redrawFrames.length, callbacks: redrawCallbacks, stats: redrawScheduler.stats() };
redrawScheduler.dispose();
redrawGrid.dispose();
// A faulty producer may keep reporting an uploadable page forever. The render epoch
// must stop scheduling after its bounded redraw budget instead of spinning indefinitely.
const cappedGrid = createNanoVDBFloat32GridPaged(device, 3 * pinPageBytes, pinPageBytes, 3 * pinPageBytes);
const cappedFrames: Array<() => void> = [];
let cappedCallbacks = 0;
const cappedScheduler = new NanoVDBProgressiveRedrawScheduler(
(callback) => { cappedFrames.push(callback); },
() => { cappedCallbacks++; },
{ maxRedraws: 2 },
);
const cappedUploader = new NanoVDBProgressivePageUploader(cappedGrid, cappedScheduler);
const cappedUploads: Array<{ pageId: number; redrawScheduled: boolean }> = [];
for (let attempt = 0; attempt < 4; attempt++) {
cappedUploads.push(cappedUploader.upload(0, pinPage.slice(0)));
cappedFrames.shift()?.();
}
const cappedRedraw = { uploads: cappedUploads, callbacks: cappedCallbacks, queuedFrames: cappedFrames.length, stats: cappedScheduler.stats() };
cappedScheduler.beginRender();
const afterCappedReset = cappedScheduler.stats();
cappedScheduler.dispose();
cappedGrid.dispose();
const missingGrid = createNanoVDBFloat32GridPaged(device, payload.byteLength, FEEDBACK_STRESS_PAGE_BYTES, FEEDBACK_STRESS_PAGE_BYTES);
const missingPageIds = [1, 2, 3];
const overflowFeedback = createGuardedFeedback(device, 2);
const missingOffsets = Array.from({ length: 384 }, (_value, index) => missingPageIds[index % missingPageIds.length] * FEEDBACK_STRESS_PAGE_BYTES);
const missingWords = await readNanoVDBWordsWebGPU(device, missingGrid, missingOffsets, overflowFeedback.buffer);
const overflowResult = await readNanoVDBPageFeedbackGPUBuffer(device, overflowFeedback.buffer, 2, stressPageCount, 18);
const overflowWords = await readWords(device, overflowFeedback.buffer, overflowFeedback.totalBytes);
const overflowGuard = [...overflowWords.slice(overflowFeedback.logicalBytes / 4)];
leafGrid.dispose();
manifestGrid.dispose();
missingGrid.dispose();
leafFeedback.buffer.destroy();
manifestFeedback.buffer.destroy();
overflowFeedback.buffer.destroy();
session.dispose();
return {
pageCount: stressPageCount,
manifestPageCount,
manifestPageBytes,
leafByteOffset,
leafPageId,
leafResult,
leafGuard,
staleDispatch,
stalePageIoCount,
currentDispatch,
pageIoRequests,
manifestFeedbackResult,
manifestWordsZero: manifestWords.every((word) => word === 0),
manifestStaleDispatch,
staleManifestRangeCount,
manifestDispatch,
manifestPageIoRequests,
manifestRanges,
declaredManifestRanges,
manifestPageMatchesPayload,
coalesced: {
rangeCalls: coalescedRangeCalls,
underlyingAborts: coalescedUnderlyingAborts,
beforeCancel: coalescedBeforeCancel,
afterFirstCancel: coalescedAfterFirstCancel,
afterResolve: coalescedAfterResolve,
outcomes: coalescedOutcomes,
consumers: coalescedConsumers,
},
lastCancel: {
rangeCalls: lastCancelRangeCalls,
underlyingAborts: lastCancelUnderlyingAborts,
afterFirst: lastCancelAfterFirst,
afterLast: lastCancelAfterLast,
outcomes: lastCancelOutcomes,
},
tamperedPage: {
rangeCalls: tamperedRangeCalls,
residentWrites: tamperedResidentWrites,
errorCode: tamperedErrorCode,
errorMessage: tamperedErrorMessage,
cache: tamperedCache,
},
framePin: {
pinnedPage0,
pinnedMissingPage,
afterPinnedEviction,
allPinnedError,
afterAllPinned,
afterNextFrame,
pageTable: pinPageTable,
},
progressiveRedraw: {
firstUpload: firstRedrawUpload,
secondUpload: secondRedrawUpload,
failedUpload: failedRedrawUpload,
beforeFirst: beforeFirstRedraw,
afterFirst: afterFirstRedraw,
thirdUpload: thirdRedrawUpload,
beforeSecond: beforeSecondRedraw,
afterSecond: afterSecondRedraw,
},
progressiveRedrawCap: { cappedRedraw, afterCappedReset },
leafSamplesFallback: leafSamples.every((sample) => sample.valid && !sample.active && sample.value === 0),
leafSampleCount: leafSamples.length,
overflowResult,
overflowGuard,
missingPageIds,
missingWordsZero: missingWords.every((word) => word === 0),
guardWords: [...GUARD_WORDS],
};
})().then((result) => scope.postMessage(result)).catch((error) => {
scope.postMessage({ error: error instanceof Error ? error.message : String(error) });
});
};

View File

@@ -0,0 +1,79 @@
import { validateNanoVDBBundleManifest, type NanoVDBBundleManifestIR } from "../../../protocol/volume-vdb";
import type { NanoVDBPageFeedbackBatch } from "../../../protocol/nanovdb-page-feedback";
import { loadNanoVDBFeedbackPages, NanoVDBGridPageRequestCoordinator } from "../volume/nanovdb-viewport";
import { createResumableHttpNanoVDBRangeSource } from "../volume/nanovdb-stream";
const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void };
async function sha256(bytes: ArrayBuffer): Promise<string> {
const copy = bytes.slice(0);
return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", copy))).map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
scope.onmessage = (): void => {
void (async () => {
const manifest = validateNanoVDBBundleManifest(await (await fetch("/__vdb_fixture__/manifest", { cache: "no-store" })).json() as NanoVDBBundleManifestIR);
const density = manifest.grids.find((grid) => grid.name === manifest.material.densityGrid);
if (!density) throw new Error("NANOVDB_MANIFEST_INVALID: resume fixture has no density grid");
const pageByteLength = 256 * 1024;
const pagingManifest = validateNanoVDBBundleManifest({ ...manifest, gpu: { ...manifest.gpu, pageByteLength } });
const pageId = 0;
const pageStart = density.byteOffset + pageId * pageByteLength;
const pageEnd = Math.min(density.byteOffset + density.byteLength, pageStart + pageByteLength);
let interruptBody = true;
const ranges: string[] = [];
const ifRanges: string[] = [];
const interruptedFetcher: typeof fetch = async (input, init) => {
const headers = new Headers(init?.headers);
ranges.push(headers.get("Range") ?? "");
ifRanges.push(headers.get("If-Range") ?? "");
const response = await fetch(input, init);
if (!interruptBody) return response;
interruptBody = false;
const bytes = new Uint8Array(await response.arrayBuffer());
const partial = bytes.slice(0, 4096);
let emitted = false;
const body = new ReadableStream<Uint8Array>({
pull(controller) {
if (!emitted) { emitted = true; controller.enqueue(partial); return; }
controller.error(new TypeError("injected page response-body interruption"));
},
});
return new Response(body, { status: response.status, headers: response.headers });
};
const source = createResumableHttpNanoVDBRangeSource("/__vdb_fixture__/bundle", manifest.bundleByteLength, {
fetcher: interruptedFetcher,
retries: 2,
retryDelayMs: 0,
requireStableEtag: true,
});
const coordinator = new NanoVDBGridPageRequestCoordinator(pagingManifest, density.name, source);
const batch: NanoVDBPageFeedbackBatch = {
schemaVersion: 1,
renderRevision: 17,
attemptedCount: 1,
gpuStoredCount: 1,
uniqueCount: 1,
pageIds: [pageId],
status: "READY",
errorCode: null,
};
let consumed: ArrayBuffer | undefined;
const dispatch = await loadNanoVDBFeedbackPages(batch, 17, coordinator, new AbortController().signal, ({ data }) => { consumed = data; });
const direct = await (await fetch("/__vdb_fixture__/bundle", { cache: "no-store", headers: { Range: `bytes=${pageStart}-${pageEnd - 1}` } })).arrayBuffer();
const result = {
dispatch,
ranges,
ifRanges,
expectedRange: `bytes=${pageStart}-${pageEnd - 1}`,
resumedRangeStart: Number(ranges[1]?.match(/^bytes=(\d+)-/)?.[1] ?? -1),
consumedBytes: consumed?.byteLength ?? 0,
expectedBytes: direct.byteLength,
consumedSha256: consumed ? await sha256(consumed) : "",
expectedSha256: await sha256(direct),
coordinator: coordinator.stats(),
};
coordinator.dispose();
scope.postMessage(result);
})().catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) }));
};

View File

@@ -0,0 +1,55 @@
import {
NanoVDBWebGPUDeviceSession,
renderNanoVDBFloat32WebGPU,
uploadNanoVDBFloat32Grid,
type NanoVDBViewAxis,
} from "../render/nanovdb-volume-renderer";
import { loadNanoVDBViewportAsset } from "../volume/nanovdb-viewport";
const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown, transfer?: Transferable[]) => void };
async function sha256(value: Uint8Array): Promise<string> {
const owned = new ArrayBuffer(value.byteLength);
new Uint8Array(owned).set(value);
return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", owned))).map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
scope.onmessage = (): void => {
void (async () => {
const asset = await loadNanoVDBViewportAsset(
"m8-19-offscreen",
"/__vdb_fixture__/manifest",
"/__vdb_fixture__/bundle",
new AbortController().signal,
);
const density = asset.manifest.grids.find((grid) => grid.name === asset.manifest.material.densityGrid);
const payload = asset.grids.find((grid) => grid.name === density?.name)?.data;
if (!density || !payload) throw new Error("NANOVDB_STREAM_INCOMPLETE: M8-19 density payload is missing");
const session = new NanoVDBWebGPUDeviceSession();
const device = await session.open(payload.byteLength, 4);
const uploaded = uploadNanoVDBFloat32Grid(device, payload);
const material = {
...asset.manifest.material,
temperatureGrid: undefined,
colorGrid: undefined,
emissionGrid: undefined,
interpolation: "LINEAR" as const,
};
const images: Record<NanoVDBViewAxis, ArrayBuffer> = { X: new ArrayBuffer(0), Y: new ArrayBuffer(0), Z: new ArrayBuffer(0) };
const hashes: Record<NanoVDBViewAxis, string> = { X: "", Y: "", Z: "" };
for (const axis of ["X", "Y", "Z"] as const) {
const pixels = await renderNanoVDBFloat32WebGPU(device, uploaded, density, material, 64, 64, {}, axis);
hashes[axis] = await sha256(pixels);
images[axis] = new ArrayBuffer(pixels.byteLength);
new Uint8Array(images[axis]).set(pixels);
}
uploaded.dispose();
session.dispose();
scope.postMessage({
sourceSha256: asset.manifest.sourceSha256,
shaderSemanticVersion: asset.manifest.gpu.shaderSemanticVersion,
hashes,
images,
}, Object.values(images));
})().catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) }));
};

View File

@@ -0,0 +1,170 @@
import { validateNanoVDBBundleManifest, type NanoVDBBundleManifestIR } from "../../../protocol/volume-vdb";
import type { NanoVDBPageFeedbackBatch } from "../../../protocol/nanovdb-page-feedback";
import { loadNanoVDBFeedbackPages, NanoVDBGridPageRequestCoordinator } from "../volume/nanovdb-viewport";
import { createResumableHttpNanoVDBRangeSource } from "../volume/nanovdb-stream";
const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void };
interface RangeStats {
rangeCalls: number;
transferredBytes: number;
peakRangeBytes: number;
activeRangeBytes: number;
abortedRequests: number;
requestStarted: Promise<void>;
resolveRequestStarted: () => void;
}
function createRangeStats(): RangeStats {
let resolveRequestStarted = (): void => undefined;
const requestStarted = new Promise<void>((resolve) => { resolveRequestStarted = resolve; });
return { rangeCalls: 0, transferredBytes: 0, peakRangeBytes: 0, activeRangeBytes: 0, abortedRequests: 0, requestStarted, resolveRequestStarted };
}
function trackingFetcher(stats: RangeStats): typeof fetch {
return async (input, init) => {
stats.rangeCalls += 1;
stats.resolveRequestStarted();
const response = await fetch(input, init);
const expectedBytes = Number(response.headers.get("Content-Length") ?? 0);
stats.activeRangeBytes += expectedBytes;
stats.peakRangeBytes = Math.max(stats.peakRangeBytes, stats.activeRangeBytes);
let released = false;
const release = (): void => {
if (released) return;
released = true;
stats.activeRangeBytes = Math.max(0, stats.activeRangeBytes - expectedBytes);
};
const signal = init?.signal;
const onAbort = (): void => { stats.abortedRequests += 1; };
signal?.addEventListener("abort", onAbort, { once: true });
if (!response.body) {
release();
signal?.removeEventListener("abort", onAbort);
return response;
}
const reader = response.body.getReader();
const body = new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const next = await reader.read();
if (next.done) {
release();
signal?.removeEventListener("abort", onAbort);
controller.close();
return;
}
stats.transferredBytes += next.value.byteLength;
controller.enqueue(next.value);
}
catch (error) {
release();
signal?.removeEventListener("abort", onAbort);
controller.error(error);
}
},
async cancel(reason) {
release();
signal?.removeEventListener("abort", onAbort);
await reader.cancel(reason);
},
});
return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
};
}
function feedbackBatch(pageId: number): NanoVDBPageFeedbackBatch {
return {
schemaVersion: 1,
renderRevision: 17,
attemptedCount: 1,
gpuStoredCount: 1,
uniqueCount: 1,
pageIds: [pageId],
status: "READY",
errorCode: null,
};
}
scope.onmessage = (): void => {
void (async () => {
const manifest = validateNanoVDBBundleManifest(await (await fetch("/__vdb_sparse_64m__/manifest", { cache: "no-store" })).json() as NanoVDBBundleManifestIR);
const gridName = manifest.material.densityGrid;
const successStats = createRangeStats();
const successSource = createResumableHttpNanoVDBRangeSource("/__vdb_sparse_64m__/bundle", manifest.bundleByteLength, {
fetcher: trackingFetcher(successStats),
retries: 0,
requireStableEtag: true,
});
const successCoordinator = new NanoVDBGridPageRequestCoordinator(manifest, gridName, successSource);
const requestedPageIds = [0, 16, 128, 192];
const firstPageStarted = performance.now();
let firstPageMs = 0;
let loadedPageCount = 0;
let pageBytes = 0;
for (const [index, pageId] of requestedPageIds.entries()) {
await loadNanoVDBFeedbackPages(feedbackBatch(pageId), 17, successCoordinator, new AbortController().signal, ({ data }) => {
if (data.byteLength === 0) throw new Error("NANOVDB_STREAM_INCOMPLETE: sparse page is empty");
pageBytes = data.byteLength;
loadedPageCount += 1;
});
if (index === 0) firstPageMs = Math.round(performance.now() - firstPageStarted);
}
const successElapsedMs = Math.round(performance.now() - firstPageStarted);
const successCoordinatorStats = successCoordinator.stats();
successCoordinator.dispose();
const cancelStats = createRangeStats();
const cancelSource = createResumableHttpNanoVDBRangeSource("/__vdb_sparse_64m__/bundle?delayMs=2", manifest.bundleByteLength, {
fetcher: trackingFetcher(cancelStats),
retries: 0,
requireStableEtag: true,
});
const cancelCoordinator = new NanoVDBGridPageRequestCoordinator(manifest, gridName, cancelSource);
const cancelController = new AbortController();
let consumerCalls = 0;
let cancelStatus = "COMPLETED";
let cancelErrorName = "";
const cancelStartedAt = performance.now();
const cancelPromise = loadNanoVDBFeedbackPages(feedbackBatch(64), 17, cancelCoordinator, cancelController.signal, () => { consumerCalls += 1; });
await cancelStats.requestStarted;
const abortAt = performance.now();
setTimeout(() => cancelController.abort(), 12);
try {
await cancelPromise;
}
catch (error) {
cancelStatus = "CANCELLED";
cancelErrorName = error instanceof DOMException ? error.name : error instanceof Error ? error.name : String(error);
}
const cancelLatencyMs = Math.round(performance.now() - abortAt);
const cancelElapsedMs = Math.round(performance.now() - cancelStartedAt);
const cancelCoordinatorStats = cancelCoordinator.stats();
cancelCoordinator.dispose();
scope.postMessage({
bundleBytes: manifest.bundleByteLength,
chunkBytes: manifest.chunks[0].byteLength,
pageBytes,
requestedPageIds,
loadedPageCount,
transferredBytes: successStats.transferredBytes,
rangeCalls: successStats.rangeCalls,
peakRangeBytes: successStats.peakRangeBytes,
firstPageMs,
successElapsedMs,
successCoordinatorStats,
cancel: {
status: cancelStatus,
errorName: cancelErrorName,
consumerCalls,
transferredBytes: cancelStats.transferredBytes,
rangeCalls: cancelStats.rangeCalls,
abortedRequests: cancelStats.abortedRequests,
peakRangeBytes: cancelStats.peakRangeBytes,
cancelLatencyMs,
cancelElapsedMs,
coordinatorStats: cancelCoordinatorStats,
},
});
})().catch((error) => scope.postMessage({ error: error instanceof Error ? error.message : String(error) }));
};

View File

@@ -0,0 +1,87 @@
import {
PHYSICS_FAMILIES,
PHYSICS_SIMULATION_BUDGET,
parsePhysicsSimulationManifest,
verifyPhysicsCachePayload,
type PhysicsFamily,
} from "../../../protocol/physics-simulation";
const digest = async (value: ArrayBuffer): Promise<string> => Array.from(
new Uint8Array(await crypto.subtle.digest("SHA-256", value)),
(byte) => byte.toString(16).padStart(2, "0"),
).join("");
async function cachedSystem(family: PhysicsFamily, index: number) {
const source = Uint8Array.from([0x42, 0x4c, 0x45, 0x4e, 0x44, index]).buffer;
const payload = Uint8Array.from([index + 1, 2, index + 3, 4]).buffer;
const settingsHash = await digest(Uint8Array.from([index + 11]).buffer);
const cache = {
schemaVersion: 1,
cacheKey: `physics-${family.toLowerCase()}-1-2`,
family,
source: index % 2 === 0 ? "BLENDER_DESKTOP_BAKE" : "BLENDER_SERVER_BAKE",
blenderVersion: "5.2.0",
sourceBlendSha256: await digest(source),
settingsHash,
inputHash: await digest(Uint8Array.from([index + 21]).buffer),
cacheSha256: await digest(payload),
frameStart: 1,
frameEnd: 2,
byteLength: payload.byteLength,
frames: [
{ frame: 1, byteOffset: 0, byteLength: 2, sha256: await digest(payload.slice(0, 2)) },
{ frame: 2, byteOffset: 2, byteLength: 2, sha256: await digest(payload.slice(2, 4)) },
],
status: "COMPLETE",
};
return {
source,
payload,
manifest: {
schemaVersion: 1,
systems: [{
id: `physics:${family.toLowerCase()}`,
family,
ownerObjectId: `object:${family}`,
settingsHash,
settings: { enabled: true },
dependencyIds: [],
cache,
}],
},
};
}
async function errorCode(operation: () => unknown | Promise<unknown>): Promise<string> {
try { await operation(); return ""; }
catch (error) { return String((error as Error & { code?: string }).code ?? ""); }
}
self.onmessage = async () => {
const verified: Array<{ family: PhysicsFamily; source: string; bytes: number; frames: number }> = [];
for (const [index, family] of PHYSICS_FAMILIES.entries()) {
const value = await cachedSystem(family, index);
const parsed = parsePhysicsSimulationManifest(value.manifest);
const cache = await verifyPhysicsCachePayload(parsed.systems[0], value.source, value.payload);
verified.push({ family, source: cache.source, bytes: cache.byteLength, frames: cache.frames.length });
}
const value = await cachedSystem("FLUID", 3);
const parsed = parsePhysicsSimulationManifest(value.manifest);
const sourceMismatch = await errorCode(() => verifyPhysicsCachePayload(
parsed.systems[0], Uint8Array.from([1]).buffer, value.payload,
));
const payloadMismatch = await errorCode(() => verifyPhysicsCachePayload(
parsed.systems[0], value.source, Uint8Array.from([9, 9, 9, 9]).buffer,
));
const cache = value.manifest.systems[0].cache;
const versionMismatch = await errorCode(() => parsePhysicsSimulationManifest({
...value.manifest,
systems: [{ ...value.manifest.systems[0], cache: { ...cache, blenderVersion: "5.3.0" } }],
}));
const budgetExceeded = await errorCode(() => parsePhysicsSimulationManifest({
...value.manifest,
systems: [{ ...value.manifest.systems[0], cache: { ...cache, byteLength: PHYSICS_SIMULATION_BUDGET.maxCacheBytes + 1 } }],
}));
self.postMessage({ verified, sourceMismatch, payloadMismatch, versionMismatch, budgetExceeded });
};

View File

@@ -18,15 +18,22 @@ const base = {
settings: { quality: 5, usePressure: false },
dependencyIds: ["object:Collision"],
cache: {
schemaVersion: 1,
cacheKey: "cloth-cache-1-2",
family: "CLOTH",
source: "BLENDER_DESKTOP_BAKE",
blenderVersion: "5.2.0",
sourceBlendSha256: hash,
settingsHash: hash,
inputHash: hash,
cacheSha256: hash,
frameStart: 1,
frameEnd: 2,
cachedFrames: [1, 2],
byteLength: 2,
frames: [
{ frame: 1, byteOffset: 0, byteLength: 1, sha256: hash },
{ frame: 2, byteOffset: 1, byteLength: 1, sha256: hash },
],
status: "COMPLETE",
},
};

View File

@@ -0,0 +1,48 @@
import {
PHYSICS_FAMILIES,
gatePhysicsExecution,
probePhysicsSolverCapabilities,
selectPhysicsExecutionRoute,
type PhysicsFamily,
type PhysicsSolverInitializationIR,
} from "../../../protocol/physics-simulation";
const mib = 1024 * 1024;
const initialization: Partial<Record<PhysicsFamily, PhysicsSolverInitializationIR>> = {
RIGID_BODY: { initialized: true, requiredThreadMode: "SINGLE", requiredMemoryBytes: 64 * mib },
SOFT_BODY: { initialized: false, requiredThreadMode: "SINGLE", requiredMemoryBytes: 64 * mib },
CLOTH: { initialized: true, requiredThreadMode: "PTHREAD", requiredMemoryBytes: 128 * mib },
FLUID: { initialized: true, requiredThreadMode: "SINGLE", requiredMemoryBytes: 512 * mib },
DYNAMIC_PAINT: { initialized: true, requiredThreadMode: "SINGLE", requiredMemoryBytes: Number.NaN },
};
self.onmessage = async () => {
const defaultCapabilities = await probePhysicsSolverCapabilities(undefined, {
threadMode: "PTHREAD",
memoryLimitBytes: 2_048 * mib,
});
const capabilities = await probePhysicsSolverCapabilities({
hasFamilyExport: (family) => family !== "HAIR",
initializeFamily: async (family) => {
if (family === "PARTICLE") throw new Error("synthetic initialization failure");
return initialization[family] as PhysicsSolverInitializationIR;
},
}, {
threadMode: "SINGLE",
memoryLimitBytes: 256 * mib,
});
const localGate = gatePhysicsExecution("RIGID_BODY", "LOCAL_SOLVER", capabilities);
const fallbackGate = gatePhysicsExecution("CLOTH", "LOCAL_SOLVER", capabilities);
self.postMessage({
familyOrder: [...PHYSICS_FAMILIES],
defaultProbe: defaultCapabilities.map((entry) => entry.solverProbe),
probes: Object.fromEntries(capabilities.map((entry) => [entry.family, entry.solverProbe])),
routes: Object.fromEntries(capabilities.map((entry) => [entry.family, selectPhysicsExecutionRoute(entry.family, capabilities).mode])),
localGate: { status: localGate.status, issues: localGate.issues.length },
fallbackGate: {
status: fallbackGate.status,
code: fallbackGate.issues[0]?.code,
message: fallbackGate.issues[0]?.message,
},
});
};

View File

@@ -42,7 +42,20 @@ self.onmessage = () => {
parseSequencerTimeline({ ...base, strips: new Array(SEQUENCER_BUDGET.maxStrips + 1).fill(movie) });
}
catch (error) { result.budget = error instanceof Error ? error.message : String(error); }
result.codec = gateSequencerCodec("video/mp4", new Set()).issues[0]?.code;
const codecRequest = {
schemaVersion: 1 as const,
stripType: "MOVIE" as const,
mimeType: "video/mp4",
byteLength: 1,
sourceSha256: "0".repeat(64),
};
result.codec = gateSequencerCodec(codecRequest, {
...codecRequest,
status: "BLOCKED",
backend: null,
reason: "RUNTIME_UNAVAILABLE",
decoded: null,
}).issues[0]?.code;
const transitionTimeline = { ...base, strips: [
{ id: "strip:From", name: "From", type: "SCENE", channel: 1, frameStart: 10, frameEnd: 20, sourceStart: 100, sourceEnd: 110, speed: 1, muted: false, locked: false },
{ id: "strip:To", name: "To", type: "SCENE", channel: 2, frameStart: 10, frameEnd: 20, sourceStart: 200, sourceEnd: 210, speed: 1, muted: false, locked: false },

View File

@@ -1,9 +1,22 @@
import type { StorageAssetListResult, StorageAssetPutResult, StorageAssetReadResult, StorageAssetRecord, StorageInfoResult, StorageLODManifestListResult, StorageLODManifestResult, StorageLODPruneResult, StorageLODReadResult, StorageLODResult, StorageOperationListResult, StorageOperationPruneResult, StorageOperationRecord, StorageOperationResult, StorageProjectReadResult, StorageProjectResult, StorageRecoveryResult, StorageRequest, StorageResponse, StorageSaveResult, StorageSimulationCacheFrameReadResult, StorageSimulationCacheListResult, StorageSimulationCacheReadResult, StorageSimulationCacheResult, StorageSnapshotListResult, StorageSnapshotReadResult, StorageSnapshotResult } from "../../../protocol/storage";
import type { StorageAssetListResult, StorageAssetPutResult, StorageAssetReadResult, StorageAssetRecord, StorageBudgetResult, StorageInfoResult, StorageLODManifestListResult, StorageLODManifestResult, StorageLODPruneResult, StorageLODReadResult, StorageLODResult, StorageOperationListResult, StorageOperationPruneResult, StorageOperationRecord, StorageOperationResult, StorageProjectCleanupResult, StorageProjectReadResult, StorageProjectResult, StorageRecoveryResult, StorageRequest, StorageResponse, StorageSaveResult, StorageSimulationCacheFrameReadResult, StorageSimulationCacheListResult, StorageSimulationCachePlaybackReadyResult, StorageSimulationCachePlaybackReleaseResult, StorageSimulationCachePruneResult, StorageSimulationCacheQuarantineIssue, StorageSimulationCacheReadResult, StorageSimulationCacheResult, StorageSnapshotListResult, StorageSnapshotReadResult, StorageSnapshotResult } from "../../../protocol/storage";
import { normalizeProjectAssetPath } from "../../../protocol/asset-path";
import { parseLODCacheRecord, type LODCacheRecord } from "../../../protocol/lod";
import { parseSimulationCacheManifest, selectSimulationCacheFrame, simulationCacheKey, SimulationCacheValidationError, verifySimulationCache, verifySimulationCacheFrame, type SimulationCacheManifestIR } from "../../../protocol/simulation-cache";
import { planSimulationCacheLRU, selectSimulationCacheFrame, simulationCacheKey, SimulationCacheValidationError, verifySimulationCacheCancellable, verifySimulationCacheFrame, verifySimulationCacheRevisionBinding, type SimulationCacheManifestIR } from "../../../protocol/simulation-cache";
import { STORAGE_DATABASE_NAME, STORAGE_SCHEMA_VERSION, STORAGE_STORES, upgradeStorageSchema } from "../storage/migrations";
import { deleteLodCache, ensureProjectLayout, projectLayout, readContentAsset, readContentAssetRange, readLodCache, readProjectBlend, recoverProjectBlend, validateSha256, writeContentAsset, writeLodCache, writeProjectBlend, type ProjectSaveFault } from "../storage/opfs-files";
import { deleteContentAsset, deleteLodCache, deleteProjectSnapshot, ensureProjectLayout, measureProjectVDBBytes, projectLayout, readContentAsset, readContentAssetRange, readLodCache, readProjectBlend, readProjectSnapshot, recoverProjectBlend, removeOrphanContentAssets, validateSha256, writeContentAsset, writeLodCache, writeProjectBlend, writeProjectSnapshot, type ProjectSaveFault } from "../storage/opfs-files";
import { classifyRecentProjectIdentity, createRecentProjectIndex, parseRecentProjectIndex, parseRecentProjectRecord, removeRecentProject, upsertRecentProject, type RecentProjectIdentity, type RecentProjectIndex, type RecentProjectIssue, type RecentProjectRecord } from "../../../protocol/recent-projects";
import { createStorageBudget } from "../../../protocol/storage-budget";
import { applyUdimTilePatch } from "../../../protocol/paint";
import {
texturePaintTileBindingKey,
validateTexturePaintTileBindingRequest,
validateTexturePaintTileCommit,
type TexturePaintTileBindingIR,
type TexturePaintTileBindingRequestIR,
type TexturePaintTileBindingResultIR,
type TexturePaintTileCommitIR,
type TexturePaintTileCommitResultIR,
} from "../../../protocol/texture-paint-asset";
const scope = self as unknown as {
onmessage: ((event: MessageEvent<StorageRequest>) => void) | null;
@@ -11,7 +24,33 @@ const scope = self as unknown as {
};
const projectTransactions = new Map<string, Promise<void>>();
const simulationPlaybackReady = new Set<string>();
const simulationPlaybackActive = new Set<string>();
const cancelledRequests = new Set<string>();
let recentProjectsTransaction: Promise<void> = Promise.resolve();
let opfsUsable: boolean | undefined;
const RECENT_PROJECTS_SETTING_ID = "recent-projects:v1";
function storageError(code: NonNullable<StorageResponse["errorCode"]>, message: string): Error & { code: NonNullable<StorageResponse["errorCode"]> } {
const error = new Error(`${code}: ${message}`) as Error & { code: NonNullable<StorageResponse["errorCode"]> };
error.code = code;
return error;
}
function simulationSessionKey(projectId: string, cacheKey: string): string {
return `${projectId}:${cacheKey}`;
}
function throwIfCancelled(requestId: string): void {
if (cancelledRequests.has(requestId)) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_CANCELLED", "Simulation cache operation was cancelled");
}
}
function cancelRequest(requestId: string): void {
cancelledRequests.add(requestId);
setTimeout(() => cancelledRequests.delete(requestId), 30_000);
}
interface WorkerLockManager {
request<T>(name: string, callback: () => Promise<T>): Promise<T>;
@@ -51,6 +90,15 @@ async function withProjectTransaction<T>(projectId: string, operation: () => Pro
}
}
async function withRecentProjectsTransaction<T>(operation: () => Promise<T>): Promise<T> {
const result = recentProjectsTransaction.catch(() => undefined).then(() => {
const locks = (self as unknown as { navigator?: { locks?: WorkerLockManager } }).navigator?.locks;
return locks ? locks.request("blender-web-recent-projects", operation) : operation();
});
recentProjectsTransaction = result.then(() => undefined, () => undefined);
return result;
}
function openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(STORAGE_DATABASE_NAME, STORAGE_SCHEMA_VERSION);
@@ -101,6 +149,13 @@ interface SimulationManifestRow {
manifest: SimulationCacheManifestIR;
path: string;
createdAt: string;
lastAccessAt: string;
}
interface SimulationQuarantineRow extends StorageSimulationCacheQuarantineIssue {
id: string;
projectId: string;
row: SimulationManifestRow;
}
interface ProjectRow {
@@ -114,6 +169,100 @@ interface ProjectRow {
buffer?: ArrayBuffer;
}
interface SettingRow {
id: string;
value?: unknown;
}
async function readRecentProjectIndex(): Promise<{ index: RecentProjectIndex; quarantined: number }> {
const db = await openDatabase();
const transaction = db.transaction("setting", "readonly");
const row = await new Promise<SettingRow | undefined>((resolve, reject) => {
const request = transaction.objectStore("setting").get(RECENT_PROJECTS_SETTING_ID);
request.onsuccess = () => resolve(request.result as SettingRow | undefined);
request.onerror = () => reject(request.error ?? new Error("Recent project index lookup failed"));
});
await transactionComplete(transaction);
db.close();
const parsed = row ? parseRecentProjectIndex(row.value) : { index: createRecentProjectIndex(), quarantined: 0 };
if (parsed.quarantined > 0) {
const cleaned = await openDatabase();
const write = cleaned.transaction("setting", "readwrite");
write.objectStore("setting").put({ id: RECENT_PROJECTS_SETTING_ID, value: parsed.index });
await transactionComplete(write);
cleaned.close();
}
return parsed;
}
async function writeRecentProjectIndex(index: RecentProjectIndex): Promise<void> {
const db = await openDatabase();
const transaction = db.transaction("setting", "readwrite");
transaction.objectStore("setting").put({ id: RECENT_PROJECTS_SETTING_ID, value: index });
await transactionComplete(transaction);
db.close();
}
async function readCommittedProjectIdentity(projectId: string, row: ProjectRow): Promise<RecentProjectIdentity | undefined> {
if (row.backend === "indexeddb") {
if (!row.buffer || row.buffer.byteLength !== row.bytes) return undefined;
const sha256 = await sha256Hex(row.buffer);
return { revision: row.revision, bytes: row.bytes, sha256 };
}
if (!await useOpfsForProject(projectId)) return { revision: row.revision, bytes: row.bytes, sha256: row.sha256 };
const recovery = await recoverProjectBlend(projectId);
if (!recovery.manifest) return undefined;
return { revision: recovery.manifest.revision, bytes: recovery.manifest.bytes, sha256: recovery.manifest.sha256 };
}
async function inspectRecentProject(project: RecentProjectRecord): Promise<RecentProjectIssue | undefined> {
// Records created while storage was unavailable cannot be checked against a committed backend.
if (project.backend === "unknown") return undefined;
try {
const row = await readProjectRow(project.projectId);
if (!row) return { project, code: "MISSING" };
const rowIdentity = { revision: row.revision, bytes: row.bytes, sha256: row.sha256 };
const committed = await readCommittedProjectIdentity(project.projectId, row);
const metadataIssue = classifyRecentProjectIdentity(rowIdentity, committed);
if (metadataIssue) return { project, code: metadataIssue };
const issue = classifyRecentProjectIdentity(project, committed);
return issue ? { project, code: issue } : undefined;
}
catch (error) {
const message = error instanceof Error ? error.message : "";
return { project, code: /integrity|hash|digest|mismatch/i.test(message) ? "HASH_MISMATCH" : "MISSING" };
}
}
async function listRecentProjects(): Promise<{ projects: RecentProjectRecord[]; quarantined: number; issues: RecentProjectIssue[] }> {
const parsed = await readRecentProjectIndex();
const projects: RecentProjectRecord[] = [];
const issues: RecentProjectIssue[] = [];
for (const project of parsed.index.projects) {
const issue = await inspectRecentProject(project);
if (issue) issues.push(issue);
else projects.push(project);
}
return { projects, quarantined: parsed.quarantined, issues };
}
async function touchRecentProject(project: RecentProjectRecord): Promise<{ projects: RecentProjectRecord[]; quarantined: number; issues: RecentProjectIssue[] }> {
const parsed = parseRecentProjectRecord(project);
if (!parsed) throw new Error("RECENT_PROJECT_INVALID");
const current = await readRecentProjectIndex();
const next = upsertRecentProject(current.index, parsed);
await writeRecentProjectIndex(next);
return listRecentProjects();
}
async function removeRecentProjectEntry(projectId: string): Promise<{ projects: RecentProjectRecord[]; quarantined: number; issues: RecentProjectIssue[] }> {
projectLayout(projectId);
const current = await readRecentProjectIndex();
const next = removeRecentProject(current.index, projectId);
await writeRecentProjectIndex(next);
return listRecentProjects();
}
async function readProjectRow(projectId: string): Promise<ProjectRow | undefined> {
projectLayout(projectId);
const db = await openDatabase();
@@ -162,6 +311,58 @@ async function readSimulationRow(projectId: string, cacheKey: string): Promise<S
return row;
}
function simulationIssue(error: unknown): { code: NonNullable<StorageResponse["errorCode"]>; reason: string } {
const code = error && typeof error === "object" && "code" in error ?
(error as { code?: StorageResponse["errorCode"] }).code : undefined;
const simulationCode = code === "SIMULATION_CACHE_INVALID" || code === "SIMULATION_CACHE_MISSING" ||
code === "SIMULATION_CACHE_HASH_MISMATCH" ? code : "SIMULATION_CACHE_INVALID";
return {
code: simulationCode,
reason: error instanceof Error ? error.message : "Simulation cache validation failed",
};
}
async function quarantineSimulationCache(
row: SimulationManifestRow,
error: unknown,
): Promise<StorageSimulationCacheQuarantineIssue> {
const issue = simulationIssue(error);
const quarantinedAt = new Date().toISOString();
const quarantine: SimulationQuarantineRow = {
id: row.id,
projectId: row.projectId,
cacheKey: row.cacheKey,
code: issue.code,
reason: issue.reason,
quarantinedAt,
row,
};
const db = await openDatabase();
const transaction = db.transaction(["simulation_manifest", "simulation_quarantine"], "readwrite");
transaction.objectStore("simulation_manifest").delete(row.id);
transaction.objectStore("simulation_quarantine").put(quarantine);
await transactionComplete(transaction);
db.close();
const sessionKey = simulationSessionKey(row.projectId, row.cacheKey);
simulationPlaybackReady.delete(sessionKey);
simulationPlaybackActive.delete(sessionKey);
return { cacheKey: row.cacheKey, code: issue.code, reason: issue.reason, quarantinedAt };
}
async function listSimulationQuarantine(projectId: string): Promise<StorageSimulationCacheQuarantineIssue[]> {
const db = await openDatabase();
const transaction = db.transaction("simulation_quarantine", "readonly");
const rows = await new Promise<SimulationQuarantineRow[]>((resolve, reject) => {
const request = transaction.objectStore("simulation_quarantine").getAll();
request.onsuccess = () => resolve((request.result as SimulationQuarantineRow[]).filter((row) => row.projectId === projectId));
request.onerror = () => reject(request.error ?? new Error("Simulation quarantine scan failed"));
});
await transactionComplete(transaction);
db.close();
return rows.map(({ cacheKey, code, reason, quarantinedAt }) => ({ cacheKey, code, reason, quarantinedAt }))
.sort((left, right) => left.quarantinedAt.localeCompare(right.quarantinedAt) || left.cacheKey.localeCompare(right.cacheKey));
}
async function smoke() {
const db = await openDatabase();
const write = db.transaction("smoke", "readwrite");
@@ -245,7 +446,7 @@ async function saveProject(projectId: string, revision: number, buffer: ArrayBuf
if (faultAt && existing && previousBuffer) {
const rollback = await writeProjectBlend(projectId, existing.revision, previousBuffer);
if (rollback.manifest.sha256 !== existing.sha256) {
throw new Error("PROJECT_SAVE_ROLLBACK_FAILED: old committed hash was not restored");
throw new Error("PROJECT_SAVE_ROLLBACK_FAILED: old committed hash was not restored", { cause: error });
}
}
throw error;
@@ -414,7 +615,12 @@ async function pruneOperations(projectId: string, throughRevision: number): Prom
return { projectId, throughRevision, removed: remove.length };
}
interface SnapshotRow extends StorageSnapshotResult { id: string; buffer: ArrayBuffer }
interface SnapshotRow extends StorageSnapshotResult {
id: string;
backend?: "indexeddb" | "opfs";
path?: string;
buffer?: ArrayBuffer;
}
async function saveSnapshot(projectId: string, revision: number, buffer: ArrayBuffer, maxCount = 5, maxBytes = 268_435_456): Promise<StorageSnapshotResult> {
projectLayout(projectId);
@@ -422,25 +628,59 @@ async function saveSnapshot(projectId: string, revision: number, buffer: ArrayBu
throw new Error("Invalid snapshot retention request");
}
const sha256 = await sha256Hex(buffer);
const db = await openDatabase();
const transaction = db.transaction("snapshot", "readwrite");
const store = transaction.objectStore("snapshot");
const readDb = await openDatabase();
const readTransaction = readDb.transaction("snapshot", "readonly");
const rows = await new Promise<SnapshotRow[]>((resolve, reject) => {
const request = store.getAll();
const request = readTransaction.objectStore("snapshot").getAll();
request.onsuccess = () => resolve((request.result as SnapshotRow[]).filter((row) => row.projectId === projectId));
request.onerror = () => reject(request.error ?? new Error("Snapshot scan failed"));
});
await transactionComplete(readTransaction);
readDb.close();
const existing = rows.find((candidate) => candidate.revision === revision);
if (existing?.bytes === buffer.byteLength && existing.sha256 === sha256) {
const existingBuffer = existing.backend === "opfs"
? await readProjectSnapshot(projectId, revision)
: existing.buffer;
if (existingBuffer?.byteLength === existing.bytes && await sha256Hex(existingBuffer) === sha256) {
return { projectId, revision, bytes: existing.bytes, sha256, createdAt: existing.createdAt, persisted: true };
}
}
const useOpfs = await useOpfsForProject(projectId);
const opfs = useOpfs ? await writeProjectSnapshot(projectId, revision, buffer) : undefined;
const createdAt = new Date().toISOString();
const row: SnapshotRow = { id: `${projectId}:${revision}`, projectId, revision, bytes: buffer.byteLength, sha256, createdAt, persisted: true, buffer: buffer.slice(0) };
store.put(row);
const row: SnapshotRow = {
id: `${projectId}:${revision}`,
projectId,
revision,
bytes: buffer.byteLength,
sha256,
createdAt,
persisted: true,
backend: useOpfs ? "opfs" : "indexeddb",
path: opfs?.path,
buffer: useOpfs ? undefined : buffer.slice(0),
};
const retained = [...rows.filter((candidate) => candidate.revision !== revision), row].sort((left, right) => right.revision - left.revision);
let total = 0;
const removed: SnapshotRow[] = [];
const db = await openDatabase();
const transaction = db.transaction("snapshot", "readwrite");
const store = transaction.objectStore("snapshot");
store.put(row);
for (let index = 0; index < retained.length; index++) {
total += retained[index].bytes;
if (index >= maxCount || total > maxBytes) store.delete(retained[index].id);
if (index >= maxCount || total > maxBytes) {
store.delete(retained[index].id);
removed.push(retained[index]);
}
}
await transactionComplete(transaction);
db.close();
await Promise.all(removed.filter((candidate) => candidate.backend === "opfs" && candidate.revision !== revision)
.map((candidate) => deleteProjectSnapshot(projectId, candidate.revision)));
return { projectId, revision, bytes: row.bytes, sha256, createdAt, persisted: true };
}
@@ -469,8 +709,10 @@ async function readSnapshot(projectId: string, revision: number): Promise<Storag
});
await transactionComplete(transaction);
db.close();
if (!row || row.buffer.byteLength !== row.bytes || await sha256Hex(row.buffer) !== row.sha256) throw new Error("Snapshot integrity check failed");
return { projectId, revision, bytes: row.bytes, sha256: row.sha256, createdAt: row.createdAt, buffer: row.buffer.slice(0) };
if (!row) throw new Error("Snapshot integrity check failed");
const buffer = row.backend === "opfs" ? await readProjectSnapshot(projectId, revision) : row.buffer;
if (!buffer || buffer.byteLength !== row.bytes || await sha256Hex(buffer) !== row.sha256) throw new Error("Snapshot integrity check failed");
return { projectId, revision, bytes: row.bytes, sha256: row.sha256, createdAt: row.createdAt, buffer: buffer.slice(0) };
}
interface AssetRow extends StorageAssetRecord {
@@ -553,10 +795,19 @@ async function readAsset(projectId: string, sha256: string): Promise<StorageAsse
projectLayout(projectId);
validateSha256(sha256);
const row = await readAssetRow(projectId, sha256);
if (!row) throw new Error("Content-addressed asset metadata is missing");
const data = row.buffer ? row.buffer.slice(0) : await readContentAsset(projectId, sha256);
if (!row) throw storageError("NON_MESH_RESOURCE_MISSING", "content-addressed asset metadata is missing");
let data: ArrayBuffer;
try {
data = row.buffer ? row.buffer.slice(0) : await readContentAsset(projectId, sha256);
}
catch (error) {
if (error instanceof DOMException && error.name === "NotFoundError") {
throw storageError("NON_MESH_RESOURCE_MISSING", "content-addressed asset bytes are missing");
}
throw error;
}
if (data.byteLength !== row.bytes || await sha256Hex(data) !== sha256) {
throw new Error("Content-addressed asset integrity check failed");
throw storageError("ASSET_SOURCE_HASH_MISMATCH", "content-addressed asset integrity check failed");
}
const lastAccessAt = new Date().toISOString();
const db = await openDatabase();
@@ -584,6 +835,115 @@ async function listAssets(projectId: string): Promise<StorageAssetListResult> {
};
}
async function readTexturePaintTileBindingRow(requestValue: TexturePaintTileBindingRequestIR): Promise<TexturePaintTileBindingIR | undefined> {
const request = validateTexturePaintTileBindingRequest(requestValue);
const db = await openDatabase();
const transaction = db.transaction("setting", "readonly");
const row = await new Promise<SettingRow | undefined>((resolve, reject) => {
const lookup = transaction.objectStore("setting").get(texturePaintTileBindingKey(request));
lookup.onsuccess = () => resolve(lookup.result as SettingRow | undefined);
lookup.onerror = () => reject(lookup.error ?? new Error("Texture paint tile binding lookup failed"));
});
await transactionComplete(transaction);
db.close();
return row?.value as TexturePaintTileBindingIR | undefined;
}
async function readTexturePaintTileBinding(requestValue: TexturePaintTileBindingRequestIR): Promise<TexturePaintTileBindingResultIR> {
const request = validateTexturePaintTileBindingRequest(requestValue);
return { projectId: request.projectId, binding: await readTexturePaintTileBindingRow(request) };
}
async function decodePackedPng(data: ArrayBuffer, width: number, height: number): Promise<Uint8Array> {
const bitmap = await createImageBitmap(new Blob([data], { type: "image/png" }));
try {
if (bitmap.width !== width || bitmap.height !== height) throw new Error("PAINT_SCHEMA_INVALID: Packed texture dimensions do not match the dirty tile target");
const canvas = new OffscreenCanvas(width, height);
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) throw new Error("PAINT_SCHEMA_INVALID: Texture paint decode canvas is unavailable");
context.clearRect(0, 0, width, height);
context.drawImage(bitmap, 0, 0);
return new Uint8Array(context.getImageData(0, 0, width, height).data);
}
finally {
bitmap.close();
}
}
async function encodePackedPng(pixels: Uint8Array, width: number, height: number): Promise<ArrayBuffer> {
const canvas = new OffscreenCanvas(width, height);
const context = canvas.getContext("2d");
if (!context) throw new Error("PAINT_SCHEMA_INVALID: Texture paint encode canvas is unavailable");
context.putImageData(new ImageData(new Uint8ClampedArray(pixels), width, height), 0, 0);
return (await canvas.convertToBlob({ type: "image/png" })).arrayBuffer();
}
async function commitTexturePaintTile(commitValue: TexturePaintTileCommitIR): Promise<TexturePaintTileCommitResultIR> {
const commit = validateTexturePaintTileCommit(commitValue);
const target = commit.target;
const bindingRequest = {
schemaVersion: 1 as const,
projectId: target.projectId,
textureAssetId: target.textureAssetId,
tile: target.tile,
};
const previous = await readTexturePaintTileBindingRow(bindingRequest);
if (previous && (previous.imageId !== target.imageId || previous.kind !== target.kind || previous.width !== target.width ||
previous.height !== target.height || previous.mimeType !== target.mimeType || previous.colorSpace !== target.colorSpace)) {
throw new Error("PAINT_SCHEMA_INVALID: Texture paint tile target does not match its published binding");
}
const expectedBase = previous?.assetSha256 ?? target.baseAssetSha256;
if (target.baseAssetSha256 !== expectedBase) throw new Error("PAINT_TILE_HASH_MISMATCH: Texture paint base asset binding is stale");
const base = await readAsset(target.projectId, expectedBase);
if (base.asset.mimeType !== "image/png") throw new Error("PAINT_SCHEMA_INVALID: Texture paint base asset is not a packed PNG");
const basePixels = await decodePackedPng(base.data, target.width, target.height);
const nextPixels = await applyUdimTilePatch(basePixels, commit.patch, target.revision);
const nextData = await encodePackedPng(nextPixels, target.width, target.height);
if (commit.faultAt === "before-asset-write") throw new Error("STORAGE_TRANSACTION: injected before texture tile asset write");
const stored = await putAsset(target.projectId, nextData, target.mimeType, target.sourcePath);
if (commit.faultAt === "after-asset-write" || commit.faultAt === "before-binding-commit") {
throw new Error("STORAGE_TRANSACTION: injected before texture tile binding commit");
}
const binding: TexturePaintTileBindingIR = {
schemaVersion: 1,
projectId: target.projectId,
imageId: target.imageId,
textureAssetId: target.textureAssetId,
kind: target.kind,
tile: target.tile,
revision: target.revision,
generation: (previous?.generation ?? 0) + 1,
width: target.width,
height: target.height,
mimeType: target.mimeType,
colorSpace: target.colorSpace,
sourcePath: target.sourcePath,
assetId: stored.assetId,
assetSha256: stored.sha256,
pixelSha256: commit.patch.resultSha256,
bytes: stored.bytes,
path: stored.path,
updatedAt: new Date().toISOString(),
};
const db = await openDatabase();
const transaction = db.transaction("setting", "readwrite");
transaction.objectStore("setting").put({ id: texturePaintTileBindingKey(bindingRequest), value: binding });
await transactionComplete(transaction);
db.close();
const published = await readTexturePaintTileBindingRow(bindingRequest);
if (!published || published.assetSha256 !== stored.sha256 || published.pixelSha256 !== commit.patch.resultSha256) {
throw new Error("STORAGE_TRANSACTION: Texture paint tile binding verification failed");
}
await readAsset(target.projectId, published.assetSha256);
return {
projectId: target.projectId,
persisted: true,
binding: published,
previousAssetSha256: expectedBase,
orphanedAssetPossible: false,
};
}
async function saveLOD(projectId: string, cacheKey: string, data: ArrayBuffer): Promise<StorageLODResult> {
const layout = await writeLodCache(projectId, cacheKey, data);
const existing = await readLODRow(projectId, cacheKey);
@@ -721,82 +1081,213 @@ async function putSimulationCache(
projectId: string,
manifestValue: SimulationCacheManifestIR,
data: ArrayBuffer,
requestId: string,
): Promise<StorageSimulationCacheResult> {
projectLayout(projectId);
const manifest = await verifySimulationCache(manifestValue, data);
throwIfCancelled(requestId);
const manifest = await verifySimulationCacheRevisionBinding(manifestValue);
throwIfCancelled(requestId);
const project = await readProjectRow(projectId);
if (!project) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache requires a committed source blend");
}
if (project.revision !== manifest.sourceRevision) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_REVISION_MISMATCH", "Simulation cache source revision does not match the committed project");
}
if (project.sha256 !== manifest.sourceBlendSha256) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", "Simulation cache source blend digest does not match the committed project");
}
await verifySimulationCacheCancellable(manifest, data, () => throwIfCancelled(requestId));
const cacheKey = simulationCacheKey(manifest);
const existing = await readSimulationRow(projectId, cacheKey);
if (existing && existing.manifest.cacheSha256 !== manifest.cacheSha256) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", "Simulation cache key already references a different payload");
}
const asset = await putAsset(
projectId,
data,
"application/x-blender-simulation-cache",
`simulation/${cacheKey}.bin`,
);
if (asset.sha256 !== manifest.cacheSha256) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", "Content-addressed asset digest differs from the Simulation manifest");
let asset: StorageAssetPutResult | undefined;
try {
asset = await putAsset(
projectId,
data,
"application/x-blender-simulation-cache",
`simulation/${cacheKey}.bin`,
);
if (asset.sha256 !== manifest.cacheSha256) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", "Content-addressed asset digest differs from the Simulation manifest");
}
throwIfCancelled(requestId);
const now = new Date().toISOString();
const row: SimulationManifestRow = {
id: `${projectId}:${cacheKey}`,
projectId,
cacheKey,
manifest,
path: asset.path,
createdAt: existing?.createdAt ?? now,
lastAccessAt: now,
};
const db = await openDatabase();
const transaction = db.transaction(["simulation_manifest", "simulation_quarantine"], "readwrite");
transaction.objectStore("simulation_manifest").put(row);
transaction.objectStore("simulation_quarantine").delete(row.id);
await transactionComplete(transaction);
db.close();
simulationPlaybackReady.add(simulationSessionKey(projectId, cacheKey));
return { projectId, cacheKey, persisted: true, manifest, path: row.path };
}
const row: SimulationManifestRow = {
id: `${projectId}:${cacheKey}`,
projectId,
cacheKey,
manifest,
path: asset.path,
createdAt: existing?.createdAt ?? new Date().toISOString(),
};
catch (error) {
if (!existing && asset && error instanceof SimulationCacheValidationError && error.code === "SIMULATION_CACHE_CANCELLED") {
await deleteUnreferencedSimulationAssets(projectId, new Set([asset.sha256]));
}
throw error;
}
}
async function requireSimulationCacheProjectIdentity(
projectId: string,
manifest: SimulationCacheManifestIR,
): Promise<ProjectRow> {
const project = await readProjectRow(projectId);
if (!project) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache requires a committed source blend");
}
if (project.revision !== manifest.sourceRevision) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_REVISION_MISMATCH", "Simulation cache is stale for the committed project revision");
}
if (project.sha256 !== manifest.sourceBlendSha256) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", "Simulation cache source blend digest does not match the committed project");
}
return project;
}
async function requireSimulationCacheRow(
projectId: string,
cacheKey: string,
): Promise<{ row: SimulationManifestRow; manifest: SimulationCacheManifestIR }> {
const row = await readSimulationRow(projectId, cacheKey);
if (!row) throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache manifest is missing");
let manifest: SimulationCacheManifestIR;
try {
manifest = await verifySimulationCacheRevisionBinding(row.manifest);
if (simulationCacheKey(manifest) !== cacheKey) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache manifest key is inconsistent");
}
}
catch (error) {
await quarantineSimulationCache(row, error);
throw error;
}
await requireSimulationCacheProjectIdentity(projectId, manifest);
return { row, manifest };
}
function simulationPayloadError(error: unknown): SimulationCacheValidationError {
if (error instanceof SimulationCacheValidationError) return error;
const code = error && typeof error === "object" && "code" in error ? (error as { code?: string }).code : undefined;
if (code === "NON_MESH_RESOURCE_MISSING") {
return new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache payload is missing");
}
return new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", "Simulation cache payload failed integrity verification");
}
async function readVerifiedSimulationPayload(
row: SimulationManifestRow,
manifest: SimulationCacheManifestIR,
requestId: string,
): Promise<ArrayBuffer> {
try {
throwIfCancelled(requestId);
const asset = await readAsset(row.projectId, manifest.cacheSha256);
await verifySimulationCacheCancellable(manifest, asset.data, () => throwIfCancelled(requestId));
return asset.data;
}
catch (error) {
if (error instanceof SimulationCacheValidationError && error.code === "SIMULATION_CACHE_CANCELLED") throw error;
const normalized = simulationPayloadError(error);
await quarantineSimulationCache(row, normalized);
throw normalized;
}
}
async function touchSimulationCache(row: SimulationManifestRow): Promise<SimulationManifestRow> {
const updated = { ...row, lastAccessAt: new Date().toISOString() };
const db = await openDatabase();
const transaction = db.transaction("simulation_manifest", "readwrite");
transaction.objectStore("simulation_manifest").put(row);
transaction.objectStore("simulation_manifest").put(updated);
await transactionComplete(transaction);
db.close();
return { projectId, cacheKey, persisted: true, manifest, path: row.path };
return updated;
}
async function readSimulationCache(projectId: string, cacheKey: string): Promise<StorageSimulationCacheReadResult> {
async function readSimulationCache(projectId: string, cacheKey: string, requestId: string): Promise<StorageSimulationCacheReadResult> {
projectLayout(projectId);
const row = await readSimulationRow(projectId, cacheKey);
if (!row) throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache manifest is missing");
const manifest = parseSimulationCacheManifest(row.manifest);
if (simulationCacheKey(manifest) !== cacheKey) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache manifest key is inconsistent");
}
const asset = await readAsset(projectId, manifest.cacheSha256);
await verifySimulationCache(manifest, asset.data);
return { projectId, cacheKey, persisted: true, manifest, path: row.path, data: asset.data };
const { row, manifest } = await requireSimulationCacheRow(projectId, cacheKey);
const data = await readVerifiedSimulationPayload(row, manifest, requestId);
const touched = await touchSimulationCache(row);
simulationPlaybackReady.add(simulationSessionKey(projectId, cacheKey));
return { projectId, cacheKey, persisted: true, manifest, path: touched.path, data };
}
async function readSimulationCacheFrame(projectId: string, cacheKey: string, frame: number): Promise<StorageSimulationCacheFrameReadResult> {
async function prepareSimulationCachePlayback(
projectId: string,
cacheKey: string,
requestId: string,
): Promise<StorageSimulationCachePlaybackReadyResult> {
projectLayout(projectId);
const row = await readSimulationRow(projectId, cacheKey);
if (!row) throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache manifest is missing");
const manifest = parseSimulationCacheManifest(row.manifest);
if (simulationCacheKey(manifest) !== cacheKey) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache manifest key is inconsistent");
const { row, manifest } = await requireSimulationCacheRow(projectId, cacheKey);
await readVerifiedSimulationPayload(row, manifest, requestId);
throwIfCancelled(requestId);
const touched = await touchSimulationCache(row);
const sessionKey = simulationSessionKey(projectId, cacheKey);
simulationPlaybackReady.add(sessionKey);
simulationPlaybackActive.add(sessionKey);
return { projectId, cacheKey, persisted: true, manifest, path: touched.path, verifiedAt: touched.lastAccessAt };
}
async function releaseSimulationCachePlayback(
projectId: string,
cacheKey: string,
): Promise<StorageSimulationCachePlaybackReleaseResult> {
projectLayout(projectId);
const sessionKey = simulationSessionKey(projectId, cacheKey);
simulationPlaybackReady.delete(sessionKey);
simulationPlaybackActive.delete(sessionKey);
return { projectId, cacheKey, released: true };
}
async function readSimulationCacheFrame(projectId: string, cacheKey: string, frame: number, requestId: string): Promise<StorageSimulationCacheFrameReadResult> {
projectLayout(projectId);
throwIfCancelled(requestId);
if (!simulationPlaybackReady.has(simulationSessionKey(projectId, cacheKey))) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_NOT_READY", "Simulation cache playback requires full verification in the current Worker");
}
const { row, manifest } = await requireSimulationCacheRow(projectId, cacheKey);
const selected = selectSimulationCacheFrame(manifest, frame);
const asset = await readAssetRow(projectId, manifest.cacheSha256);
if (!asset || asset.bytes !== manifest.byteLength) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache payload is missing or truncated");
let data: ArrayBuffer;
try {
const asset = await readAssetRow(projectId, manifest.cacheSha256);
if (!asset || asset.bytes !== manifest.byteLength) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache payload is missing or truncated");
}
throwIfCancelled(requestId);
data = asset.buffer ?
asset.buffer.slice(selected.byteOffset, selected.byteOffset + selected.byteLength) :
await readContentAssetRange(projectId, manifest.cacheSha256, selected.byteOffset, selected.byteLength, manifest.byteLength);
throwIfCancelled(requestId);
await verifySimulationCacheFrame(manifest, frame, data);
}
const data = asset.buffer ?
asset.buffer.slice(selected.byteOffset, selected.byteOffset + selected.byteLength) :
await readContentAssetRange(projectId, manifest.cacheSha256, selected.byteOffset, selected.byteLength, manifest.byteLength);
await verifySimulationCacheFrame(manifest, frame, data);
catch (error) {
if (error instanceof SimulationCacheValidationError && error.code === "SIMULATION_CACHE_CANCELLED") throw error;
const normalized = simulationPayloadError(error);
await quarantineSimulationCache(row, normalized);
throw normalized;
}
const touched = await touchSimulationCache(row);
return {
projectId,
cacheKey,
persisted: true,
manifest,
path: row.path,
path: touched.path,
frame,
byteOffset: selected.byteOffset,
byteLength: selected.byteLength,
@@ -806,6 +1297,10 @@ async function readSimulationCacheFrame(projectId: string, cacheKey: string, fra
async function listSimulationCaches(projectId: string): Promise<StorageSimulationCacheListResult> {
projectLayout(projectId);
const project = await readProjectRow(projectId);
if (!project) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache listing requires a committed source blend");
}
const db = await openDatabase();
const transaction = db.transaction("simulation_manifest", "readonly");
const rows = await new Promise<SimulationManifestRow[]>((resolve, reject) => {
@@ -815,22 +1310,184 @@ async function listSimulationCaches(projectId: string): Promise<StorageSimulatio
});
await transactionComplete(transaction);
db.close();
const caches = rows.map((row) => {
const manifest = parseSimulationCacheManifest(row.manifest);
if (simulationCacheKey(manifest) !== row.cacheKey) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache index key is inconsistent");
const caches: StorageSimulationCacheListResult["caches"] = [];
for (const row of rows) {
try {
const manifest = await verifySimulationCacheRevisionBinding(row.manifest);
if (simulationCacheKey(manifest) !== row.cacheKey) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache index key is inconsistent");
}
if (manifest.sourceRevision === project.revision && manifest.sourceBlendSha256 === project.sha256) {
caches.push({
cacheKey: row.cacheKey,
manifest,
path: row.path,
createdAt: row.createdAt,
lastAccessAt: row.lastAccessAt ?? row.createdAt,
});
}
}
return { cacheKey: row.cacheKey, manifest, path: row.path, createdAt: row.createdAt };
}).sort((left, right) => left.cacheKey.localeCompare(right.cacheKey));
return { projectId, caches };
catch (error) {
await quarantineSimulationCache(row, error);
}
}
caches.sort((left, right) => left.cacheKey.localeCompare(right.cacheKey));
const issues = await listSimulationQuarantine(projectId);
return { projectId, caches, quarantined: issues.length, issues };
}
scope.onmessage = async (event) => {
async function deleteUnreferencedSimulationAssets(projectId: string, sha256Values: ReadonlySet<string>): Promise<void> {
if (sha256Values.size === 0) return;
const db = await openDatabase();
const readTransaction = db.transaction(["simulation_manifest", "simulation_quarantine"], "readonly");
const rowsPromise = new Promise<SimulationManifestRow[]>((resolve, reject) => {
const request = readTransaction.objectStore("simulation_manifest").getAll();
request.onsuccess = () => resolve((request.result as SimulationManifestRow[]).filter((row) => row.projectId === projectId));
request.onerror = () => reject(request.error ?? new Error("Simulation manifest reference scan failed"));
});
const quarantinePromise = new Promise<SimulationQuarantineRow[]>((resolve, reject) => {
const request = readTransaction.objectStore("simulation_quarantine").getAll();
request.onsuccess = () => resolve((request.result as SimulationQuarantineRow[]).filter((row) => row.projectId === projectId));
request.onerror = () => reject(request.error ?? new Error("Simulation quarantine reference scan failed"));
});
const [rows, quarantine] = await Promise.all([rowsPromise, quarantinePromise]);
await transactionComplete(readTransaction);
const referenced = new Set([
...rows.map((row) => row.manifest.cacheSha256),
...quarantine.map((row) => row.row.manifest.cacheSha256),
]);
const removable: string[] = [];
for (const sha256 of sha256Values) {
if (referenced.has(sha256)) continue;
const asset = await readAssetRow(projectId, sha256);
if (asset?.mimeType === "application/x-blender-simulation-cache") removable.push(sha256);
}
if (removable.length > 0) {
const transaction = db.transaction("asset", "readwrite");
const store = transaction.objectStore("asset");
removable.forEach((sha256) => store.delete(`${projectId}:${sha256}`));
await transactionComplete(transaction);
}
db.close();
await Promise.all(removable.map((sha256) => deleteContentAsset(projectId, sha256)));
}
async function pruneSimulationCaches(
projectId: string,
maxBytes: number,
protectedCacheKeys: readonly string[] = [],
): Promise<StorageSimulationCachePruneResult> {
projectLayout(projectId);
const db = await openDatabase();
const transaction = db.transaction("simulation_manifest", "readonly");
const rows = await new Promise<SimulationManifestRow[]>((resolve, reject) => {
const request = transaction.objectStore("simulation_manifest").getAll();
request.onsuccess = () => resolve((request.result as SimulationManifestRow[]).filter((row) => row.projectId === projectId));
request.onerror = () => reject(request.error ?? new Error("Simulation manifest LRU scan failed"));
});
await transactionComplete(transaction);
db.close();
const validRows: SimulationManifestRow[] = [];
for (const row of rows) {
try {
const manifest = await verifySimulationCacheRevisionBinding(row.manifest);
if (simulationCacheKey(manifest) !== row.cacheKey) throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache index key is inconsistent");
validRows.push({ ...row, manifest, lastAccessAt: row.lastAccessAt ?? row.createdAt });
}
catch (error) {
await quarantineSimulationCache(row, error);
}
}
const activeKeys = [...simulationPlaybackActive].filter((key) => key.startsWith(`${projectId}:`)).map((key) => key.slice(projectId.length + 1));
const plan = planSimulationCacheLRU(
validRows.map((row) => ({ cacheKey: row.cacheKey, byteLength: row.manifest.byteLength, createdAt: row.createdAt, lastAccessAt: row.lastAccessAt })),
maxBytes,
[...protectedCacheKeys, ...activeKeys],
);
const removedRows = validRows.filter((row) => plan.cacheKeys.includes(row.cacheKey));
if (removedRows.length > 0) {
const writeDb = await openDatabase();
const write = writeDb.transaction("simulation_manifest", "readwrite");
const store = write.objectStore("simulation_manifest");
removedRows.forEach((row) => store.delete(row.id));
await transactionComplete(write);
writeDb.close();
for (const row of removedRows) {
const sessionKey = simulationSessionKey(projectId, row.cacheKey);
simulationPlaybackReady.delete(sessionKey);
simulationPlaybackActive.delete(sessionKey);
}
await deleteUnreferencedSimulationAssets(projectId, new Set(removedRows.map((row) => row.manifest.cacheSha256)));
}
return {
projectId,
maxBytes: plan.maxBytes,
beforeBytes: plan.beforeBytes,
remainingBytes: plan.remainingBytes,
removedBytes: plan.removedBytes,
removed: plan.cacheKeys.length,
cacheKeys: plan.cacheKeys,
protectedCacheKeys: plan.protectedCacheKeys,
budgetSatisfied: plan.budgetSatisfied,
};
}
async function getStorageBudget(projectId: string): Promise<StorageBudgetResult> {
projectLayout(projectId);
const db = await openDatabase();
const transaction = db.transaction(["project", "snapshot", "lod_manifest", "asset"], "readonly");
const getAll = <T>(storeName: "snapshot" | "lod_manifest" | "asset"): Promise<T[]> => new Promise((resolve, reject) => {
const request = transaction.objectStore(storeName).getAll();
request.onsuccess = () => resolve(request.result as T[]);
request.onerror = () => reject(request.error ?? new Error(`Storage budget ${storeName} scan failed`));
});
const projectRequest = transaction.objectStore("project").get(projectId);
const projectPromise = new Promise<ProjectRow | undefined>((resolve, reject) => {
projectRequest.onsuccess = () => resolve(projectRequest.result as ProjectRow | undefined);
projectRequest.onerror = () => reject(projectRequest.error ?? new Error("Storage budget project lookup failed"));
});
const [project, snapshots, lodRows, assets] = await Promise.all([
projectPromise,
getAll<SnapshotRow>("snapshot"),
getAll<LODManifestRow>("lod_manifest"),
getAll<AssetRow>("asset"),
]);
await transactionComplete(transaction);
db.close();
const projectSnapshots = snapshots.filter((row) => row.projectId === projectId);
const projectLod = lodRows.filter((row) => row.projectId === projectId);
const projectAssets = assets.filter((row) => row.projectId === projectId);
const simulationAssets = projectAssets.filter((row) => row.mimeType === "application/x-blender-simulation-cache");
const sum = (values: readonly number[]): number => values.reduce((total, value) => Number.isSafeInteger(value) && value >= 0 ? total + value : total, 0);
return createStorageBudget(projectId, {
projectBytes: project?.bytes ?? 0,
snapshotBytes: sum(projectSnapshots.map((row) => row.bytes)),
lodBytes: sum(projectLod.map((row) => row.bytes ?? 0)),
mediaBytes: sum(projectAssets.filter((row) => row.mimeType !== "application/x-blender-simulation-cache").map((row) => row.bytes)),
vdbBytes: sum(simulationAssets.map((row) => row.bytes)) + await measureProjectVDBBytes(projectId),
});
}
async function cleanupProject(projectId: string): Promise<StorageProjectCleanupResult> {
projectLayout(projectId);
const listed = await listAssets(projectId);
const referenced = new Set(listed.assets.map((asset) => asset.sha256));
const cleanup = await removeOrphanContentAssets(projectId, referenced);
return { projectId, ...cleanup };
}
async function handleRequest(event: MessageEvent<StorageRequest>): Promise<void> {
try {
let result;
const command = event.data.command;
if (command.type === "smoke") result = await smoke();
if (command.type === "crashForTest") throw new Error("Worker crash injection escaped the synchronous boundary");
else if (command.type === "smoke") result = await smoke();
else if (command.type === "info") result = await info();
else if (command.type === "getBudget") result = await withProjectTransaction(command.projectId, () => getStorageBudget(command.projectId));
else if (command.type === "cleanupProject") result = await withProjectTransaction(command.projectId, () => cleanupProject(command.projectId));
else if (command.type === "listRecentProjects") result = await withRecentProjectsTransaction(listRecentProjects);
else if (command.type === "touchRecentProject") result = await withRecentProjectsTransaction(() => touchRecentProject(command.project));
else if (command.type === "removeRecentProject") result = await withRecentProjectsTransaction(() => removeRecentProjectEntry(command.projectId));
else if (command.type === "ensureProject") result = await withProjectTransaction(command.projectId, () => ensureProject(command.projectId));
else if (command.type === "saveProject") result = await withProjectTransaction(command.projectId, () => saveProject(command.projectId, command.revision, command.buffer, command.faultAt));
else if (command.type === "recoverProject") result = await withProjectTransaction(command.projectId, () => recoverProject(command.projectId));
@@ -844,6 +1501,8 @@ scope.onmessage = async (event) => {
else if (command.type === "putAsset") result = await putAsset(command.projectId, command.data, command.mimeType, command.sourcePath);
else if (command.type === "readAsset") result = await readAsset(command.projectId, command.sha256);
else if (command.type === "listAssets") result = await listAssets(command.projectId);
else if (command.type === "commitTexturePaintTile") result = await withProjectTransaction(command.commit.target.projectId, () => commitTexturePaintTile(command.commit));
else if (command.type === "readTexturePaintTileBinding") result = await withProjectTransaction(command.request.projectId, () => readTexturePaintTileBinding(command.request));
else if (command.type === "saveLOD") result = await saveLOD(command.projectId, command.cacheKey, command.data);
else if (command.type === "putLODManifest") result = await putLODManifest(command.projectId, command.manifest);
else if (command.type === "getLODManifest") result = await getLODManifest(command.projectId, command.cacheKey);
@@ -851,10 +1510,14 @@ scope.onmessage = async (event) => {
else if (command.type === "readLOD") result = await readLOD(command.projectId, command.cacheKey);
else if (command.type === "deleteLOD") result = await deleteLOD(command.projectId, command.cacheKey);
else if (command.type === "pruneLOD") result = await pruneLOD(command.projectId, command.maxBytes);
else if (command.type === "putSimulationCache") result = await withProjectTransaction(command.projectId, () => putSimulationCache(command.projectId, command.manifest, command.data));
else if (command.type === "readSimulationCache") result = await readSimulationCache(command.projectId, command.cacheKey);
else if (command.type === "readSimulationCacheFrame") result = await readSimulationCacheFrame(command.projectId, command.cacheKey, command.frame);
else if (command.type === "listSimulationCaches") result = await listSimulationCaches(command.projectId);
else if (command.type === "putSimulationCache") result = await withProjectTransaction(command.projectId, () => putSimulationCache(command.projectId, command.manifest, command.data, event.data.requestId));
else if (command.type === "prepareSimulationCachePlayback") result = await withProjectTransaction(command.projectId, () => prepareSimulationCachePlayback(command.projectId, command.cacheKey, event.data.requestId));
else if (command.type === "releaseSimulationCachePlayback") result = await withProjectTransaction(command.projectId, () => releaseSimulationCachePlayback(command.projectId, command.cacheKey));
else if (command.type === "readSimulationCache") result = await withProjectTransaction(command.projectId, () => readSimulationCache(command.projectId, command.cacheKey, event.data.requestId));
else if (command.type === "readSimulationCacheFrame") result = await withProjectTransaction(command.projectId, () => readSimulationCacheFrame(command.projectId, command.cacheKey, command.frame, event.data.requestId));
else if (command.type === "listSimulationCaches") result = await withProjectTransaction(command.projectId, () => listSimulationCaches(command.projectId));
else if (command.type === "pruneSimulationCaches") result = await withProjectTransaction(command.projectId, () => pruneSimulationCaches(command.projectId, command.maxBytes, command.protectedCacheKeys));
else if (command.type === "cancelRequest") return;
else throw new Error("Unknown storage command");
if (result && "data" in result && result.data instanceof ArrayBuffer) scope.postMessage({ requestId: event.data.requestId, ok: true, result }, [result.data]);
else if (result && "buffer" in result && result.buffer instanceof ArrayBuffer) scope.postMessage({ requestId: event.data.requestId, ok: true, result }, [result.buffer]);
@@ -867,4 +1530,16 @@ scope.onmessage = async (event) => {
errorCode: error && typeof error === "object" && "code" in error ? (error as { code: StorageResponse["errorCode"] }).code : undefined,
});
}
finally {
cancelledRequests.delete(event.data.requestId);
}
}
scope.onmessage = (event) => {
if (event.data.command.type === "crashForTest") throw new Error("WORKER_CRASH_INJECTED");
if (event.data.command.type === "cancelRequest") {
cancelRequest(event.data.command.targetRequestId);
return;
}
void handleRequest(event);
};

View File

@@ -8,10 +8,22 @@ import {
uploadNanoVDBFloat32GridPaged,
} from "../render/nanovdb-volume-renderer";
import { createResumableHttpNanoVDBRangeSource } from "../volume/nanovdb-stream";
import { loadNanoVDBGridPage, loadNanoVDBViewportAsset, planNanoVDBGridResidency } from "../volume/nanovdb-viewport";
import { loadNanoVDBGridPage, loadNanoVDBViewportAsset, planNanoVDBGridResidency, rebuildNanoVDBGridAfterDeviceLoss } from "../volume/nanovdb-viewport";
const scope = self as unknown as { onmessage: (() => void) | null; postMessage: (value: unknown) => void };
async function readWords(device: GPUDevice, buffer: GPUBuffer, byteLength: number): Promise<Uint32Array> {
const readback = device.createBuffer({ size: byteLength, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
const encoder = device.createCommandEncoder();
encoder.copyBufferToBuffer(buffer, 0, readback, 0, byteLength);
device.queue.submit([encoder.finish()]);
await readback.mapAsync(GPUMapMode.READ);
const words = new Uint32Array(readback.getMappedRange().slice(0));
readback.unmap();
readback.destroy();
return words;
}
scope.onmessage = (): void => {
void (async () => {
const manifest = await (await fetch("/__vdb_fixture__/manifest", { cache: "no-store" })).json() as NanoVDBBundleManifestIR;
@@ -199,10 +211,19 @@ 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));
const visiblePageIds = [2, 1, 0, 2];
const replay = await rebuildNanoVDBGridAfterDeviceLoss(
recoveredDevice,
pagingManifest,
density.name,
pageSource,
visiblePageIds,
new AbortController().signal,
);
const recoveredDemand = replay.grid;
const recoveredDemandWords = await readNanoVDBWordsWebGPU(recoveredDevice, recoveredDemand, [0, pageByteLength]);
const recoveredPageTable = [...await readWords(recoveredDevice, recoveredDemand.pageTable, recoveredDemand.pageCount * 4)];
const recoveredResidentPages = [...recoveredDemand.residentVirtualPages];
recoveredDemand.dispose();
const recovered = uploadNanoVDBFloat32GridPaged(recoveredDevice, payload, 256 * 1024, residentBudget);
const after = await sampleNanoVDBFloat32WebGPU(recoveredDevice, recovered, native.map((sample) => sample.coord));
@@ -227,7 +248,20 @@ scope.onmessage = (): void => {
demandPaging,
globalResidency,
paging,
deviceLoss: { reason: loss.reason, firstGeneration, recoveredGeneration, recoveredDemandWords },
deviceLoss: {
reason: loss.reason,
firstGeneration,
recoveredGeneration,
residentBeforeReplay: replay.residentBeforeReplay,
visiblePageIds: replay.plan.visiblePageIds,
replayedPageIds: replay.plan.replayedPageIds,
skippedPageIds: replay.plan.skippedPageIds,
recoveredResidentPages,
recoveredPageTable,
oldNonVisiblePage: lastPageIndex,
recoveredDemandWords,
expectedDemandWords: [new Uint32Array(page0)[0], new Uint32Array(page1)[0]],
},
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

@@ -34,21 +34,29 @@ import { NANOVDB_VIEWPORT_PREVIEW_SIZE, NanoVDBViewportRenderSession, renderNano
import { createNanoVDBViewportObject } from "../three-adapter/volume";
import {
configurePBRLight,
configurePBRCamera,
configurePBRRenderer,
createPBRLight,
createPBRMaterial,
PBR_SHADOW_MAP_DIMENSION,
setPBRMaterialSelected,
} from "../three-adapter/pbr";
import { GPUTextureStore } from "../three-adapter/texture-assets";
import { planPBRLightingBudget } from "../../../protocol/render-budget";
import { applyCurveHandlePreview, applyNonMeshElementSelection, applyNonMeshTransform, createNonMeshObject } from "../three-adapter/nonmesh";
import {
applyGreasePencilPointSelection,
applyGreasePencilPointPreview,
applyGreasePencilTransform,
createGreasePencilObject,
greasePencilMarqueeCandidates,
greasePencilPointRef,
type GreasePencilPointRef,
} from "../three-adapter/grease-pencil";
import { selectGreasePencilMarquee, type GreasePencilDrawingScopeIR, type GreasePencilMarqueeBoxIR } from "../../../protocol/grease-pencil-marquee";
import { applyOrbitDelta, cameraState, VIEWPORT_DEFAULT_ORBIT, orbitPosition, type ViewportOrbitState } from "../../../protocol/viewport-camera";
import { validatePaintDepthVisibilityRequest, type PaintDepthVisibilityRequestIR } from "../../../protocol/paint-depth-visibility";
import { samplePaintDepthVisibilityGPU } from "../three-adapter/paint-depth-visibility";
const workerScope = self as unknown as {
onmessage: ((event: MessageEvent<OffscreenViewportRequest>) => void) | null;
@@ -62,13 +70,11 @@ let importedLights: Group | null = null;
let contextLost = false;
let width = 1;
let height = 1;
let yaw = -Math.PI / 4;
let pitch = 0.55;
let distance = 7;
let orbit: ViewportOrbitState = { ...VIEWPORT_DEFAULT_ORBIT, target: [...VIEWPORT_DEFAULT_ORBIT.target] as [number, number, number] };
let editMode = false;
let selectionMode: MeshElementMode = "FACE";
let currentSnapshot: SceneSnapshotIR | null = null;
const textureStore = new GPUTextureStore();
const textureStore = new GPUTextureStore("THREE_WEBGL2");
const raycaster = new Raycaster();
raycaster.params.Points.threshold = 0.14;
const objectById = new Map<string, Object3D>();
@@ -88,8 +94,8 @@ function post(message: OffscreenViewportResponse): void {
function render(): void {
if (contextLost || !renderer || !scene || !camera) return;
camera.position.set(distance * Math.cos(pitch) * Math.cos(yaw), distance * Math.cos(pitch) * Math.sin(yaw), distance * Math.sin(pitch));
camera.lookAt(0, 0, 0);
camera.position.set(...orbitPosition(orbit));
camera.lookAt(...orbit.target);
renderer.render(scene, camera);
publishCurveGizmoFrame();
const gl = renderer.getContext();
@@ -109,7 +115,7 @@ function render(): void {
for (let index = 0; index < pixels.length; index += 4) {
if (pixels[index] > 50 || pixels[index + 1] > 50 || pixels[index + 2] > 50) visiblePixels += 1;
}
post({ type: "frame", visiblePixels });
post({ type: "frame", visiblePixels, camera: cameraState(orbit) });
}
function publishCurveGizmoFrame(): void {
@@ -155,6 +161,15 @@ function matrixFor(node: SceneSnapshotIR["nodes"][number]): Matrix4 {
);
}
function applyCamera(snapshot: SceneSnapshotIR): void {
if (!camera) return;
const cameraObjectId = snapshot.scenes[0]?.cameraObjectId;
const cameraNode = snapshot.nodes.find((node) => node.id === cameraObjectId && node.type === "CAMERA");
const definition = snapshot.cameras.find((candidate) => candidate.id === cameraNode?.dataId);
if (!cameraNode || !definition) return;
configurePBRCamera(camera, definition);
}
function geometryFrom(payload: MeshGeometryBuffer, materialCount: number): BufferGeometry {
const sourcePositions = new Float32Array(payload.positions);
const sourceIndices = new Uint32Array(payload.indices);
@@ -283,7 +298,7 @@ function applyTextureAssets(assets: readonly GPUTextureAsset[]): void {
if (currentSnapshot && root) textureStore.applySnapshotMaterials(root, currentSnapshot);
const world = currentSnapshot?.worlds.find((candidate) => candidate.id === currentSnapshot?.scenes[0]?.worldId) ?? currentSnapshot?.worlds[0];
if (scene && renderer) void textureStore.applyEnvironment(scene, renderer, world, true);
post({ type: "textureStatus", loaded: status.loaded, rejected: status.rejected, bytes: status.bytes, errors: status.errors, errorCodes: status.errorCodes });
post({ type: "textureStatus", loaded: status.loaded, rejected: status.rejected, bytes: status.bytes, errors: status.errors, errorCodes: status.errorCodes, budget: status.budget });
render();
}).catch((error) => post({ type: "error", message: error instanceof Error ? error.message : "Texture upload failed" }));
}
@@ -393,19 +408,26 @@ function setSnapshot(snapshot: SceneSnapshotIR, buffers: MeshGeometryBuffer[], n
greasePencilOnionStrokeCount += Number(object.userData.greasePencilOnionStrokeCount ?? 0);
greasePencilCount++;
}
post({ type: "snapshotStatus", nonMeshCount, nonMeshBlockedCount, greasePencilCount, greasePencilBlockedCount, greasePencilOnionStrokeCount });
const lightingBudget = planPBRLightingBudget(snapshot, "THREE_WEBGL2");
post({ type: "snapshotStatus", nonMeshCount, nonMeshBlockedCount, greasePencilCount, greasePencilBlockedCount, greasePencilOnionStrokeCount, renderBudget: lightingBudget });
const world = snapshot.worlds.find((candidate) => candidate.id === snapshot.scenes[0]?.worldId) ?? snapshot.worlds[0];
const sceneDefinition = snapshot.scenes.find((candidate) => candidate.id === snapshot.sceneId) ?? snapshot.scenes[0];
scene.background = world ? new Color().setRGB(...world.color) : new Color(0x25272b);
if (renderer) configurePBRRenderer(renderer, sceneDefinition?.colorManagement?.exposure ?? world?.exposure ?? 0);
applyCamera(snapshot);
const rendered = new Set(lightingBudget.renderedLightNodeIds);
const shadowed = new Set(lightingBudget.shadowLightNodeIds);
if (importedLights) {
const lights = new Map(snapshot.lights.map((definition) => [definition.id, definition]));
for (const node of snapshot.nodes) {
if (node.type !== "LIGHT" || !node.visible || !node.dataId) continue;
if (node.type !== "LIGHT" || !node.visible || !node.dataId || !rendered.has(node.id)) continue;
const definition = lights.get(node.dataId);
if (!definition) continue;
const light = createPBRLight(definition);
configurePBRLight(light, node, importedLights);
configurePBRLight(light, node, importedLights, {
shadowEnabled: shadowed.has(node.id),
shadowMapDimension: lightingBudget.budget.shadowMapDimension,
});
light.name = node.name;
importedLights.add(light);
objectById.set(node.id, light);
@@ -415,7 +437,7 @@ function setSnapshot(snapshot: SceneSnapshotIR, buffers: MeshGeometryBuffer[], n
render();
}
function setSelection(ids: string[], elements: Array<{ dataId: string; kind: NonMeshElementKind; index: number }>, greasePencilPoints: GreasePencilPointRef[]): void {
function setSelection(ids: string[], elements: Array<{ dataId: string; kind: NonMeshElementKind; index: number }>, greasePencilPoints: GreasePencilPointRef[], greasePencilSelectionRevision: number): void {
const selected = new Set(ids);
const visited = new Set<Object3D>();
for (const [id, object] of objectById) {
@@ -444,9 +466,10 @@ function setSelection(ids: string[], elements: Array<{ dataId: string; kind: Non
if (root) applyNonMeshElementSelection(root, selection);
if (root) applyGreasePencilPointSelection(root, greasePencilPoints);
render();
post({ type: "greasePencilSelectionStatus", selectionRevision: greasePencilSelectionRevision, pointIds: greasePencilPoints.map((point) => point.pointId) });
}
function pick(x: number, y: number, additive: boolean): void {
function pick(x: number, y: number, additive: boolean, baseSelectionRevision: number): void {
if (!root || !camera) return;
raycaster.setFromCamera(new Vector2(x, y), camera);
const hits = raycaster.intersectObjects(root.children, true);
@@ -455,7 +478,7 @@ function pick(x: number, y: number, additive: boolean): void {
: undefined;
if (greasePencilHit?.index !== undefined) {
const point = greasePencilPointRef(greasePencilHit.object, greasePencilHit.index);
if (point) post({ type: "greasePencilPointSelected", point, additive });
if (point) post({ type: "greasePencilPointSelected", point, additive, baseSelectionRevision });
return;
}
const preferredNonMeshHit = editMode ? hits.find((intersection) => intersection.index !== undefined && Array.isArray(intersection.object.userData.nonMeshPointKindMap)) : undefined;
@@ -511,6 +534,41 @@ function pick(x: number, y: number, additive: boolean): void {
else if (typeof objectId === "string") post({ type: "selected", objectId, additive });
}
function greasePencilMarquee(
drawing: GreasePencilDrawingScopeIR,
box: GreasePencilMarqueeBoxIR,
baseRevision: number,
baseSelectionRevision: number,
additive: boolean,
): void {
if (!root || !camera || !currentSnapshot) return;
const result = selectGreasePencilMarquee({
schemaVersion: 1,
baseRevision,
baseSelectionRevision,
drawing,
box,
candidates: greasePencilMarqueeCandidates(root, drawing, camera),
}, currentSnapshot.revision);
post({ type: "greasePencilMarqueeSelected", result, additive });
}
function paintDepthVisibility(requestId: string, requestValue: PaintDepthVisibilityRequestIR): void {
try {
if (!renderer || !scene || !camera || !currentSnapshot) throw new Error("PAINT_DEPTH_UNAVAILABLE: Offscreen viewport is not ready");
const request = validatePaintDepthVisibilityRequest(requestValue, currentSnapshot.revision);
const node = currentSnapshot.nodes.find((candidate) => candidate.id === request.objectId && candidate.dataId === request.meshId && candidate.visible);
const object = node ? objectById.get(node.id) : undefined;
if (!object) throw new Error("PAINT_DEPTH_UNAVAILABLE: Paint object is not available in the current viewport");
const result = samplePaintDepthVisibilityGPU({ renderer, scene, camera, object, request, backend: "OFFSCREEN_WEBGL2" });
post({ type: "paintDepthVisibilityResult", requestId, result });
render();
}
catch (error) {
post({ type: "paintDepthVisibilityError", requestId, message: error instanceof Error ? error.message : "PAINT_DEPTH_UNAVAILABLE: GPU depth readback failed" });
}
}
workerScope.onmessage = (event): void => {
try {
const message = event.data;
@@ -537,7 +595,7 @@ workerScope.onmessage = (event): void => {
const light = new DirectionalLight(0xffffff, 2.5);
light.position.set(4, -5, 8);
light.castShadow = true;
light.shadow.mapSize.set(1024, 1024);
light.shadow.mapSize.set(PBR_SHADOW_MAP_DIMENSION, PBR_SHADOW_MAP_DIMENSION);
light.shadow.bias = -0.0005;
light.shadow.normalBias = 0.03;
scene.add(light, light.target, new GridHelper(20, 20, 0x60656e, 0x383b42), root, importedLights);
@@ -552,7 +610,7 @@ workerScope.onmessage = (event): void => {
void refreshVolumes();
}
else if (message.type === "resize") resize(message.width, message.height, message.pixelRatio);
else if (message.type === "selection") setSelection(message.objectIds, message.elements, message.greasePencilPoints);
else if (message.type === "selection") setSelection(message.objectIds, message.elements, message.greasePencilPoints, message.greasePencilSelectionRevision);
else if (message.type === "interaction") {
editMode = message.editMode;
selectionMode = message.selectionMode;
@@ -574,12 +632,14 @@ workerScope.onmessage = (event): void => {
render();
}
else if (message.type === "orbit") {
yaw -= message.deltaX * 0.008;
pitch = Math.max(-1.45, Math.min(1.45, pitch + message.deltaY * 0.008));
distance = Math.max(0.2, Math.min(500, distance * Math.exp(message.zoom * 0.001)));
orbit = applyOrbitDelta(orbit, message.deltaX, message.deltaY, message.zoom);
render();
}
else if (message.type === "pick") pick(message.x, message.y, message.additive);
else if (message.type === "pick") pick(message.x, message.y, message.additive, message.baseSelectionRevision);
else if (message.type === "greasePencilMarquee") {
greasePencilMarquee(message.drawing, message.box, message.baseRevision, message.baseSelectionRevision, message.additive);
}
else if (message.type === "paintDepthVisibility") paintDepthVisibility(message.requestId, message.request);
else if (message.type === "dispose") {
clearRoot();
volumeRenderGeneration++;

View File

@@ -1,6 +1,6 @@
import type { ErrorReport } from "../../../protocol/error";
import { parseSceneDelta, type SceneDelta } from "../../../protocol/scene-delta";
import type { MeshGeometryBuffer, WebEngineLODError, WebEngineLODLevelResult, WebEngineOpenResourceStatus, WebEngineRequest, WebEngineResponse, WebEngineResult, WebEngineStatus } from "../../../protocol/web-engine";
import type { MeshGeometryBuffer, WebEngineEditCommand, WebEngineLODError, WebEngineLODLevelResult, WebEngineOpenResourceStatus, WebEngineRequest, WebEngineResponse, WebEngineResult, WebEngineStatus } from "../../../protocol/web-engine";
import { parseSceneSnapshotIR } from "../../../protocol/scene-ir";
import { parseLODManifest, type SimplifyResult } from "../../../protocol/simplify";
import type { LODGenerationRequest } from "../../../protocol/lod";
@@ -12,11 +12,23 @@ import { cloneMeshGeometryBuffers, diffMeshGeometryBuffers } from "../../../prot
import { gateSculptCapability, parseSculptMeshAttributes, parseSculptStroke } from "../../../protocol/sculpt";
import { gateGeometryNodeGraph } from "../../../protocol/geometry-nodes";
import { gateShaderGraph } from "../../../protocol/shader-graph";
import { gateNlaTracks } from "../../../protocol/nla";
import { compileShaderGraph, type ShaderCompileReport } from "../../../protocol/shader-compiler";
import { gateNlaTracks, moveNlaStrip } from "../../../protocol/nla";
import { blockedGate, capabilityIssue, type CapabilityGateResult } from "../../../protocol/capability-gates";
import { gateRenderCapability } from "../../../protocol/render-capabilities";
import { gateNonMeshData } from "../../../protocol/non-mesh";
import { chunkNonMeshGeometry, nonMeshChunkTransferables, type NonMeshGeometryChunk, type NonMeshAttributeArray } from "../../../protocol/nonmesh-binary";
import {
EXTERNAL_VFONT_MAX_BYTES,
validateExternalVFontImport,
validateExternalVFontMainImportProof,
} from "../../../protocol/external-vfont";
import { validateGreasePencilReorderCommand } from "../../../protocol/grease-pencil-reorder";
import { PaintStrokeSessionError, PaintStrokeSessionStore } from "../../../protocol/paint-stroke-session";
import {
gatePaintPBVHCapability,
PAINT_PBVH_WASM_ENTRYPOINT,
} from "../../../protocol/paint-pbvh-capability";
type WasmModule = {
_malloc: (size: number) => number;
@@ -97,6 +109,7 @@ let sourceBlendBuffer: ArrayBuffer | null = null;
let sourceBlendRevision = -1;
const cancelledOpenRequests = new Set<string>();
const activeOpenResources = new Map<string, { liveInputBytes: number; liveNativeHandles: number; liveStagingFiles: number }>();
const paintStrokeSessions = new PaintStrokeSessionStore();
function openResourceStatus(): WebEngineOpenResourceStatus {
return [...activeOpenResources.values()].reduce<WebEngineOpenResourceStatus>((status, item) => ({
@@ -140,11 +153,78 @@ function reportProtocolError(error: unknown, fallbackCode: ErrorReport["code"]):
throw report(fallbackCode, error instanceof Error ? error.message : "Capability payload is invalid");
}
async function assertExternalVFontImport(payload: Extract<WebEngineEditCommand, { type: "importVFont" }>): Promise<void> {
try {
validateExternalVFontMainImportProof(payload);
}
catch (error) {
reportProtocolError(error, "NON_MESH_BINARY_INVALID");
}
if (typeof payload.base64 !== "string" || payload.base64.length === 0 ||
payload.base64.length > Math.ceil(EXTERNAL_VFONT_MAX_BYTES / 3) * 4 + 4) {
throw report("NON_MESH_DATA_BUDGET_EXCEEDED", "External VFont command exceeds the 32 MiB payload budget");
}
let data: ArrayBuffer;
try {
const binary = atob(payload.base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
data = bytes.buffer;
}
catch {
throw report("NON_MESH_BINARY_INVALID", "External VFont command base64 is invalid");
}
try {
await validateExternalVFontImport({
sourcePath: payload.sourcePath,
mimeType: payload.mimeType,
byteLength: payload.byteLength,
sha256: payload.sha256,
data,
});
}
catch (error) {
reportProtocolError(error, "NON_MESH_BINARY_INVALID");
}
}
function rejectCapabilityGate(gate: CapabilityGateResult): never {
const issue = gate.issues[0];
throw report(issue?.code ?? "CAPABILITY_MISSING", `N-${gate.taskId.slice(2)} ${gate.capability} blocked${issue ? `: ${issue.message}` : ""}`);
}
function prepareNlaMoveCommand(
payload: Extract<WebEngineEditCommand, { type: "moveNLAStrip" }>,
): Extract<WebEngineEditCommand, { type: "setNLAStack" }> {
const snapshot = currentSnapshot;
if (!snapshot?.nodes.some((node) => node.id === payload.objectId)) {
throw report("NLA_PATH_INCOMPATIBLE", `NLA owner does not exist: ${payload.objectId}`);
}
if (payload.baseRevision !== snapshot.revision) {
throw report("REVISION_CONFLICT", "NLA operator base revision does not match the current SceneIR");
}
const actionIds = new Set(snapshot.animations.map((animation) => animation.id));
const actionChannelPaths = new Map(snapshot.animations.map((animation) => [
animation.id,
new Set(animation.channels.map((channel) => channel.path)),
]));
try {
return {
type: "setNLAStack",
objectId: payload.objectId,
baseRevision: payload.baseRevision,
tracks: moveNlaStrip(
(snapshot.nlaTracks ?? []).filter((track) => track.ownerId === payload.objectId),
payload,
{ actionIds, actionChannelPaths, ownerId: payload.objectId },
),
};
}
catch (error) {
reportProtocolError(error, "NLA_INVALID_STACK");
}
}
function sculptMeshGate(meshId: string): CapabilityGateResult {
const mesh = currentSnapshot?.meshes.find((candidate) => candidate.id === meshId);
const objectCount = currentSnapshot?.nodes.filter((node) => node.type === "MESH" && node.dataId === meshId).length ?? 0;
@@ -309,6 +389,16 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
if (!Number.isSafeInteger(payload.activeTextBox) || payload.activeTextBox < 0 || payload.activeTextBox >= payload.textBoxes.length) throw report("NON_MESH_PROPERTY_INVALID", "Font active text box is outside the text box array");
return;
}
case "importVFont": {
try {
validateExternalVFontMainImportProof(payload);
return;
}
catch (error) {
reportProtocolError(error, "NON_MESH_BINARY_INVALID");
}
return;
}
case "setFontLinks": {
const data = currentSnapshot?.nonMeshData?.find((candidate) => candidate.id === payload.dataId);
if (!data || data.type !== "FONT") throw report("NON_MESH_DATA_UNSUPPORTED", `N-015 font data block is unavailable: ${payload.dataId}`);
@@ -345,7 +435,21 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
return;
}
case "moveGreasePencilLayer": {
if (typeof payload.dataId !== "string" || typeof payload.layerId !== "string" || !["UP", "DOWN", "TOP", "BOTTOM"].includes(payload.direction)) throw report("GREASE_PENCIL_SCHEMA_INVALID", "Grease Pencil layer move is invalid");
try {
validateGreasePencilReorderCommand(payload, currentSnapshot?.revision ?? -1, currentSnapshot?.greasePencils ?? []);
}
catch (error) {
reportProtocolError(error, "GREASE_PENCIL_SCHEMA_INVALID");
}
return;
}
case "moveGreasePencilFrame": {
try {
validateGreasePencilReorderCommand(payload, currentSnapshot?.revision ?? -1, currentSnapshot?.greasePencils ?? []);
}
catch (error) {
reportProtocolError(error, "GREASE_PENCIL_SCHEMA_INVALID");
}
return;
}
case "insertGreasePencilFrame":
@@ -379,8 +483,13 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
const object = currentSnapshot?.nodes.find((candidate) => candidate.id === payload.objectId && candidate.type === "MESH");
const mesh = currentSnapshot?.meshes.find((candidate) => candidate.id === object?.dataId);
if (!object || !mesh || typeof payload.vertexGroup !== "string" || payload.vertexGroup.length === 0 || new TextEncoder().encode(payload.vertexGroup).byteLength > 63 || !Array.isArray(payload.indices) || !Array.isArray(payload.values) || payload.indices.length === 0 || payload.indices.length !== payload.values.length || payload.indices.length > 1_000_000) throw report("PAINT_SCHEMA_INVALID", "Vertex weight patch is invalid");
if (payload.indices.some((index) => !Number.isSafeInteger(index) || index < 0 || index >= mesh.vertexCount) || payload.values.some((value) => typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) || (payload.normalize !== undefined && typeof payload.normalize !== "boolean") || (payload.mirror !== undefined && typeof payload.mirror !== "boolean")) throw report("PAINT_SCHEMA_INVALID", "Vertex weight patch values are outside the bounded domain");
if (payload.mirror) throw report("CAPABILITY_MISSING", "N-017 topology mirror requires a verified mesh symmetry map");
const uniqueIndices = new Set<number>();
if (payload.indices.some((index) => {
if (!Number.isSafeInteger(index) || index < 0 || index >= mesh.vertexCount || uniqueIndices.has(index)) return true;
uniqueIndices.add(index);
return false;
}) || payload.values.some((value) => typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) || (payload.normalize !== undefined && typeof payload.normalize !== "boolean") || (payload.limit !== undefined && (!Number.isSafeInteger(payload.limit) || payload.limit < 1 || payload.limit > 32)) || (payload.mirror !== undefined && typeof payload.mirror !== "boolean") || (payload.mirrorAxis !== undefined && (!Number.isSafeInteger(payload.mirrorAxis) || payload.mirrorAxis < 0 || payload.mirrorAxis > 2)) || (payload.mirrorTolerance !== undefined && (typeof payload.mirrorTolerance !== "number" || !Number.isFinite(payload.mirrorTolerance) || payload.mirrorTolerance <= 0 || payload.mirrorTolerance > 1))) throw report("PAINT_SCHEMA_INVALID", "Vertex weight patch values are outside the bounded domain");
if (!payload.mirror && (payload.mirrorAxis !== undefined || payload.mirrorTolerance !== undefined)) throw report("PAINT_SCHEMA_INVALID", "Vertex weight mirror axis/tolerance require mirror=true");
return;
}
case "setLightProperties": {
@@ -949,11 +1058,17 @@ function progress(requestId: string, phase: "started" | "progress" | "completed"
scope.postMessage({ kind: "progress", requestId, progress: { requestId, operation: "blend.open", phase, fraction, message, stage, cancellable } });
}
scope.onmessage = async (event) => {
function openCancellationWindow(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 16));
}
async function handleRequest(event: MessageEvent<WebEngineRequest>): Promise<void> {
const request = event.data;
try {
let result: WebEngineResult;
switch (request.command.type) {
case "crashForTest":
throw report("WORKER_TERMINATED", "Worker crash injection escaped the synchronous boundary");
case "init":
result = baseResult();
await initialize();
@@ -966,6 +1081,7 @@ scope.onmessage = async (event) => {
let candidateHandle = 0;
try {
progress(request.requestId, "started", 0, "Preparing isolated Main", "NATIVE_INITIALIZE", true);
await openCancellationWindow();
await initialize();
throwIfOpenCancelled(request.requestId);
if (!wasmFactory || !wasmBinary) throw report("WASM_INIT_FAILED", "WebEngine isolated open module is unavailable");
@@ -987,7 +1103,7 @@ scope.onmessage = async (event) => {
resource.liveInputBytes = 0;
}
progress(request.requestId, "progress", 0.7, "Isolated Main opened", "NATIVE_OPENED", true);
await new Promise<void>((resolve) => setTimeout(resolve, 0));
await openCancellationWindow();
throwIfOpenCancelled(request.requestId);
const previousModule = module;
@@ -1000,6 +1116,7 @@ scope.onmessage = async (event) => {
throwIfOpenCancelled(request.requestId);
const sourceCopy = request.command.buffer.slice(0);
currentSnapshot = scene.snapshot;
paintStrokeSessions.clear();
sourceBlendBuffer = sourceCopy;
sourceBlendRevision = scene.snapshot.revision;
result = { status: status(), ...publishFullScene(scene) };
@@ -1040,11 +1157,87 @@ scope.onmessage = async (event) => {
await initialize();
result = { status: status(), delta: readDelta() };
break;
case "beginPaintStroke":
if (!currentSnapshot) throw report("PAINT_SCHEMA_INVALID", "Paint pointer sessions require an open Main scene");
result = { status: status(), paintStrokeSession: paintStrokeSessions.begin(request.command.session, currentSnapshot.revision) };
break;
case "appendPaintStrokeChunk":
if (!currentSnapshot) throw report("PAINT_SCHEMA_INVALID", "Paint pointer sessions require an open Main scene");
result = { status: status(), paintStrokeSession: paintStrokeSessions.append(request.command.chunk, currentSnapshot.revision) };
break;
case "cancelPaintStroke":
result = { status: status(), paintStrokeSession: paintStrokeSessions.cancel(request.command.session) };
break;
case "queryPaintPBVHCapability": {
await initialize();
const activeObject = currentSnapshot?.nodes.find((node) => node.id === currentSnapshot?.activeObjectId && node.type === "MESH");
const nativeEntrypointPresent = typeof (module as unknown as Record<string, unknown> | null)?.[PAINT_PBVH_WASM_ENTRYPOINT] === "function";
try {
const gate = gatePaintPBVHCapability(request.command.request, {
nativeEntrypointPresent,
sessionContextReady: false,
verifiedBrushes: new Set(),
currentRevision: currentSnapshot?.revision,
currentObjectId: activeObject?.id,
currentMeshId: activeObject?.dataId ?? undefined,
});
result = { status: status(), capabilityGate: gate };
}
catch (error) {
reportProtocolError(error, "PAINT_SCHEMA_INVALID");
}
break;
}
case "commitPaintStroke": {
await initialize();
const beforeSnapshot = currentSnapshot;
if (!beforeSnapshot) throw report("PAINT_SCHEMA_INVALID", "Paint pointer sessions require an open Main scene");
const staged = paintStrokeSessions.commit(request.command.session, beforeSnapshot.revision);
assertFutureCapability(staged.command);
const input = copyCommand(staged.command);
try {
if (!module || module._web_engine_apply_command(handle, input.pointer, input.length) !== 0) throw nativeError("INVALID_ARGUMENT");
}
finally {
module?._free(input.pointer);
}
const scene = await readSnapshotResult();
currentSnapshot = scene.snapshot;
result = {
status: status(),
...publishIncrementalScene(scene),
delta: readDelta(),
paintStrokeSession: { ...staged.receipt, state: "COMMITTED", committedRevision: scene.snapshot.revision },
};
break;
}
case "applyCommand": {
await initialize();
const beforeSnapshot = currentSnapshot;
const payload = request.command.payload;
assertFutureCapability(payload);
const nativePayload = payload.type === "moveNLAStrip" ? prepareNlaMoveCommand(payload) : payload;
if (payload.type === "importVFont") await assertExternalVFontImport(payload);
assertFutureCapability(nativePayload);
let shaderCompile: ShaderCompileReport | undefined;
if (payload.type === "setShaderGraph") {
shaderCompile = compileShaderGraph(
payload.graph,
beforeSnapshot?.materials.find((material) => material.id === payload.materialId),
{
imageIds: new Set(beforeSnapshot?.images.map((image) => image.id) ?? []),
blockedImageIds: new Set(beforeSnapshot?.images
.filter((image) => image.libraryLinked || image.assetStatus === "LINKED_LIBRARY_REQUIRED" || image.assetStatus === "MISSING" || image.assetStatus === "CORRUPT")
.map((image) => image.id) ?? []),
textureIdentities: new Map(beforeSnapshot?.images.map((image) => [image.id, {
assetId: image.assetId,
sha256: image.sha256,
colorSpace: image.colorSpace,
}] as const) ?? []),
},
);
const compileIssue = shaderCompile.issues[0];
if (shaderCompile.status === "BLOCKED" && compileIssue) throw report(compileIssue.code, compileIssue.message, true);
}
if (payload.type === "decimateMesh" || payload.type === "previewDecimateMesh") assertModifierStackEvaluated(beforeSnapshot, payload.meshId);
if (payload.type === "decimateMesh" || payload.type === "previewDecimateMesh") assertSkinSimplifyEvaluable(beforeSnapshot, payload.meshId, payload.profile);
if (payload.type === "previewDecimateMesh") {
@@ -1102,7 +1295,7 @@ scope.onmessage = async (event) => {
result = { status: status(), ...publishIncrementalScene(scene), delta: readDelta() };
break;
}
const input = copyCommand(payload);
const input = copyCommand(nativePayload);
try {
if (!module || module._web_engine_apply_command(handle, input.pointer, input.length) !== 0) {
throw nativeError("INVALID_ARGUMENT");
@@ -1114,7 +1307,13 @@ scope.onmessage = async (event) => {
const scene = await readSnapshotResult();
currentSnapshot = scene.snapshot;
const simplify = simplifyReport(beforeSnapshot, scene, payload);
result = { status: status(), ...publishIncrementalScene(scene), delta: readDelta(), simplify };
result = {
status: status(),
...publishIncrementalScene(scene),
delta: readDelta(),
simplify,
shaderCompile,
};
break;
}
case "generateLOD": {
@@ -1287,6 +1486,7 @@ scope.onmessage = async (event) => {
handle = 0;
currentSnapshot = null;
currentGeometryBuffers = [];
paintStrokeSessions.clear();
result = { status: { ready: false, liveHandles: 0, allocatedBytes: 0 } };
break;
}
@@ -1314,8 +1514,15 @@ scope.onmessage = async (event) => {
scope.postMessage({ kind: "result", requestId: request.requestId, ok: true, result }, transfer);
}
catch (error) {
const native = typeof error === "object" && error !== null && "code" in error ? error as ErrorReport : null;
const native = error instanceof PaintStrokeSessionError
? report(error.code, error.message)
: typeof error === "object" && error !== null && "code" in error ? error as ErrorReport : null;
const failure = native ?? report("WASM_INIT_FAILED", error instanceof Error ? error.message : "WebEngine request failed");
scope.postMessage({ kind: "result", requestId: request.requestId, ok: false, error: failure });
}
}
scope.onmessage = (event) => {
if (event.data.command.type === "crashForTest") throw new Error("WORKER_CRASH_INJECTED");
void handleRequest(event);
};