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