Clustered Forward+ benchmark

Hi guys! I am stoked about the new lighting engine. I made a little benchmark html file to see what it could do and on my laptop 4070 I got 60fps at 1080p and 2000 lights or the same with around 300-500 lights at closer to 4K. Super impressive!

Here’s the code, you can just paste it to an html file and open it in a chromium browser. I tossed the test in a web worker to limit performance hiccups.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>WebGPU Clustered Lighting Benchmark (Worker + OffscreenCanvas)</title>
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <style>
    html, body {
      margin: 0;
      padding: 0;
      width: 100%;
      height: 100%;
      overflow: hidden;
      background: #020308;
      font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
    }
    #renderCanvas {
      width: 100%;
      height: 100%;
      touch-action: none;
      display: block;
      background: #000;
    }
    #hud {
      position: fixed;
      top: 8px;
      left: 8px;
      padding: 6px 10px;
      background: rgba(0, 0, 0, 0.7);
      color: #e8f3ff;
      font-size: 12px;
      line-height: 1.4;
      border-radius: 4px;
      pointer-events: none;
      white-space: pre;
    }
    #warning {
      position: fixed;
      bottom: 8px;
      left: 8px;
      padding: 6px 10px;
      background: rgba(150, 40, 40, 0.9);
      color: #fff;
      font-size: 11px;
      border-radius: 4px;
      max-width: 360px;
      display: none;
    }
  </style>
</head>
<body>
<canvas id="renderCanvas"></canvas>
<div id="hud"></div>
<div id="warning">
  WebGPU + OffscreenCanvas in workers may require Chrome/Edge with experimental flags enabled.
</div>

<script>
  // --- Worker code as a string ------------------------------------------------
  const workerCode = `
  // Worker-scoped Babylon import (includes WebGPUEngine in current versions)
  importScripts("https://cdn.babylonjs.com/babylon.js");

  let engine = null;
  let scene  = null;
  let canvas = null;

  const state = {
    hudPingInterval: 0.25, // seconds between stat messages
    hudTimeAcc: 0
  };

  self.onmessage = async (e) => {
    const data = e.data;
    if (data.type === "init") {
      canvas = data.canvas;
      const width  = data.width;
      const height = data.height;
      const dpr    = data.dpr || 1;

      canvas.width  = width * dpr;
      canvas.height = height * dpr;

      // WebGPUEngine here, using the same babylon.js bundle
      engine = new BABYLON.WebGPUEngine(canvas, {
        adaptToDeviceRatio: false,
      });
      await engine.initAsync();

      scene = createScene(engine, width, height, dpr);

      engine.runRenderLoop(() => {
        const dt = engine.getDeltaTime() * 0.001;
        if (scene) {
          // onBeforeRenderObservable is invoked inside scene.render()
          scene.render();
        }
      });
    } else if (data.type === "resize" && engine && canvas) {
      const width  = data.width;
      const height = data.height;
      const dpr    = data.dpr || 1;
      canvas.width  = width * dpr;
      canvas.height = height * dpr;
      engine.resize();
    }
  };

  function createScene(engine, width, height, dpr) {
    const scene = new BABYLON.Scene(engine);
    scene.clearColor = new BABYLON.Color4(0.01, 0.01, 0.015, 1.0);

    // Fixed camera
    const camera = new BABYLON.ArcRotateCamera(
      "camera",
      Math.PI * 0.3,
      Math.PI * 0.3,
      80,
      new BABYLON.Vector3(0, 5, 0),
      scene
    );
    camera.fov = 60 * Math.PI / 180;

    const hemi = new BABYLON.HemisphericLight("hemi",
      new BABYLON.Vector3(0, 1, 0), scene);
    hemi.intensity = 0.15;

    // --- Geometry --------------------------------------------------------

    const ground = BABYLON.MeshBuilder.CreateGround("ground", {
      width: 80,
      height: 80
    }, scene);
    const groundMat = new BABYLON.PBRMaterial("groundMat", scene);
    groundMat.albedoColor = new BABYLON.Color3(0.25, 0.27, 0.3);
    groundMat.metallic = 0.0;
    groundMat.roughness = 0.6;
    groundMat.usePhysicalLightFalloff = false;
    ground.material = groundMat;

    const boxMat = new BABYLON.PBRMaterial("boxMat", scene);
    boxMat.albedoColor = new BABYLON.Color3(0.9, 0.9, 0.9);
    boxMat.metallic = 0.0;
    boxMat.roughness = 0.3;
    boxMat.usePhysicalLightFalloff = false;

    const gridSize = 4;
    const spacing  = 10;
    for (let x = -gridSize; x <= gridSize; x++) {
      for (let z = -gridSize; z <= gridSize; z++) {
        const box = BABYLON.MeshBuilder.CreateBox(\`box_\${x}_\${z}\`, { size: 2 }, scene);
        box.position.set(x * spacing * 0.7, 1, z * spacing * 0.7);
        box.material = boxMat;
      }
    }

    // --- Light spheres (thin instances) ----------------------------------

    const lightSphere = BABYLON.MeshBuilder.CreateSphere("lightSphere", {
      diameter: 1.2,
      segments: 12
    }, scene);
    const lightSphereMat = new BABYLON.StandardMaterial("lightSphereMat", scene);
    lightSphereMat.disableLighting = true;
    lightSphereMat.emissiveColor = new BABYLON.Color3(1, 1, 1);
    lightSphere.material = lightSphereMat;
    lightSphere.isPickable = false;

    let currentLightCount = 0;
    let lights = [];
    let movers = [];
    let clusteredContainer = null;

    let matricesData = null;
    let colorsData   = null;
    const tmpMatrix  = new BABYLON.Matrix();

    function randomColor3() {
      const r = 0.3 + 0.7 * Math.random();
      const g = 0.3 + 0.7 * Math.random();
      const b = 0.3 + 0.7 * Math.random();
      return new BABYLON.Color3(r, g, b);
    }

    function setupClusteredLights(lightCount) {
      if (clusteredContainer) {
        clusteredContainer.dispose();
        clusteredContainer = null;
      }
      for (const L of lights) {
        L.dispose();
      }
      lights.length = 0;
      movers.length = 0;

      currentLightCount = lightCount;
      matricesData = new Float32Array(lightCount * 16);
      colorsData   = new Float32Array(lightCount * 4);

      for (let i = 0; i < lightCount; i++) {
        const light = new BABYLON.PointLight("L" + i, BABYLON.Vector3.Zero(), scene);
        light.range = 35.0;
        light.intensity = 0.1;

        const color = randomColor3();
        light.diffuse = color;

        const radiusX = 24 + Math.random() * 10;
        const radiusZ = 24 + Math.random() * 10;
        const centerY = 4 + Math.random() * 6;
        const speed   = 0.15 + Math.random() * 0.35;
        const phase   = Math.random() * Math.PI * 2.0;

        movers.push({ light, radiusX, radiusZ, centerY, speed, phase });
        lights.push(light);

        colorsData[i * 4 + 0] = color.r;
        colorsData[i * 4 + 1] = color.g;
        colorsData[i * 4 + 2] = color.b;
        colorsData[i * 4 + 3] = 1.0;

        BABYLON.Matrix.TranslationToRef(0, centerY, 0, tmpMatrix);
        tmpMatrix.copyToArray(matricesData, i * 16);
      }

      lightSphere.thinInstanceSetBuffer("matrix", matricesData, 16);
      lightSphere.thinInstanceSetBuffer("color",  colorsData,   4);

      clusteredContainer = new BABYLON.ClusteredLightContainer("clustered", lights, scene);
      // Optional:
      // clusteredContainer.horizontalTiles = 16;
      // clusteredContainer.verticalTiles   = 9;
      // clusteredContainer.depthSlices     = 16;
    }

    // --- Benchmark state --------------------------------------------------

    const benchmark = {
      active: true,
      targetFPS: 50,
      warmupFrames: 60,
      measureFrames: 240,
      phase: "warmup", // "warmup" | "measure" | "done"
      frameCount: 0,
      totalTime: 0,
      bestLightCount: 0,
      maxLights: 100000,
      step: 256,
      lastAvgFPS: 0,
      lastInstantFPS: 0
    };

    setupClusteredLights(benchmark.step);

    let time = 0;

    scene.onBeforeRenderObservable.add(() => {
      const dt = engine.getDeltaTime() * 0.001;
      if (dt <= 0) {
        return;
      }
      time += dt;

      // Animate lights
      for (let i = 0; i < movers.length; i++) {
        const m = movers[i];
        const t = time * m.speed + m.phase;

        const x = Math.cos(t) * m.radiusX;
        const z = Math.sin(t) * m.radiusZ;
        const y = m.centerY + Math.sin(t * 0.7) * 1.5;

        m.light.position.set(x, y, z);
        BABYLON.Matrix.TranslationToRef(x, y, z, tmpMatrix);
        tmpMatrix.copyToArray(matricesData, i * 16);
      }

      lightSphere.thinInstanceSetBuffer("matrix", matricesData, 16);

      // Benchmark
      if (benchmark.active) {
        benchmark.frameCount++;
        benchmark.lastInstantFPS = 1 / dt;

        if (benchmark.phase === "warmup") {
          if (benchmark.frameCount >= benchmark.warmupFrames) {
            benchmark.phase = "measure";
            benchmark.frameCount = 0;
            benchmark.totalTime = 0;
          }
        } else if (benchmark.phase === "measure") {
          benchmark.totalTime += dt;
          if (benchmark.frameCount >= benchmark.measureFrames) {
            const avgFPS = benchmark.frameCount / benchmark.totalTime;
            benchmark.lastAvgFPS = avgFPS;

            if (avgFPS >= benchmark.targetFPS) {
              benchmark.bestLightCount = currentLightCount;
              const next = currentLightCount + benchmark.step;
              if (next > benchmark.maxLights) {
                benchmark.phase = "done";
                benchmark.active = false;
              } else {
                setupClusteredLights(next);
                benchmark.phase = "warmup";
                benchmark.frameCount = 0;
                benchmark.totalTime = 0;
              }
            } else {
              benchmark.phase = "done";
              benchmark.active = false;
            }
          }
        }
      }

      // Send HUD stats at a modest rate
      state.hudTimeAcc += dt;
      if (state.hudTimeAcc >= state.hudPingInterval) {
        state.hudTimeAcc = 0;
        self.postMessage({
          type: "stats",
          currentLightCount,
          targetFPS: benchmark.targetFPS,
          phase: benchmark.phase,
          instantFPS: benchmark.lastInstantFPS,
          lastAvgFPS: benchmark.lastAvgFPS,
          bestLightCount: benchmark.bestLightCount || currentLightCount
        });
      }
    });

    return scene;
  }
  `;

  // --- Main thread: wire up worker + OffscreenCanvas ------------------------
  const canvas = document.getElementById("renderCanvas");
  const hud    = document.getElementById("hud");
  const warning = document.getElementById("warning");

  if (!("transferControlToOffscreen" in HTMLCanvasElement.prototype)) {
    warning.style.display = "block";
    warning.textContent = "OffscreenCanvas is not supported in this browser.";
  }

  const blob = new Blob([workerCode], { type: "application/javascript" });
  const workerUrl = URL.createObjectURL(blob);
  const worker = new Worker(workerUrl);

  function sendResize() {
    const rect = canvas.getBoundingClientRect();
    const dpr  = window.devicePixelRatio || 1;
    worker.postMessage({
      type: "resize",
      width: rect.width,
      height: rect.height,
      dpr
    });
  }

  // Listen for stats from worker
  worker.onmessage = (e) => {
    const data = e.data;
    if (data.type === "stats") {
      const phaseLabel = data.phase === "done" ? "done" : data.phase;
      const instant = data.instantFPS ? data.instantFPS.toFixed(1) : "?";
      const avg     = data.lastAvgFPS ? data.lastAvgFPS.toFixed(1) : "?";
      hud.textContent =
        "WebGPU Clustered Forward+ Benchmark (Worker + OffscreenCanvas)\n" +
        "Lights (active): " + data.currentLightCount + "\n" +
        "Target avg FPS:  " + data.targetFPS + "\n" +
        "Phase:           " + phaseLabel + "\n" +
        "Instant FPS:     " + instant + "\n" +
        "Last avg FPS:    " + avg + "\n" +
        "Best >= target:  " + data.bestLightCount + "\n" +
        "\n" +
        "Rendering fully off the main thread.";
    }
  };

  // Kick off by transferring canvas
  if ("transferControlToOffscreen" in canvas) {
    const offscreen = canvas.transferControlToOffscreen();
    const rect = canvas.getBoundingClientRect();
    const dpr  = window.devicePixelRatio || 1;

    worker.postMessage({
      type: "init",
      canvas: offscreen,
      width: rect.width,
      height: rect.height,
      dpr
    }, [offscreen]);

    window.addEventListener("resize", sendResize);
  }
</script>
</body>
</html>

2 Likes

Happy that you liked it. All credits to @matanui159

1 Like