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

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