From a72b984f4c05d210319692145087fed6ab87e79d Mon Sep 17 00:00:00 2001 From: mes123456 Date: Fri, 4 Sep 2026 10:56:34 +0800 Subject: [PATCH] feat(web): preload completed and load debug wasm modules on demand --- web/app/public/wasm-module-manifest.json | 40 ++++++++++ web/app/src/engine-client/WebEngineClient.ts | 16 ++++ .../src/engine-client/wasm-module-loader.ts | 74 ++++++++++++++++++ web/app/src/workers/web-engine.worker.ts | 77 ++++++++++++++++--- web/protocol/wasm-modules.ts | 69 +++++++++++++++++ web/tests/unit/wasm-module-loader.test.mjs | 48 ++++++++++++ 6 files changed, 315 insertions(+), 9 deletions(-) create mode 100644 web/app/public/wasm-module-manifest.json create mode 100644 web/app/src/engine-client/wasm-module-loader.ts create mode 100644 web/protocol/wasm-modules.ts create mode 100644 web/tests/unit/wasm-module-loader.test.mjs diff --git a/web/app/public/wasm-module-manifest.json b/web/app/public/wasm-module-manifest.json new file mode 100644 index 000000000..fe6c8344c --- /dev/null +++ b/web/app/public/wasm-module-manifest.json @@ -0,0 +1,40 @@ +{ + "schemaVersion": 1, + "releaseId": "blender-wasm-0.1.0-rc.1", + "modules": [ + { + "id": "core-completed", + "lifecycle": "complete", + "preload": true, + "resources": { + "single": { + "fileName": "web_engine.wasm", + "url": "/vendor/blender/web_engine.wasm", + "sha256": "66fc59cba55a8c17d398b74d12ea4a9f09746c0f30c40f579c44aa34956e3072" + }, + "pthread": { + "fileName": "web_engine.wasm", + "url": "/vendor/blender/pthread/web_engine.wasm", + "sha256": "f2a26a9221b5d4d5e710463a89e2d93faf9b2d8a9076a455c5ce1a0e52d88b5f" + } + } + }, + { + "id": "debug-incremental", + "lifecycle": "debug", + "preload": false, + "resources": { + "single": { + "fileName": "web_engine.wasm", + "url": "/vendor/blender/single/web_engine.wasm", + "sha256": "7f3a50f1d41b2ac0e6366e84caaabefc3cdfc47c055d5ea459abec654da6fcc0" + }, + "pthread": { + "fileName": "web_engine.wasm", + "url": "/vendor/blender/pthread/web_engine.wasm", + "sha256": "f2a26a9221b5d4d5e710463a89e2d93faf9b2d8a9076a455c5ce1a0e52d88b5f" + } + } + } + ] +} diff --git a/web/app/src/engine-client/WebEngineClient.ts b/web/app/src/engine-client/WebEngineClient.ts index baa8368b8..a34edc4a4 100644 --- a/web/app/src/engine-client/WebEngineClient.ts +++ b/web/app/src/engine-client/WebEngineClient.ts @@ -4,6 +4,7 @@ import type { AssetRequestResult, MeshGeometryBuffer, WebEngineEditCommand, + WebEngineJsonCommand, WebEngineLODResult, WebEngineRequest, WebEngineResponse, @@ -82,6 +83,12 @@ export class WebEngineClient { return (await this.request({ type: "init" })).status; } + /** Load one debug WASM module without replacing the completed engine module. */ + async loadDebugWasmModule(id: string): Promise> { + const result = await this.request({ type: "loadWasmModule", id }); + return result.wasmModules ?? []; + } + async openBlend(buffer: ArrayBuffer, onProgress?: (progress: ProgressEvent) => void, signal?: AbortSignal): Promise { if (buffer.byteLength === 0) { throw this.report("INVALID_ARGUMENT", "无法打开空的 .blend 文件", true); @@ -101,6 +108,15 @@ export class WebEngineClient { return { status: result.status, snapshot: result.snapshot, geometryBuffers: [...this.geometryBuffers], nonMeshGeometryBuffers: [...this.nonMeshGeometryBuffers] }; } + /** Invoke the stable read-only cmd/JSON facade without adding a native registration. */ + async callJson(cmd: WebEngineJsonCommand, params: Record = {}): Promise<{ status: WebEngineStatus; result: T }> { + const result = await this.request({ type: "jsonCommand", cmd, params }); + if (result.json === undefined) throw this.report("INVALID_ARGUMENT", `WebEngine JSON command returned no result: ${cmd}`, true); + if (result.geometryBuffers) this.geometryBuffers = result.geometryBuffers; + if (result.nonMeshGeometryBuffers) this.nonMeshGeometryBuffers = result.nonMeshGeometryBuffers; + return { status: result.status, result: result.json as T }; + } + async delta(): Promise<{ status: WebEngineStatus; delta: SceneDelta }> { const result = await this.request({ type: "delta" }); if (!result.delta) throw this.report("INVALID_ARGUMENT", "WebEngine 未返回 SceneDelta", true); diff --git a/web/app/src/engine-client/wasm-module-loader.ts b/web/app/src/engine-client/wasm-module-loader.ts new file mode 100644 index 000000000..0719c0f6b --- /dev/null +++ b/web/app/src/engine-client/wasm-module-loader.ts @@ -0,0 +1,74 @@ +import { validateWasmModuleManifest, type WasmModuleEntry, type WasmModuleManifest, type WasmModuleVariant } from "../../../protocol/wasm-modules"; + +export type WasmModuleLoadStatus = "pending" | "loaded" | "failed"; + +export interface WasmModuleState { + id: string; + lifecycle: WasmModuleEntry["lifecycle"]; + status: WasmModuleLoadStatus; + byteLength: number; + error?: string; +} + +export interface WasmModuleLoader { + preloadCompleted(): Promise; + load(id: string): Promise; + loadDebug(id: string): Promise; + state(): WasmModuleState[]; + manifest(): WasmModuleManifest; +} + +async function fetchVerified(resource: { url: string; sha256: string }, fetcher: typeof fetch): Promise { + const response = await fetcher(resource.url, { cache: "force-cache" }); + if (!response.ok) throw new Error(`WASM_MODULE_REQUEST_FAILED: ${resource.url}:${response.status}`); + const bytes = await response.arrayBuffer(); + const digest = await crypto.subtle.digest("SHA-256", bytes); + const actual = [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); + if (actual !== resource.sha256) throw new Error(`WASM_MODULE_HASH_MISMATCH: ${resource.url}`); + return bytes; +} + +export function createWasmModuleLoader( + rawManifest: unknown, + variant: WasmModuleVariant, + fetcher: typeof fetch = fetch, +): WasmModuleLoader { + const manifest = validateWasmModuleManifest(rawManifest); + const bytes = new Map(); + const statuses = new Map(manifest.modules.map((entry) => [entry.id, { id: entry.id, lifecycle: entry.lifecycle, status: "pending", byteLength: 0 }])); + const loading = new Map>(); + const load = (entry: WasmModuleEntry): Promise => { + const existing = bytes.get(entry.id); + if (existing) return Promise.resolve(existing); + const active = loading.get(entry.id); + if (active) return active; + const promise = fetchVerified(entry.resources[variant], fetcher).then((value) => { + bytes.set(entry.id, value); + statuses.set(entry.id, { id: entry.id, lifecycle: entry.lifecycle, status: "loaded", byteLength: value.byteLength }); + return value; + }).catch((error: unknown) => { + statuses.set(entry.id, { id: entry.id, lifecycle: entry.lifecycle, status: "failed", byteLength: 0, error: error instanceof Error ? error.message : String(error) }); + throw error; + }).finally(() => loading.delete(entry.id)); + loading.set(entry.id, promise); + return promise; + }; + return { + async preloadCompleted() { + await Promise.all(manifest.modules.filter((entry) => entry.lifecycle === "complete" && entry.preload).map(load)); + return [...statuses.values()].map((state) => ({ ...state })); + }, + load: async (id) => { + const entry = manifest.modules.find((candidate) => candidate.id === id); + if (!entry) throw new Error(`WASM_MODULE_NOT_FOUND: ${id}`); + return load(entry); + }, + async loadDebug(id) { + const entry = manifest.modules.find((candidate) => candidate.id === id); + if (!entry || entry.lifecycle !== "debug") throw new Error(`WASM_MODULE_DEBUG_NOT_FOUND: ${id}`); + return load(entry); + }, + state: () => [...statuses.values()].map((state) => ({ ...state })), + manifest: () => manifest, + }; +} diff --git a/web/app/src/workers/web-engine.worker.ts b/web/app/src/workers/web-engine.worker.ts index 86b531f33..c3dba4071 100644 --- a/web/app/src/workers/web-engine.worker.ts +++ b/web/app/src/workers/web-engine.worker.ts @@ -1,7 +1,8 @@ import type { ErrorReport } from "../../../protocol/error"; import { parseSceneDelta, type SceneDelta } from "../../../protocol/scene-delta"; -import type { MeshGeometryBuffer, WebEngineEditCommand, WebEngineLODError, WebEngineLODLevelResult, WebEngineOpenResourceStatus, WebEngineRequest, WebEngineResponse, WebEngineResult, WebEngineStatus } from "../../../protocol/web-engine"; +import type { MeshGeometryBuffer, WebEngineEditCommand, WebEngineJsonCommand, WebEngineLODError, WebEngineLODLevelResult, WebEngineOpenResourceStatus, WebEngineRequest, WebEngineResponse, WebEngineResult, WebEngineStatus } from "../../../protocol/web-engine"; import { parseSceneSnapshotIR } from "../../../protocol/scene-ir"; +import { parseEditorWorkflow, EditorWorkflowValidationError } from "../../../protocol/editor-workflow"; import { parseLODManifest, type SimplifyResult } from "../../../protocol/simplify"; import type { LODGenerationRequest } from "../../../protocol/lod"; import { evaluateSkinSimplifyGate } from "../../../protocol/skin"; @@ -36,6 +37,7 @@ import { sha256LibraryBytes, type LibraryAppendClosureIR, } from "../../../protocol/library-main-append"; +import { createWasmModuleLoader, type WasmModuleLoader } from "../engine-client/wasm-module-loader"; type WasmModule = { _malloc: (size: number) => number; @@ -106,6 +108,7 @@ let wasmFactory: WasmFactory | null = null; let wasmBinary: ArrayBuffer | null = null; let handle = 0; let initializationPromise: Promise | null = null; +let wasmModuleLoader: WasmModuleLoader | null = null; let currentSnapshot: ReturnType | null = null; let currentGeometryBuffers: MeshGeometryBuffer[] = []; let sourceBlendBuffer: ArrayBuffer | null = null; @@ -909,7 +912,7 @@ function nativeError(fallbackCode: ErrorReport["code"]): ErrorReport { const message = module && module._web_engine_last_error_message() !== 0 ? module.UTF8ToString(module._web_engine_last_error_message()) : "WebEngine native call failed"; - const mappedCode: ErrorReport["code"] = code === -4 ? "BLEND_READ_FAILED" : code === -5 ? "BLEND_WRITE_FAILED" : fallbackCode; + const mappedCode: ErrorReport["code"] = code === -4 ? "BLEND_READ_FAILED" : code === -5 ? "BLEND_WRITE_FAILED" : message.startsWith("GN_NODE_UNSUPPORTED") ? "CAPABILITY_MISSING" : fallbackCode; return report(mappedCode, message); } @@ -918,7 +921,7 @@ function nativeErrorFor(target: WasmModule, fallbackCode: ErrorReport["code"]): const message = target._web_engine_last_error_message() !== 0 ? target.UTF8ToString(target._web_engine_last_error_message()) : "WebEngine native call failed"; - const mappedCode: ErrorReport["code"] = code === -4 ? "BLEND_READ_FAILED" : code === -5 ? "BLEND_WRITE_FAILED" : fallbackCode; + const mappedCode: ErrorReport["code"] = code === -4 ? "BLEND_READ_FAILED" : code === -5 ? "BLEND_WRITE_FAILED" : message.startsWith("GN_NODE_UNSUPPORTED") ? "CAPABILITY_MISSING" : fallbackCode; return report(mappedCode, message); } @@ -950,12 +953,15 @@ async function initialize(): Promise { if (module) return status(); if (!initializationPromise) { initializationPromise = (async () => { + const moduleManifest = await fetch("/wasm-module-manifest.json", { cache: "force-cache" }).then(async (response) => { + if (!response.ok) throw new Error(`wasm-module-manifest request failed: ${response.status}`); + return response.json(); + }); + wasmModuleLoader = createWasmModuleLoader(moduleManifest, "single"); + await wasmModuleLoader.preloadCompleted(); const imported = await import("../vendor/blender/web_engine.js") as unknown as { default: WasmFactory }; const factory = imported.default; - const binary = await fetch("/vendor/blender/web_engine.wasm?v=2").then((response) => { - if (!response.ok) throw new Error(`web_engine.wasm request failed: ${response.status}`); - return response.arrayBuffer(); - }); + const binary = await wasmModuleLoader.load("core-completed"); const initializedModule = await factory({ wasmBinary: binary }); const initializedHandle = initializedModule._web_engine_create(); if (initializedHandle <= 0) throw new Error("WebEngine handle creation failed"); @@ -967,6 +973,7 @@ async function initialize(): Promise { })().catch((error: unknown) => { module = null; handle = 0; + wasmModuleLoader = null; throw error; }).finally(() => { initializationPromise = null; @@ -1017,8 +1024,24 @@ function callUnifiedJson(cmd: string, params: Record = {}, t } } +const READ_ONLY_JSON_COMMANDS: ReadonlySet = new Set(["snapshot", "delta", "depsgraph", "memoryStats"]); +const STATEFUL_JSON_COMMANDS: ReadonlySet = new Set(["applyCommand", "undo", "redo"]); + function readSnapshot(): ReturnType { - return parseSceneSnapshotIR(callUnifiedJson("snapshot")); + const snapshot = parseSceneSnapshotIR(callUnifiedJson("snapshot")); + if (snapshot.editorWorkflowStatus === "AVAILABLE" && !snapshot.editorWorkflow) { + throw report("EDITOR_LAYOUT_INVALID", "SceneIR editor workflow is marked AVAILABLE but has no payload"); + } + if (snapshot.editorWorkflow) { + try { + parseEditorWorkflow(snapshot.editorWorkflow); + } + catch (error) { + if (error instanceof EditorWorkflowValidationError) throw report(error.code, error.message); + throw report("EDITOR_LAYOUT_INVALID", "SceneIR editor workflow validation failed"); + } + } + return snapshot; } function readDelta(): SceneDelta { @@ -1279,7 +1302,7 @@ function cloneNonMeshGeometryBuffers(chunks: readonly NonMeshGeometryChunk[]): N } function baseResult(): WebEngineResult { - return { status: status() }; + return { status: status(), wasmModules: wasmModuleLoader?.state() }; } function publishFullScene(scene: SceneReadResult): Pick { @@ -1399,6 +1422,13 @@ async function handleRequest(event: MessageEvent): Promise): Promise(request.command.cmd, request.command.params ?? {}); + if (STATEFUL_JSON_COMMANDS.has(request.command.cmd)) { + const scene = await readSnapshotResult(); + currentSnapshot = scene.snapshot; + result = { + status: status(), + json: jsonResult, + snapshot: scene.snapshot, + delta: readDelta(), + geometryBuffers: scene.geometryBuffers, + nonMeshGeometryBuffers: scene.nonMeshGeometryBuffers, + }; + break; + } + result = { + status: status(), + json: jsonResult, + }; + break; + } case "delta": await initialize(); result = { status: status(), delta: readDelta() }; diff --git a/web/protocol/wasm-modules.ts b/web/protocol/wasm-modules.ts new file mode 100644 index 000000000..37193682a --- /dev/null +++ b/web/protocol/wasm-modules.ts @@ -0,0 +1,69 @@ +export const WASM_MODULE_MANIFEST_SCHEMA = 1 as const; + +export type WasmModuleLifecycle = "complete" | "debug"; +export type WasmModuleVariant = "single" | "pthread"; + +export interface WasmModuleResource { + fileName: string; + url: string; + sha256: string; +} + +export interface WasmModuleEntry { + id: string; + lifecycle: WasmModuleLifecycle; + preload: boolean; + resources: Record; +} + +export interface WasmModuleManifest { + schemaVersion: typeof WASM_MODULE_MANIFEST_SCHEMA; + releaseId: string; + modules: WasmModuleEntry[]; +} + +const SHA256 = /^[a-f0-9]{64}$/; +const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function resource(value: unknown, path: string): WasmModuleResource { + if (!record(value) || typeof value.fileName !== "string" || !NAME.test(value.fileName) || !value.fileName.endsWith(".wasm") || + typeof value.url !== "string" || !value.url.startsWith("/") || /[?#\\]/.test(value.url) || + typeof value.sha256 !== "string" || !SHA256.test(value.sha256)) { + throw new Error(`WASM_MODULE_MANIFEST_INVALID: ${path}`); + } + const segments = value.url.slice(1).split("/"); + if (segments.some((segment) => !NAME.test(segment)) || segments.at(-1) !== value.fileName) { + throw new Error(`WASM_MODULE_MANIFEST_INVALID: ${path}.url`); + } + return { fileName: value.fileName, url: value.url, sha256: value.sha256 }; +} + +export function validateWasmModuleManifest(value: unknown): WasmModuleManifest { + if (!record(value) || value.schemaVersion !== WASM_MODULE_MANIFEST_SCHEMA || typeof value.releaseId !== "string" || !NAME.test(value.releaseId) || !Array.isArray(value.modules)) { + throw new Error("WASM_MODULE_MANIFEST_INVALID"); + } + const ids = new Set(); + const modules = value.modules.map((entry, index): WasmModuleEntry => { + if (!record(entry) || typeof entry.id !== "string" || !NAME.test(entry.id) || ids.has(entry.id) || + (entry.lifecycle !== "complete" && entry.lifecycle !== "debug") || typeof entry.preload !== "boolean" || + !record(entry.resources) || !entry.resources.single || !entry.resources.pthread) { + throw new Error(`WASM_MODULE_MANIFEST_INVALID: modules[${index}]`); + } + ids.add(entry.id); + if (entry.lifecycle === "debug" && entry.preload) throw new Error(`WASM_MODULE_MANIFEST_INVALID: modules[${index}].preload`); + return { + id: entry.id, + lifecycle: entry.lifecycle, + preload: entry.preload, + resources: { + single: resource(entry.resources.single, `modules[${index}].resources.single`), + pthread: resource(entry.resources.pthread, `modules[${index}].resources.pthread`), + }, + }; + }); + return { schemaVersion: WASM_MODULE_MANIFEST_SCHEMA, releaseId: value.releaseId, modules }; +} diff --git a/web/tests/unit/wasm-module-loader.test.mjs b/web/tests/unit/wasm-module-loader.test.mjs new file mode 100644 index 000000000..b7639b13d --- /dev/null +++ b/web/tests/unit/wasm-module-loader.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import ts from "typescript"; + +const root = path.resolve(import.meta.dirname, "../../.."); +const loaderPath = path.join(root, "web/app/src/engine-client/wasm-module-loader.ts"); +const protocolPath = path.join(root, "web/protocol/wasm-modules.ts"); +const transpile = (file) => ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: file, + reportDiagnostics: true, +}); +const protocol = transpile(protocolPath); +assert.deepEqual(protocol.diagnostics, []); +const loader = transpile(loaderPath); +assert.deepEqual(loader.diagnostics, []); +const loaderSource = loader.outputText.replace('from "../../../protocol/wasm-modules"', `from "data:text/javascript;base64,${Buffer.from(protocol.outputText).toString("base64")}"`); +const loaderModule = await import(`data:text/javascript;base64,${Buffer.from(loaderSource).toString("base64")}`); + +const bytes = new TextEncoder().encode("completed"); +const digest = await crypto.subtle.digest("SHA-256", bytes); +const sha256 = [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join(""); +const manifest = { + schemaVersion: 1, + releaseId: "blender-wasm-0.1.0-rc.1", + modules: [ + { id: "complete", lifecycle: "complete", preload: true, resources: { single: { fileName: "complete.wasm", url: "/complete.wasm", sha256 }, pthread: { fileName: "complete.wasm", url: "/complete.wasm", sha256 } } }, + { id: "debug", lifecycle: "debug", preload: false, resources: { single: { fileName: "debug.wasm", url: "/debug.wasm", sha256 }, pthread: { fileName: "debug.wasm", url: "/debug.wasm", sha256 } } }, + ], +}; +const fetcher = async (url) => new Response(bytes, { status: 200 }); + +test("completed modules preload once and debug modules load independently", async () => { + const loader = loaderModule.createWasmModuleLoader(manifest, "single", fetcher); + const states = await loader.preloadCompleted(); + assert.equal(states.find((state) => state.id === "complete")?.status, "loaded"); + assert.equal(states.find((state) => state.id === "debug")?.status, "pending"); + assert.equal((await loader.load("complete")).byteLength, bytes.byteLength); + assert.equal((await loader.loadDebug("debug")).byteLength, bytes.byteLength); + assert.equal(loader.state().find((state) => state.id === "debug")?.status, "loaded"); +}); + +test("debug loading rejects a completed module id", async () => { + const loader = loaderModule.createWasmModuleLoader(manifest, "single", fetcher); + await assert.rejects(() => loader.loadDebug("complete"), /WASM_MODULE_DEBUG_NOT_FOUND/); +});