diff --git a/src/lib/exporter/modernFrameRenderer.test.ts b/src/lib/exporter/modernFrameRenderer.test.ts index 79c2caa13..4707fd1ff 100644 --- a/src/lib/exporter/modernFrameRenderer.test.ts +++ b/src/lib/exporter/modernFrameRenderer.test.ts @@ -231,6 +231,67 @@ it("bypasses blur annotation compositing during gaps and clears stale composite }); describe("ModernFrameRenderer Pixi lifecycle", () => { + it("tries WebGL before WebGPU in default/auto mode when both are nominally available", async () => { + pixiApplicationInstancesMock.length = 0; + pixiInitializationErrorsMock.length = 0; + // Default/auto mode with navigator.gpu present used to try WebGPU first, which + // crashed on the broken Linux path before any fallback could engage. The fix makes + // WebGL the first attempt in that case, so a nominally-present-but-crashing WebGPU + // backend no longer blocks the export. + vi.stubGlobal("navigator", { gpu: {} }); + + try { + const renderer = createRenderer() as unknown as { + config: { preferredRenderBackend?: "webgl" | "webgpu" }; + createPixiApplication: ( + canvas: HTMLCanvasElement, + ) => Promise<{ backend: "webgl" | "webgpu" }>; + }; + + await expect( + renderer.createPixiApplication({} as HTMLCanvasElement), + ).resolves.toMatchObject({ + backend: "webgl", + }); + + // WebGL succeeded on the first attempt; WebGPU was never tried. + expect(pixiApplicationInstancesMock).toHaveLength(1); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("falls back to WebGPU when WebGL fails in default/auto mode", async () => { + pixiApplicationInstancesMock.length = 0; + pixiInitializationErrorsMock.length = 0; + pixiInitializationErrorsMock.push(new Error("WebGL initialization failed")); + vi.stubGlobal("navigator", { gpu: {} }); + + try { + const renderer = createRenderer() as unknown as { + config: { preferredRenderBackend?: "webgl" | "webgpu" }; + createPixiApplication: ( + canvas: HTMLCanvasElement, + ) => Promise<{ backend: "webgl" | "webgpu" }>; + }; + + await expect( + renderer.createPixiApplication({} as HTMLCanvasElement), + ).resolves.toMatchObject({ + backend: "webgpu", + }); + + // WebGL failed first; WebGPU succeeded on the second attempt. + expect(pixiApplicationInstancesMock).toHaveLength(2); + // The failed WebGL attempt is destroyed; the successful WebGPU instance survives. + expect(pixiApplicationInstancesMock[0].destroy).not.toHaveBeenCalled(); + expect(pixiApplicationInstancesMock[0].stage.destroy).toHaveBeenCalledTimes(1); + expect(pixiApplicationInstancesMock[0].renderer.destroy).toHaveBeenCalledTimes(1); + } finally { + vi.unstubAllGlobals(); + } + }); + it("continues to the next backend when failed-init cleanup would throw", async () => { pixiApplicationInstancesMock.length = 0; pixiInitializationErrorsMock.length = 0; @@ -244,6 +305,8 @@ describe("ModernFrameRenderer Pixi lifecycle", () => { canvas: HTMLCanvasElement, ) => Promise<{ backend: "webgl" | "webgpu" }>; }; + // Explicit `preferredRenderBackend: "webgpu"` still tries WebGPU first; + // the bug fix only changes the default/auto order. renderer.config.preferredRenderBackend = "webgpu"; await expect( @@ -252,6 +315,7 @@ describe("ModernFrameRenderer Pixi lifecycle", () => { backend: "webgl", }); + // WebGPU failed first; WebGL succeeded on the second attempt. expect(pixiApplicationInstancesMock).toHaveLength(2); expect(pixiApplicationInstancesMock[0].destroy).not.toHaveBeenCalled(); expect(pixiApplicationInstancesMock[0].stage.destroy).toHaveBeenCalledTimes(1); diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index c66c18d79..0f6616933 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -629,7 +629,7 @@ export class FrameRenderer { : preferredRenderBackend === "webgpu" ? ["webgpu", "webgl"] : typeof navigator !== "undefined" && "gpu" in navigator - ? ["webgpu", "webgl"] + ? ["webgl", "webgpu"] : ["webgl"]; const failures: PixiRendererAttempt[] = [];