Babylon.js - Worker mode source

Back to demo

main.ts

import { Engine } from "@babylonjs/core/Engines/engine";
import type { Scene } from "@babylonjs/core/scene";
import { createOffscreenScene } from "./scene";

declare global {
    interface Window {
        __babylonDemoReady?: Promise<void>;
        __babylonDemo?: {
            engine: Engine;
            scene?: Scene;
        };
    }
}

type WorkerMessage =
    | {
          type: "ready";
      }
    | {
          type: "error";
          message: string;
      };

const mainCanvas = document.getElementById("renderCanvas") as HTMLCanvasElement | null;
const workerCanvas = document.getElementById("workerRenderCanvas") as HTMLCanvasElement | null;
const loading = document.getElementById("loading");
const notSupported = document.getElementById("notSupported");
const labelWorker = document.getElementById("labelWorker");
const slowButton = document.getElementById("slowButton");

if (!mainCanvas || !workerCanvas) {
    throw new Error("Offscreen demo requires renderCanvas and workerRenderCanvas elements.");
}

function fitCanvasToDisplaySize(canvas: HTMLCanvasElement): void {
    canvas.width = Math.max(1, Math.floor(canvas.clientWidth));
    canvas.height = Math.max(1, Math.floor(canvas.clientHeight));
}

function slowDownMainThread(): void {
    let count = 0;
    window.setInterval(() => {
        for (let index = 0; index < 10_000_000; index++) {
            count += Math.cos(Math.sin(Math.random()));
        }

        if (count > Number.MAX_SAFE_INTEGER) {
            count = 0;
        }
    }, 1);
}

async function waitForSceneReady(scene: Scene): Promise<void> {
    await new Promise<void>((resolve) => {
        scene.executeWhenReady(resolve);
    });
}

function startMainThreadDemo(): Promise<void> {
    const engine = new Engine(mainCanvas, true, {
        preserveDrawingBuffer: true,
        stencil: true,
    });
    window.__babylonDemo = { engine };

    return createOffscreenScene(engine, true).then(async (scene) => {
        window.__babylonDemo = { engine, scene };
        await waitForSceneReady(scene);
        mainCanvas.style.opacity = "1";

        engine.runRenderLoop(() => {
            engine.resize();
            if (scene.activeCamera) {
                scene.render();
            }
        });

        window.addEventListener("resize", () => {
            engine.resize();
        });
    });
}

function startWorkerDemo(): Promise<void> {
    if (!("OffscreenCanvas" in window) || typeof workerCanvas.transferControlToOffscreen !== "function") {
        notSupported?.classList.remove("hidden");
        if (labelWorker) {
            labelWorker.textContent = "OffscreenCanvas is not supported";
        }
        return Promise.reject(new Error("OffscreenCanvas is not supported by this browser."));
    }

    fitCanvasToDisplaySize(workerCanvas);
    const offscreenCanvas = workerCanvas.transferControlToOffscreen();
    const worker = new Worker(new URL("./worker.ts", import.meta.url), { type: "module" });

    const ready = new Promise<void>((resolve, reject) => {
        worker.addEventListener("message", (event: MessageEvent<WorkerMessage>) => {
            if (event.data.type === "ready") {
                workerCanvas.style.opacity = "1";
                resolve();
            } else if (event.data.type === "error") {
                reject(new Error(event.data.message));
            }
        });

        worker.addEventListener("error", (event) => {
            reject(new Error(event.message));
        });
    });

    worker.postMessage(
        {
            type: "init",
            canvas: offscreenCanvas,
            width: workerCanvas.width,
            height: workerCanvas.height,
        },
        [offscreenCanvas]
    );

    window.addEventListener("resize", () => {
        worker.postMessage({
            type: "resize",
            width: Math.max(1, Math.floor(workerCanvas.clientWidth)),
            height: Math.max(1, Math.floor(workerCanvas.clientHeight)),
        });
    });

    return ready;
}

slowButton?.addEventListener("click", slowDownMainThread);

window.__babylonDemoReady = Promise.all([startMainThreadDemo(), startWorkerDemo()]).then(() => {
    loading?.classList.add("hidden");
});

scene.ts

import { Animation } from "@babylonjs/core/Animations/animation";
import type { Engine } from "@babylonjs/core/Engines/engine";
import { ImportMeshAsync } from "@babylonjs/core/Loading/sceneLoader";
import { Scene } from "@babylonjs/core/scene";
import "@babylonjs/core/Helpers/sceneHelpers";
import "@babylonjs/loaders/glTF";

export async function createOffscreenScene(engine: Engine, attachCameraControls: boolean): Promise<Scene> {
    const scene = new Scene(engine);
    const result = await ImportMeshAsync("flightHelmet.glb", scene, {
        rootUrl: "https://models.babylonjs.com/",
    });

    scene.createDefaultCameraOrLight(true, true, attachCameraControls);
    scene.createDefaultEnvironment();

    const rootMesh = result.meshes[0];
    if (rootMesh) {
        rootMesh.rotationQuaternion = null;
        Animation.CreateAndStartAnimation("turnTable", rootMesh, "rotation.y", 60, 480, 0, Math.PI * 2);
    }

    return scene;
}

worker.ts

import { Engine } from "@babylonjs/core/Engines/engine";
import { createOffscreenScene } from "./scene";

type InitMessage = {
    type: "init";
    canvas: OffscreenCanvas;
    width: number;
    height: number;
};

type ResizeMessage = {
    type: "resize";
    width: number;
    height: number;
};

type WorkerMessage = InitMessage | ResizeMessage;

let canvas: OffscreenCanvas | undefined;
let engine: Engine | undefined;

async function waitForSceneReady(scene: Awaited<ReturnType<typeof createOffscreenScene>>): Promise<void> {
    await new Promise<void>((resolve) => {
        scene.executeWhenReady(resolve);
    });
}

async function startWorkerRender(message: InitMessage): Promise<void> {
    canvas = message.canvas;
    canvas.width = message.width;
    canvas.height = message.height;

    engine = new Engine(canvas, true, {
        preserveDrawingBuffer: true,
        stencil: true,
    });

    const scene = await createOffscreenScene(engine, false);
    await waitForSceneReady(scene);

    engine.runRenderLoop(() => {
        engine?.resize();
        if (scene.activeCamera) {
            scene.render();
        }
    });

    self.postMessage({ type: "ready" });
}

self.onmessage = (event: MessageEvent<WorkerMessage>) => {
    const message = event.data;

    if (message.type === "init") {
        startWorkerRender(message).catch((error: unknown) => {
            self.postMessage({
                type: "error",
                message: error instanceof Error ? error.message : String(error),
            });
        });
        return;
    }

    if (canvas) {
        canvas.width = message.width;
        canvas.height = message.height;
        engine?.resize();
    }
};