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

View File

@@ -16,12 +16,12 @@
"js": {
"fileName": "web_engine.js",
"url": "/vendor/blender/single/web_engine.js",
"sha256": "eedb8cedeb2190fbece91d58cd5dd568356caf42191517aff2085911177157ca"
"sha256": "25aa51361aa8c95479873240c776d6213c1092dd480ee2a0036d2cbbd561dcad"
},
"wasm": {
"fileName": "web_engine.wasm",
"url": "/vendor/blender/single/web_engine.wasm",
"sha256": "f079e2221b501eee7f0b8e3290c22f37210ba44ee7a392c7fa817fde16b9ee5e"
"sha256": "7f3a50f1d41b2ac0e6366e84caaabefc3cdfc47c055d5ea459abec654da6fcc0"
}
}
},
@@ -36,17 +36,17 @@
"js": {
"fileName": "web_engine.js",
"url": "/vendor/blender/pthread/web_engine.js",
"sha256": "5a78f2e27969074f72902805c32453c980841c390db6c48eb9d43787d33a8837"
"sha256": "a64288eeb74fb0696d38d785348f0ba37e7ae716b5a341bf181f178f78e1324d"
},
"wasm": {
"fileName": "web_engine.wasm",
"url": "/vendor/blender/pthread/web_engine.wasm",
"sha256": "a6bed7b410b15f0d5aca229d1e926bff861e3ff16f0d3d6abc1bc73bcaf5fc0b"
"sha256": "f2a26a9221b5d4d5e710463a89e2d93faf9b2d8a9076a455c5ce1a0e52d88b5f"
},
"pthreadWorker": {
"fileName": "web_engine.js",
"url": "/vendor/blender/pthread/web_engine.js",
"sha256": "5a78f2e27969074f72902805c32453c980841c390db6c48eb9d43787d33a8837"
"sha256": "a64288eeb74fb0696d38d785348f0ba37e7ae716b5a341bf181f178f78e1324d"
}
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

Binary file not shown.

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

View File

@@ -94,12 +94,140 @@ function localVDBFixture(): Plugin {
};
}
// Materialize requested bytes over HTTP without checking a 64 MiB performance-only fixture into the repository.
const sparseBundleBytes = 64 * 1024 * 1024;
const sparseChunkBytes = 4 * 1024 * 1024;
const sparseStreamBytes = 64 * 1024;
const sparseChunkSha256 = "bb9f8df61474d25e71fa00722318cd387396ca1736605e1248821cc0de3d3af8";
const sparseBundleSha256 = "3b6a07d0d404fab4e23b6d34bc6696a6a312dd92821332385e5af7c01c421351";
const sparseConverterSha256 = "0000000000000000000000000000000000000000000000000000000000000000";
function sparseBundleManifest(): Record<string, unknown> {
return {
schemaVersion: 1,
projectId: "vdb-sparse-64m",
sourcePath: "//volumes/generated-synthetic-sparse.vdb",
sourceSha256: sparseBundleSha256,
conversionRequestSha256: sparseConverterSha256,
bundlePath: "//volumes/generated-sparse-64m.nvdb",
bundleByteLength: sparseBundleBytes,
bundleSha256: sparseBundleSha256,
converter: {
target: "DESKTOP",
blenderVersion: "5.2.0",
openVDBVersion: "13.0.0",
nanoVDBVersion: "32.9.0",
executableSha256: sparseConverterSha256,
},
grids: [{
name: "density",
valueType: "FLOAT16",
gridClass: "FOG_VOLUME",
semantic: "DENSITY",
activeVoxelCount: 0,
segmentByteOffset: 0,
segmentByteLength: sparseBundleBytes,
byteOffset: 0,
byteLength: sparseBundleBytes,
indexBounds: { min: [0, 0, 0], max: [0, 0, 0] },
worldBounds: { min: [0, 0, 0], max: [0, 0, 0] },
voxelSize: [1, 1, 1],
indexToWorld: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
}],
chunks: Array.from({ length: sparseBundleBytes / sparseChunkBytes }, (_, index) => ({
index,
byteOffset: index * sparseChunkBytes,
byteLength: sparseChunkBytes,
sha256: sparseChunkSha256,
})),
material: {
densityGrid: "density",
densityScale: 1,
emissionScale: 0,
temperatureScale: 1,
anisotropy: 0,
interpolation: "NEAREST",
},
gpu: {
representation: "NANOVDB_STORAGE_BUFFER",
byteAlignment: 32,
pageByteLength: 256 * 1024,
maxResidentBytes: 1 * 1024 * 1024,
shaderSemanticVersion: "volume-wgsl-v1",
},
};
}
function serveSparseVDBFixture(): Plugin {
return {
name: "serve-sparse-64m-vdb-fixture",
configureServer(server) {
server.middlewares.use((request, response, next) => {
const nodeRequest = request as unknown as { url?: string; headers: { range?: string } };
const parsed = new URL(nodeRequest.url ?? "/", "http://vite.local");
if (parsed.pathname !== "/__vdb_sparse_64m__/manifest" && parsed.pathname !== "/__vdb_sparse_64m__/bundle") { next(); return; }
const output = response as unknown as {
statusCode: number;
setHeader: (name: string, value: string | number) => void;
writeHead: (status: number) => void;
write: (data: Uint8Array) => boolean;
end: (data?: string) => void;
on: (event: string, listener: () => void) => void;
once: (event: string, listener: () => void) => void;
};
if (parsed.pathname.endsWith("/manifest")) {
const body = JSON.stringify(sparseBundleManifest());
output.statusCode = 200;
output.setHeader("Content-Type", "application/json");
output.setHeader("Cache-Control", "no-store");
output.setHeader("Content-Length", body.length);
output.end(body);
return;
}
const match = nodeRequest.headers.range?.match(/^bytes=(\d+)-(\d+)$/);
const start = match ? Number(match[1]) : 0;
const end = match ? Number(match[2]) : sparseBundleBytes - 1;
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || end >= sparseBundleBytes) {
output.statusCode = 416;
output.end();
return;
}
const length = end - start + 1;
const delayMs = Math.min(25, Math.max(0, Number.parseInt(parsed.searchParams.get("delayMs") ?? "0", 10) || 0));
output.statusCode = match ? 206 : 200;
output.setHeader("Content-Type", "application/x-nanovdb");
output.setHeader("Accept-Ranges", "bytes");
output.setHeader("Cache-Control", "no-store");
output.setHeader("ETag", '"vdb-sparse-64m-v1"');
output.setHeader("Content-Length", length);
if (match) output.setHeader("Content-Range", `bytes ${start}-${end}/${sparseBundleBytes}`);
let closed = false;
let written = 0;
const zeroes = new Uint8Array(sparseStreamBytes);
output.on("close", () => { closed = true; });
const pump = (): void => {
if (closed) return;
if (written >= length) { output.end(); return; }
const nextLength = Math.min(sparseStreamBytes, length - written);
const accepted = output.write(nextLength === zeroes.byteLength ? zeroes : zeroes.slice(0, nextLength));
written += nextLength;
const continuePump = (): void => { if (!closed) setTimeout(pump, delayMs); };
if (!accepted) output.once("drain", continuePump);
else continuePump();
};
pump();
});
},
};
}
export default defineConfig({
root: "app",
plugins: [
serveEngineVariantModuleImports(),
...(unisolatedSingleThreadTest ? [] : [preserveIsolationHeaders()]),
localVDBFixture(),
serveSparseVDBFixture(),
react(),
],
server: {