[bug] AtmospherePBRMaterialPlugin auto-attaches to PBRMaterials created after `new Atmosphere(...)` and breaks their shader compile when env is a cube

**Versions:** `@babylonjsbabylonjs/core@9@babylonjs4.1` + `@babylonjs/addons@9.4.1` (also reproduces with the `babylonjs.addons.js` UMD on `https://preview.babylonjs.com/\`).

**Engine:** WebGL2.

**Browsers tested:** Chromium 1217 (Playwright), reproduces deterministically.

**OS:** Windows 11.

## Summary

`new Atmosphere(…)` calls `RegisterMaterialPlugin(…)` in its constructor, which subscribes a factory to `Material.OnEventObservable` for `MaterialPluginEvent.Created`. From that point on, **every** `PBRMaterial` constructed in the scene auto-attaches an `AtmospherePBRMaterialPlugin` instance.

The plugin’s `getCustomCode()` unconditionally injects

```glsl

vec3 environmentIrradiance = lightIntensity * sampleReflection(irradianceSampler, uv).rgb;

```

into the PBR fragment shader (gated by the plugin’s `USE_CUSTOM_REFLECTION` define, which is set in the plugin’s constructor based on `_atmosphere.diffuseSkyIrradianceLut !== null`).

When `scene.environmentTexture` is a regular cube map (i.e. the user is not in atmosphere mode), Babylon’s `pbrBlockReflection.js` only declares `irradianceSampler` under `#ifdef USEIRRADIANCEMAP`, which is **not set for cube envs**. The plugin’s injected code then references an undeclared identifier and the compile fails:

```

BJS - Unable to compile effect:

Error: FRAGMENT SHADER ERROR: 0:: ‘irradianceSampler’ : undeclared identifier

Offending line: vec3 environmentIrradiance = lightIntensity * sampleReflection(irradianceSampler, uv).rgb;

```

The new material renders invisible.

## What `setEnabled` and `dispose` do (and don’t)

- `Atmosphere.setEnabled(false)` only flips `_isEnabled`. It does **not** unregister the global plugin factory and does **not** detach plugin instances from already-bound materials.

- `Atmosphere.dispose()` calls `UnregisterMaterialPlugin(MaterialPlugin)` so future materials are no longer affected, but plugin instances on materials that were already bound at the time of dispose remain attached.

So the bug fires whenever a `PBRMaterial` is created during the lifetime of a (constructed-but-currently-disabled) `Atmosphere`, and that material then renders against a cube env.

## Standalone repro

A 5-step minimal repro reproduces the bug deterministically. The full script is at the end of this post.

Sequence (identical in both ESM and the UMD `babylonjs.addons.js` paths):

1. Build a scene with a cube `environmentTexture`.

2. Create `matA = new PBRMaterial(…)` and let it render at least one frame. **No atmosphere plugin attached** because `Atmosphere` doesn’t exist yet.

3. `const atmosphere = new Atmosphere(…)` and `setEnabled(true)`. The constructor registers the global `MaterialPluginEvent.Created` factory.

4. `atmosphere.setEnabled(false); scene.environmentTexture = cube;` and `markAllMaterialsAsDirty(MATERIAL_AllDirtyFlag)`. Plugin factory is **still registered**.

5. Create `matB = new PBRMaterial(…)`. Inspect `matB.pluginManager._plugins` — it contains `AtmospherePBRMaterialPlugin`. Force compile:

```

D\] matB plugins: \[PBRBRDF, PBRClearCoat, PBRIridescence, PBRAnisotropic, Sheen, PBRSubSurface, DetailMap, AtmospherePBRMaterialPlugin

⇒ AtmospherePBRMaterialPlugin LEAKED ONTO matB

```

```

BJS - Unable to compile effect:

Error: FRAGMENT SHADER ERROR: 0:1231: ‘irradianceSampler’ : undeclared identifier

Offending line: vec3 environmentIrradiance = lightIntensity * sampleReflection(irradianceSampler, uv).rgb;

```

`matB` is invisible. `matA` (created before the Atmosphere existed) renders normally because the plugin was never attached to it.

## How we know the error is caused by the plugin (not by the material)

Three independent proofs:

**1. The offending GLSL line exists *only* in the addon’s plugin source.**

```

$ grep -rE "environmentIrradiance.*=.*lightIntensity.*sampleReflection@babylonjs \

node_modules/@babylonjs/

addons/atmosphere/atmospherePBRMaterialPlugin.js:

vec3 environmentIrradiance = lightIntensity * sampleReflection(irradianceSampler, uv).rgb;

```

That exact line is the one Babylon’s compiler reports as `Offending line`. It’s emitted by `AtmospherePBRMaterialPlugin.getCustomCode()` inside its `CUSTOM_REFLECTION` chunk and is nowhere in core PBR shader code.

**2. Without the plugin, the *same* matB compiles cleanly.**

A control run that’s identical to the repro except it never constructs `Atmosphere`:

```

=== CONTROL: no atmosphere, only matA + matB ===

matA plugins: [PBRBRDF, PBRClearCoat, PBRIridescence, PBRAnisotropic, Sheen, PBRSubSurface, DetailMap]

matB plugins: [PBRBRDF, PBRClearCoat, PBRIridescence, PBRAnisotropic, Sheen, PBRSubSurface, DetailMap]

matB: compiled OK ← clean (7 plugins, no AtmospherePBRMaterialPlugin)

=== TRIGGER: atmosphere alive then disabled, matB created ===

matB plugins: […, AtmospherePBRMaterialPlugin] ← 8 plugins

→ 16× FRAGMENT SHADER ERROR: ‘irradianceSampler’ : undeclared identifier

```

Same scene, same env (`environmentSpecular.env` cube), same `PBRMaterial` props (`metallic=1, roughness=0.05`), same engine, same Babylon version. The only difference is whether `new Atmosphere(…)` is ever called. If the bug were inherent to the material, the control would fail too. It doesn’t.

**3. The plugin emits a sampler reference that core PBR doesn’t declare for cube envs.**

`node_modules/@babylonjs/core/Shaders/ShadersInclude/pbrBlockReflection.js` gates the sampler:

```glsl

#ifdef USEIRRADIANCEMAP

#ifdef REFLECTIONMAP_3D

,in samplerCube irradianceSampler

#endif

,in sampler2D irradianceSampler

#endif

#endif

```

`USEIRRADIANCEMAP` is not set when `scene.environmentTexture` is a cube HDRI (irradiance there comes from spherical harmonics). The plugin’s `CUSTOM_REFLECTION` chunk uses `irradianceSampler` regardless, and gates its emission only on its own private `USE_CUSTOM_REFLECTION` define (set to `_atmosphere.diffuseSkyIrradianceLut !== null` once at plugin construction). The two defines are not coordinated, so the moment the plugin is attached to a material whose env is a cube, the compile fails.

This rules out: material misconfiguration (control 1 has identical material), engine effect-cache pollution (control 1 has the same engine + same matA-then-matB sequence), bad envtex (control 1 uses the same cube), and a bug in core PBR (offending line isn’t in core).

## Babylon Playground

**Repro:** https://playground.babylonjs.com/#9O0Y6G#1

Just open and press Run — the snippet self-loads `babylonjs.addons.js` from `preview.babylonjs.com`. Open the JS console (F12) to see the leaked plugin name on `matB` and the `‘irradianceSampler’ : undeclared identifier` errors.

Snippet contents (also embedded at the URL above):

```js

const createScene = async function () {

const scene = new BABYLON.Scene(engine);

const camera = new BABYLON.ArcRotateCamera(“cam”, -Math.PI / 2, Math.PI / 2.4, 3.5,

BABYLON.Vector3.Zero(), scene);

camera.attachControl(canvas, true);

const sun = new BABYLON.DirectionalLight(“sun”, new BABYLON.Vector3(-0.2, -1, -0.1), scene);

const envTex = BABYLON.CubeTexture.CreateFromPrefilteredData(

https://assets.babylonjs.com/environments/environmentSpecular.env”, scene);

scene.environmentTexture = envTex;

scene.createDefaultSkybox(envTex, true, 1000, 0.18);

// [A] matA created BEFORE Atmosphere — no plugin attached.

const sphereA = BABYLON.MeshBuilder.CreateSphere(“sA”, { diameter: 1 }, scene);

sphereA.position.x = -1.2;

const matA = new BABYLON.PBRMaterial(“matA”, scene);

matA.metallic = 1; matA.roughness = 0.05;

sphereA.material = matA;

console.log(“[A] matA plugins:”,

(matA.pluginManager._plugins).map(p => p.name).join(", "));

await new Promise((r) => setTimeout(r, 800));

// [B] Construct Atmosphere — registers the global plugin factory.

const Addons = window.ADDONS;

const atmosphere = new Addons.Atmosphere(“atmo”, scene, [sun], {

physicalProperties: new Addons.AtmospherePhysicalProperties(),

isLinearSpaceComposition: true,

isLinearSpaceLight: true,

isSkyViewLutEnabled: true,

isAerialPerspectiveLutEnabled: true,

});

atmosphere.setEnabled(true);

await new Promise((r) => setTimeout(r, 1500));

// [C] Disable Atmosphere, restore cube env. Plugin factory still registered.

atmosphere.setEnabled(false);

scene.environmentTexture = envTex;

scene.markAllMaterialsAsDirty(BABYLON.Constants.MATERIAL_AllDirtyFlag);

await new Promise((r) => setTimeout(r, 800));

// [D] matB created AFTER Atmosphere — plugin auto-attaches.

const sphereB = BABYLON.MeshBuilder.CreateSphere(“sB”, { diameter: 1 }, scene);

sphereB.position.x = 1.2;

const matB = new BABYLON.PBRMaterial(“matB”, scene);

matB.metallic = 1; matB.roughness = 0.05;

sphereB.material = matB;

const matBPlugins = matB.pluginManager._plugins.map(p => p.name);

console.log(“[D] matB plugins:”, matBPlugins.join(", "));

console.log(matBPlugins.includes(“AtmospherePBRMaterialPlugin”)

? " ⇒ AtmospherePBRMaterialPlugin LEAKED ONTO matB"

: " ⇒ no leak");

// [E] Force compile — emits the irradianceSampler shader error.

matB.forceCompilation(sphereB,

() => console.log(“[E] matB compile callback (success)”),

undefined, false);

return scene;

};

```

Open the browser console after running. You’ll see the leaked plugin name on `matB`, then ~16 `‘irradianceSampler’ : undeclared identifier` shader errors. The right-hand sphere (matB) is invisible; the left-hand sphere (matA) renders normally.

## Suggested fix(es)

Pick whichever fits the addon’s design intent best:

1. `Atmosphere.setEnabled(false)` should detach the plugin from all PBRMaterials in the scene **and** unregister the global factory; `setEnabled(true)` re-registers and re-attaches.

2. Or, `AtmospherePBRMaterialPlugin.getCustomCode()` should gate the `irradianceSampler` reference so it only emits when the surrounding PBR shader is configured to declare it (i.e. tie it to the same conditions as `USEIRRADIANCEMAP` in `pbrBlockReflection.js`).

3. Or, document the required lifecycle and provide a public API to remove the plugin from a material (currently there is no public `MaterialPluginManager.removePlugin`).

## Workaround we are using

In our app, we now `atmosphere.dispose()` (and null the reference) on every asset swap (immediately before `LoadAssetContainerAsync` for the next glTF). The next “Daylight” toggle reconstructs a fresh `Atmosphere`. We also defer initial `Atmosphere` construction until at least one asset is in the scene (otherwise an early auto-restore from a persisted “daylight” preference creates a half-initialized atmosphere with the fallback camera, degrading the first asset’s render).

This avoids both the shader-compile failure on the new material and a related cold-init artifact.

Happy to provide the full `Playwright`-driven trace, the disposed materials’ compile-time defines, or the full ~3K-line failing fragment shader on request.

Thanks for the very thorough write-up — your diagnosis nails it.

Root cause confirmed. AtmospherePBRMaterialPlugin sets USE_CUSTOM_REFLECTION once in its constructor based on _atmosphere.diffuseSkyIrradianceLut !== null, and from then on it always emits a CUSTOM_REFLECTION chunk that references irradianceSampler. Core PBR only declares irradianceSampler under USEIRRADIANCEMAP, which the addon only wires up when the active env has .irradianceTexture set (the diffuse sky LUT). When the active env is a cube using spherical harmonics, USESPHERICALFROMREFLECTIONMAP is set instead — irradianceSampler does not exist, and the compile fails exactly as you traced.

Fix. I went with your option #2: gate the plugin’‘s USE_CUSTOM_REFLECTION dynamically in prepareDefines against the parent material’‘s USEIRRADIANCEMAP. When the irradiance map isn’'t active for that material, USE_CUSTOM_REFLECTION becomes false and the standard PBR reflection block runs (no hijack, no undeclared identifier). When you switch back to atmosphere mode, USEIRRADIANCEMAP flips on, the plugin re-enables its custom reflection, and a recompile is triggered automatically.

This is the narrowest, safest fix — it doesn’'t change the registration/attachment semantics of the plugin, so existing atmosphere scenes are unaffected. The “attached but inactive” plugin on a cube-env material is now a true no-op for reflection.

A separate, more invasive change to make setEnabled(false) detach plugin instances (your option #1) and to expose a public MaterialPluginManager.removePlugin (your option #3) is worth doing but is a bigger conversation about the addon’‘s lifecycle contract — happy to track that as a follow-up if you’'d like.

PR: [Addons] Fix AtmospherePBRMaterialPlugin breaking PBRMaterial compile for non-irradiance-map envs by Popov72 · Pull Request #18385 · BabylonJS/Babylon.js · GitHub

Your atmosphere.dispose() workaround on asset swaps remains a fine guardrail until that ships.

Great reaction Evgeni, I keep waiting until fix ships.

José