feat(web): preload completed and load debug wasm modules on demand
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-09-04 10:56:34 +08:00
parent 0540cf510e
commit a72b984f4c
6 changed files with 315 additions and 9 deletions

View File

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

View File

@@ -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<NonNullable<WebEngineResult["wasmModules"]>> {
const result = await this.request({ type: "loadWasmModule", id });
return result.wasmModules ?? [];
}
async openBlend(buffer: ArrayBuffer, onProgress?: (progress: ProgressEvent) => void, signal?: AbortSignal): Promise<BlendOpenResult> {
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<T>(cmd: WebEngineJsonCommand, params: Record<string, unknown> = {}): 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);

View File

@@ -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<WasmModuleState[]>;
load(id: string): Promise<ArrayBuffer>;
loadDebug(id: string): Promise<ArrayBuffer>;
state(): WasmModuleState[];
manifest(): WasmModuleManifest;
}
async function fetchVerified(resource: { url: string; sha256: string }, fetcher: typeof fetch): Promise<ArrayBuffer> {
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<string, ArrayBuffer>();
const statuses = new Map<string, WasmModuleState>(manifest.modules.map((entry) => [entry.id, { id: entry.id, lifecycle: entry.lifecycle, status: "pending", byteLength: 0 }]));
const loading = new Map<string, Promise<ArrayBuffer>>();
const load = (entry: WasmModuleEntry): Promise<ArrayBuffer> => {
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,
};
}

View File

@@ -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<WebEngineStatus> | null = null;
let wasmModuleLoader: WasmModuleLoader | null = null;
let currentSnapshot: ReturnType<typeof parseSceneSnapshotIR> | 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<WebEngineStatus> {
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<WebEngineStatus> {
})().catch((error: unknown) => {
module = null;
handle = 0;
wasmModuleLoader = null;
throw error;
}).finally(() => {
initializationPromise = null;
@@ -1017,8 +1024,24 @@ function callUnifiedJson<T>(cmd: string, params: Record<string, unknown> = {}, t
}
}
const READ_ONLY_JSON_COMMANDS: ReadonlySet<WebEngineJsonCommand> = new Set(["snapshot", "delta", "depsgraph", "memoryStats"]);
const STATEFUL_JSON_COMMANDS: ReadonlySet<WebEngineJsonCommand> = new Set(["applyCommand", "undo", "redo"]);
function readSnapshot(): ReturnType<typeof parseSceneSnapshotIR> {
return parseSceneSnapshotIR(callUnifiedJson<unknown>("snapshot"));
const snapshot = parseSceneSnapshotIR(callUnifiedJson<unknown>("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<WebEngineResult, "snapshot" | "geometryBuffers" | "nonMeshGeometryBuffers"> {
@@ -1399,6 +1422,13 @@ async function handleRequest(event: MessageEvent<WebEngineRequest>): Promise<voi
await initialize();
result = baseResult();
break;
case "loadWasmModule": {
await initialize();
if (!wasmModuleLoader) throw report("WASM_INIT_FAILED", "WASM module manifest is unavailable", true);
await wasmModuleLoader.loadDebug(request.command.id);
result = { ...baseResult(), wasmModules: wasmModuleLoader.state() };
break;
}
case "openBlend": {
const resource = { liveInputBytes: request.command.buffer.byteLength, liveNativeHandles: 0, liveStagingFiles: 0 };
activeOpenResources.set(request.requestId, resource);
@@ -1478,6 +1508,35 @@ async function handleRequest(event: MessageEvent<WebEngineRequest>): Promise<voi
result = { status: status(), ...publishFullScene(scene) };
}
break;
case "jsonCommand": {
await initialize();
if (!READ_ONLY_JSON_COMMANDS.has(request.command.cmd) && !STATEFUL_JSON_COMMANDS.has(request.command.cmd)) {
throw report("INVALID_ARGUMENT", `Unsupported JSON command: ${request.command.cmd}`, true);
}
if (STATEFUL_JSON_COMMANDS.has(request.command.cmd) && request.command.cmd === "applyCommand" &&
(!request.command.params || typeof request.command.params.command !== "object" || request.command.params.command === null)) {
throw report("INVALID_ARGUMENT", "cmd json applyCommand requires params.command", true);
}
const jsonResult = callUnifiedJson<unknown>(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() };

View File

@@ -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<WasmModuleVariant, WasmModuleResource>;
}
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<string, unknown> {
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<string>();
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 };
}

View File

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