Gl_max_vertex_uniform_buffers

Hey I noticed a diff between Babylon.js v8 and v9. I have a scene that did not change and since I updated to v9, I’m having this error:
BJS - [16:41:38]: Error: VERTEX shader uniform block count exceeds GL_MAX_VERTEX_UNIFORM_BUFFERS (12)

I checked the value and it’s the truth using:
scene._engine._gl.getParameter(scene._engine._gl.MAX_VERTEX_UNIFORM_BLOCKS);

Previously I had maybe too many lights this is why I changed the scene to have:

  • 1 directional light which generates shadows
  • 1 clustered light container with 9 lights

But I still get the error (see above).

Fun facts when trying:

  • Chrome on Windows 11: KO
  • Safari on macOS 26: KO
  • Chrome on macOS 26: OK (but why?? ahah)

Any advice on how to spot? The posts I saw on the forum did not help me this is why I post a new one.

Thanks a lot for you help :heart:

Hey @julien :waving_hand:

I took a look at this and wanted to share some diagnostic insights.

How UBOs work with lights: In WebGL2, Babylon.js creates one uniform block per active light in the vertex shader, plus the Scene and Material blocks. So the formula is:

Total UBO blocks = 2 + number of active lights

With GL_MAX_VERTEX_UNIFORM_BLOCKS = 12, that means a maximum of 10 active lights per shader.

The ClusteredLightContainer counts as 1 light (its internal lights are removed from the scene), so your described setup of 1 directional + 1 clustered container = 4 UBO blocks, which should be well
under the limit.

Why the error persists: Could you check how many lights your scene actually has at runtime?

 console.log("Scene lights:", scene.lights.length, scene.lights.map(l => l.name));

Also check maxSimultaneousLights on your materials — the default is 4, but if it’s set higher and there are more lights than expected, that would explain the issue:

 scene.meshes.forEach(m => {
     if (m.material) {
         console.log(m.name, "maxSimultaneousLights:", m.material.maxSimultaneousLights, "lightSources:", m.lightSources.length);
     }
 });

About Chrome on macOS working: Chrome on macOS likely reports a higher GL_MAX_VERTEX_UNIFORM_BLOCKS (e.g., 16 on Apple GPUs), which is why it works there.

A minimal Playground reproduction would help me narrow it down further — even without the actual scene geometry, just the lights/materials/shadows setup that triggers the error would be enough.

Hey @Evgeni_Popov thanks a lot for you answer and it kind of helped me a lot.
I found the problem.

Explanation

ClusteredLightContainer is not fully serializable. For example it misses list of lights. This is why the loading process on my side is:

  • Load .babylon scene
  • While waitingItemsCount > 0 and !scene.isReady() wait some time and do stuff to notify loading progress
  • Once fully loaded, attach scripts and configure post-processes etc.
  • THEN, configure the clusreted lights like:
import { Scene } from "@babylonjs/core/scene";
import { ClusteredLightContainer } from "@babylonjs/core/Lights/Clustered/clusteredLightContainer";

export function configureLights(scene: Scene, clusteredLightContainer?: ClusteredLightContainer) {
	const clusteredLight = scene.metadata?.clusteredLight;
	if (clusteredLight) {
		if (clusteredLight.lights.length > 0) {
			clusteredLightContainer ??= new ClusteredLightContainer("Clustered Light Container", [], scene);
			clusteredLightContainer.horizontalTiles = clusteredLight.horizontalTiles;
			clusteredLightContainer.verticalTiles = clusteredLight.verticalTiles;
			clusteredLightContainer.depthSlices = clusteredLight.depthSlices;
			clusteredLightContainer.maxRange = clusteredLight.maxRange;
		}

		clusteredLight.lights.forEach((lightId: any) => {
			const light = scene.getLightById(lightId);
			if (light) {
				clusteredLightContainer?.addLight(light);
			}
		});
	}

	return clusteredLightContainer;
}

The problem here is that scene.isReady() will compile the materials of course (I’m so stupid I would spot it since the beginning) and lights are still not added to the clustered container. By just changing the order it resolved my issue:

  • Load .babylon scene
  • Configure the clusreted lights like above
  • While waitingItemsCount > 0 and !scene.isReady() wait some time and do stuff to notify loading progress
  • Once fully loaded, attach scripts and configure post-processes etc.

The best solution would be to have ClusteredLightContainer to be fully serialized/parsed. I’m creating a new post in feature requests.

Again thanks a lot for you help!

Glad you found a workaround!

But that doesn’t explain why it worked on v8 but not on v9?

We are going to see to properly serialize clustered light containers.

1 Like

Exact !!

Maybe something changed starting from scene.isReady()? I remember to ensure all materials are compiled before rendering the scene (to avoid JIT compilation at least for static materials) I called for each material

await material.forceCompilationAsync(mesh, { ... })

And maybe it’s useless now because it’s done in scene.isReady(). In other words, material.isReadyForSubMesh compiles since some versions or another method is called that triggers the compilation. It’s just and idea, you are the engine master ^^

Should I still create the post on the forum?

No need, I’m on it!

1 Like

OMG you are so smart.
I have on more questions about Clustered light container. It makes the lights no avilable in the scene.lights array anymore (removed from nodes etc.). That means scene.getNodeByName or scene.getNodeById will never find the light nodes that we added in the clustered light container.

My question: should we be able to retrieve them using those methods? Should we not consider those lights as scene nodes anymore when they are added in a container? Should we update those methods to, if the current node is a clustered light container, to check into the .lights array of the container?

I’m asking those questions because on my side for my tests I added this method:

export function getNodeById(id: string, scene: Scene) {
	const clusteredLightContainer = scene.lights.find((light) => isClusteredLightContainer(light));

	let node = clusteredLightContainer?.lights.find((light) => light.id === id);
	if (!node) {
		node = scene.getNodeById(id);
	}

	return node;
}

I did this because it broke links between nodes in the scripts using Babylon.js Editor.

Of course, saying it’s the normal behavior and I have to implement the method above is a totally acceptable answer!

Thanks for you answer :heart:

Actually, AI is :slight_smile:

Here’s the PR for clustered light container serialization/parsing:

In my opinion, the ClusteredLightContainer uses Light objects as data carriers (position, color, range, falloff), but they lose their scene-graph semantics — no includedOnlyMeshes, no
excludedMeshes, no shadow generator, no individual canAffectMesh logic. Returning them from getLightById would be misleading since callers would reasonably assume the full Light API works.

But I’m open to any discussion on this topic!

cc @sebavan and @Deltakosh

1 Like

Thanks a lot AI !! :sweat_smile:
Does scene.onDataLoadedObservable ensure it’ll be called before the AppendSceneAsync or other loading method will be resolved? On my case I do:

typescript
await AppendSceneAsync(...);

const waitingItemsCount = scene.getWaitingItemsCount();

while (!scene.isDisposed && (!scene.isReady() || scene.getWaitingItemsCount() > 0)) {
    await new Promise<void>((resolve) => setTimeout(resolve, 150));

    const loadedItemsCount = waitingItemsCount - scene.getWaitingItemsCount();
    options?.onProgress?.(0.5 + (loadedItemsCount / waitingItemsCount) * 0.5);
}

I call scene.isReady right after. Just to be sure ^^

I totally agree with you. I’m going this way until we have an answer from them :slight_smile:

With the serialization/parsing PR now in place, you won’t need the onDataLoadedObservable workaround or the manual configureLights step anymore.

The ClusteredLightContainer now properly serializes its child lights into the .babylon file and re-adds them during scene parsing. So when AppendSceneAsync resolves, the container is already fully set up — the individual lights are never in scene.lights as separate entries, and scene.isReady() will compile materials with the correct 2-light setup from the start.

To answer your original question though: onDataLoadedObservable fires when all pending data drops to zero (including textures, meshes, sounds — not just the scene file), so it typically fires after AppendSceneAsync resolves, not before. But with the serialization fix, this timing no longer matters for your use case.

1 Like