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>