Regression in 8.47.2: TypeError: Cannot read properties of undefined (reading 'isRefractionEnabled') in babylonjs-loaders

Environment

  • babylonjs / @babylonjs/core: 8.47.2

  • babylonjs-loaders: 8.47.2

  • Build: Vite 7, TypeScript 5.9

  • Usage: import ‘babylonjs-loaders’ and SceneLoader.LoadAssetContainerAsync() for .glb assets

What works

  • With 8.47.0 (core + loaders) the app runs and loads GLB containers without this error.

What breaks

  • After upgrading to 8.47.2, at runtime we get:

TypeError: Cannot read properties of undefined (reading 'isRefractionEnabled')

The stack trace points into babylonjs-loaders (e.g. inside the loader code that reads something like ... .isRefractionEnabled), so some object the loader expects (e.g. material or refraction config) is undefined.

Changelog

  • 8.47.2 only documents a Playground/Inspector v2 fix 1.

  • 8.47.1 had Loaders/Inspector changes (e.g. “Inspector v2: GLTF Import tools”, “OpenPBR Translucent Slab”).

So the regression could have been introduced in 8.47.1 and only showed up after moving to 8.47.2, or be specific to 8.47.2’s bundle.

Question

  • Is this a known regression (e.g. missing null check around refraction/PBR in the GLB loader)?

  • If not, I can try to provide a minimal repro (small scene + one GLB and exact load sequence).

Thanks.

1 Like

Thanks for the report! Yes, if you could supply a minimal repro, that’d be great.

1 Like

Yes! Thanks for flagging this @onekit !!! We’ll jump on this as soon as possible.

A reproduction would help, as I have tried to reproduce the problem with different assets, but without success.

I’m working on it, but I haven’t been able to reproduce it yet.

The issue occurs when a PBR (or Standard ) material is assigned before the first frame. The loader’s observer triggers and attempts to read engine.getCaps().isRefractionEnabled . If getCaps() is still undefined at that point, the application crashes. This might not happen in the Playground because the first frame often executes before the material assignment.

A second scenario is similar: you preload a GLB, add the container to the scene, and then assign a new material to that preloaded mesh (e.g., replacing it with a PBRMaterial). The loader’s observer runs upon the material change and checks the same property. In the Playground, the .then() block usually executes after the first frame, meaning getCaps() is already initialized, which masks the error.

There’s no isRefractionEnabled property in the engine capabilities (?)

If you can’t repro in the playground, providing a live url could also work, as we will be able to put breakpoints and debug.

This text created with AI-assistant :grin:, it recommend me to report it like this:

TypeError: Cannot read properties of undefined (reading 'isRefractionEnabled') in babylonjs-loaders (GLTF2 transmissionWeight getter)

I get this error when loading GLB with SceneLoader.LoadAssetContainerAsync (or when assigning material to a mesh):

TypeError: Cannot read properties of undefined (reading 'isRefractionEnabled')
  at e3.get (babylonjs-loaders.js:307:46)
  at babylonjs-loaders.js:4948:18

Cause: In the GLTF2 loader bundle, the getter for transmissionWeight (KHR_materials_transmission) reads:

this._material.subSurface.isRefractionEnabled

without checking if _material or subSurface exist. When the material is undefined or not PBR (e.g. StandardMaterial has no subSurface), subSurface is undefined and reading .isRefractionEnabled throws.

Suggested fix: Guard the read in the loader, e.g.:

  • Getter: this._material?.subSurface?.isRefractionEnabled ? this._material.subSurface.refractionIntensity : 0
  • Setter: only write when this._material?.subSurface exists.

I use babylonjs 8.49.3 and babylonjs-loaders 8.49.3.

I can’t reproduce it on Playground, and create test-env for this case here: https://test.game.1kit.net/

It looks like it is because we create an adapter on a standardMaterial and not PBR. @MiiBond could you have a look ?

Hi @sebavan. You know, I’m not even sure it’s related to the BabylonJS code, because a couple of days ago, the textures on meshes I was working on suddenly turned black, and a lot of things stopped working because the global WebGL API was updated. And problems started pouring in from everywhere. I thought rolling back to previous versions would save me, but in the previous versions, I started getting errors about loading GLB files. And then I realized it’s not that simple and the solution could take time. And as luck would have it, this has become a blocker for my game development, and my eyes are already wet with tears.

I’m not sure how the loading adapter could be created for a StandardMaterial. Do we know which GLB is triggering this issue? I can add guards in the loading adapter but it would be helpful to know the full story about why this is happening.

The issue is that the error occurs for all meshes, not just GLBs. It seems the engine tries to access isRefractionEnabled on the material before I even have a chance to assign it or before the material is fully initialized.
My Ground.ts class:

import {
  Color3,
  Material,
  Mesh,
  MeshBuilder,
  PBRMaterial,
  PhysicsAggregate,
  PhysicsBody,
  PhysicsMotionType,
  PhysicsShapeBox,
  PhysicsShapeHeightField,
  PhysicsShapeType,
  Quaternion,
  Scene,
  StandardMaterial,
  Texture,
  Vector3,
} from 'babylonjs'
import ShapeMask from '@game/physics/ShapeMaskInterface'
import { GroundData } from '@game/map/LevelDataInterface'
import { PI, HALF_PI } from '@game/math/MathConstants'

export default class Ground {
  mesh: Mesh | null = null
  private fences: Mesh[] = []
  private fenceTexture: Texture | undefined
  private fenceMaterial: StandardMaterial | undefined
  private fenceMaterialOuter: StandardMaterial | undefined
  private grassBaseTexture: Texture | undefined
  private grassBaseMaterial: StandardMaterial | undefined

  constructor(public scene: Scene) {
    if (!scene) {
      throw new Error('Scene is not provided.')
    }
  }

  async create(data: GroundData, levelId: string): Promise<Mesh> {
    this.dispose()

    const { width, height, depth, uScale, vScale, subdivisions } = data
    const heightMap = data.heightMap ? '/levels/' + levelId + '/img/' + data.heightMap : undefined
    const fenceTextureImageFile = data.fenceTexture ? '/levels/' + levelId + '/img/' + data.fenceTexture : '/img/wooden-wall.jpg'
    const textureImageFile = data.texture ? '/levels/' + levelId + '/img/' + data.texture : '/ground/mossy.jpg'
    const pbrTextureBeginOfPath = data.pbrTexture ? '/levels/' + levelId + '/img/' + data.pbrTexture : undefined
    let mesh: Mesh
    let shape: PhysicsShapeBox | PhysicsShapeHeightField | undefined
    if (heightMap) {
      mesh = MeshBuilder.CreateGroundFromHeightMap(
        'ground',
        heightMap,
        {
          height: height,
          width: width,
          maxHeight: depth,
          subdivisions: subdivisions,
        },
        this.scene,
      )
      await new Promise<void>((resolve) => {
        mesh.onReady = () => resolve()
      })

      // console.log(heightMap)
      shape = await this.getShapeHeightMap(heightMap, width, height)
    } else {
      mesh = MeshBuilder.CreateBox('ground', { height: height, width: width, depth: depth }, this.scene)
      shape = this.getShapeBox(width, height, depth!)
    }

    // Material assignment: uncomment next line to use real textures; otherwise a default placeholder is used (Babylon requires mesh.material to exist)
    this.applyGroundMaterial(mesh, textureImageFile, uScale, vScale, pbrTextureBeginOfPath)
    if (!mesh.material) {
      this.applyDefaultGroundMaterial(mesh)
    }

    if (fenceTextureImageFile) {
      this.createFence(width, height, uScale, vScale, fenceTextureImageFile)
    }

    mesh.checkCollisions = true
    mesh.isPickable = true // for detect stand on surface
    mesh.metadata = { active: true, type: 'ground' }
    mesh.receiveShadows = true

    if (shape) {
      shape.filterMembershipMask = ShapeMask.Ground
      shape.filterCollideMask = ShapeMask.Item | ShapeMask.Wall | ShapeMask.Player

      if (heightMap) {
        // console.log(shape)
        const aggregate = new PhysicsAggregate(
          mesh,
          PhysicsShapeType.MESH,
          {
            mass: 0,
            friction: 1.2,
            restitution: 0.3,
          },
          this.scene,
        )
        aggregate.shape = shape
      } else {
        const body = new PhysicsBody(mesh, PhysicsMotionType.STATIC, false, this.scene)
        body.shape = shape
        shape.material = { friction: 3.5, restitution: 0.1 }
        body.setMassProperties({
          mass: 0,
        })
      }
    }

    this.mesh = mesh

    return mesh
  }

  /**
   * Applies ground material to the mesh (PBR or standard texture).
   * Enable/disable by calling this method from create() or commenting the call out.
   */
  private applyGroundMaterial(mesh: Mesh, textureImageFile: string, uScale: number, vScale: number, pbrTextureBeginOfPath?: string): void {
    if (!textureImageFile) return
    const material = pbrTextureBeginOfPath
      ? this.getPBRMaterial(pbrTextureBeginOfPath, uScale, vScale)
      : this.getMaterial(textureImageFile, uScale, vScale)
    this.setMeshMaterialWithoutNotifying(mesh, material)
  }

  /**
   * Applies a minimal default material so that mesh.material is never undefined.
   * Babylon (e.g. loaders) expects every mesh to have a material with properties like isRefractionEnabled.
   * Uses internal assign to avoid triggering onMaterialChangedObservable, which would run the loader's
   * observer and cause it to read isRefractionEnabled from undefined (old material) and throw.
   */
  private applyDefaultGroundMaterial(mesh: Mesh): void {
    this.scene.getMaterialById('groundMaterialPlaceholder')?.dispose()
    const material = new StandardMaterial('groundMaterialPlaceholder', this.scene)
    material.diffuseColor = new Color3(0.5, 0.5, 0.5)
    material.specularColor = new Color3(0, 0, 0)
    this.setMeshMaterialWithoutNotifying(mesh, material)
  }

  /**
   * Sets mesh material without triggering onMaterialChangedObservable.
   * Avoids babylonjs-loaders observer which reads material.isRefractionEnabled and throws when the
   * observer runs during the setter (old material can be undefined).
   */
  private setMeshMaterialWithoutNotifying(mesh: Mesh, material: Material): void {
    const data = (mesh as any)._internalAbstractMeshDataInfo as { _material: Material | null } | undefined
    if (!data) return
    const oldMat = data._material
    if (oldMat && (oldMat as any).meshMap) (oldMat as any).meshMap[mesh.uniqueId] = undefined
    data._material = material
    if (material && (material as any).meshMap) (material as any).meshMap[mesh.uniqueId] = mesh
  }

  private getShapeBox(width: number, height: number, depth: number) {
    return new PhysicsShapeBox(new Vector3(0, -height / 2, 0), Quaternion.Identity(), new Vector3(width, depth, height), this.scene)
  }

  private async getShapeHeightMap(heightMap: string, width: number, height: number): Promise<PhysicsShapeHeightField | undefined> {
    if (!this.scene) {
      throw new Error('Scene is not provided.')
    }

    const image = new Image()
    image.src = heightMap

    await new Promise<void>((resolve, reject) => {
      image.onload = () => resolve()
      image.onerror = reject
    })

    const canvas = document.createElement('canvas')
    canvas.width = image.width
    canvas.height = image.height

    const ctx = canvas.getContext('2d')
    if (!ctx) {
      throw new Error('Canvas Rendering Context 2D is not loaded.')
    }
    ctx.drawImage(image, 0, 0)

    const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height)
    const heights: number[] = []

    for (let i = 0; i < imgData.height; i++) {
      for (let j = 0; j < imgData.width; j++) {
        const index = (i * imgData.width + j) * 4
        const heightValue = imgData.data[index] / 255
        heights.push(heightValue)
      }
    }
    const heightFieldData = new Float32Array(heights)
    // console.log('float32array', heightFieldData)
    return new PhysicsShapeHeightField(width, height, imgData.width, imgData.height, heightFieldData, this.scene)
  }

  private getPBRMaterial(pbrTextureBeginOfPath: string, uScale: number, vScale: number) {
    const albedo = pbrTextureBeginOfPath + '-albedo.png'
    const ao = pbrTextureBeginOfPath + '-ao.png'
    const height = pbrTextureBeginOfPath + '-height.png'
    const normal = pbrTextureBeginOfPath + '-normal-ogl.png'
    const metallic = pbrTextureBeginOfPath + '-metal.png'

    const albedoTexture = new Texture(albedo, this.scene)
    const aoTexture = new Texture(ao, this.scene)
    const heightTexture = new Texture(height, this.scene)
    const normalTexture = new Texture(normal, this.scene)
    const metallicRoughnessTexture = new Texture(metallic, this.scene)

    albedoTexture.uScale = uScale
    albedoTexture.vScale = vScale
    aoTexture.uScale = uScale
    aoTexture.vScale = vScale
    heightTexture.uScale = uScale
    heightTexture.vScale = vScale
    normalTexture.uScale = uScale
    normalTexture.vScale = vScale
    metallicRoughnessTexture.uScale = uScale
    metallicRoughnessTexture.vScale = vScale

    this.scene.getMaterialById('groundMaterial')?.dispose()
    const material = new PBRMaterial('groundMaterial', this.scene)

    material.albedoTexture = albedoTexture
    material.ambientTexture = aoTexture
    material.bumpTexture = normalTexture
    material.metallicTexture = metallicRoughnessTexture

    material.metallic = 1
    material.roughness = 1

    return material
  }

  private getMaterial(textureImageFile: string, uScale: number, vScale: number) {
    const grassBaseTexture = new Texture(textureImageFile, this.scene)
    grassBaseTexture.wrapU = Texture.WRAP_ADDRESSMODE
    grassBaseTexture.wrapV = Texture.WRAP_ADDRESSMODE
    grassBaseTexture.uScale = uScale
    grassBaseTexture.vScale = vScale

    this.scene.getMaterialById('groundMaterial')?.dispose()
    const material = new StandardMaterial('groundMaterial', this.scene)
    material.diffuseTexture = grassBaseTexture
    material.specularColor = new Color3(0, 0, 0)
    material.ambientColor = new Color3(0.01, 0.01, 0.01)
    material.diffuseColor = new Color3(0.8, 0.85, 0.8)
    material.maxSimultaneousLights = 10

    this.grassBaseTexture = grassBaseTexture
    this.grassBaseMaterial = material

    return material
  }

  private createFence(width: number, height: number, uScale: number, vScale: number, fenceTextureImageFile: string) {
    const fenceTexture = new Texture(fenceTextureImageFile, this.scene)
    fenceTexture.uScale = 1
    fenceTexture.vScale = 1

    // Inner material (opaque) - visible from inside the level
    this.scene.getMaterialById('fenceMaterial')?.dispose()
    const fenceMaterial = new StandardMaterial('fenceMaterial', this.scene)
    fenceMaterial.diffuseTexture = fenceTexture
    fenceMaterial.roughness = 1
    fenceMaterial.specularColor = new Color3(0, 0, 0)
    fenceMaterial.specularPower = 64
    fenceMaterial.backFaceCulling = true // Single-sided, visible only from inside
    fenceMaterial.alpha = 1.0

    // Outer material (semi-transparent) - visible from outside the level
    this.scene.getMaterialById('fenceMaterialOuter')?.dispose()
    const fenceMaterialOuter = new StandardMaterial('fenceMaterialOuter', this.scene)
    fenceMaterialOuter.diffuseTexture = fenceTexture
    fenceMaterialOuter.roughness = 1
    fenceMaterialOuter.specularColor = new Color3(0, 0, 0)
    fenceMaterialOuter.specularPower = 64
    fenceMaterialOuter.backFaceCulling = true // Single-sided, visible only from outside
    fenceMaterialOuter.alpha = 0.5
    fenceMaterialOuter.transparencyMode = Material.MATERIAL_ALPHABLEND

    const wallHeight = 8
    const planeThickness = 0.2 // Distance between inner and outer planes to prevent z-fighting

    // Front wall - inner plane (facing inside, z = height/2)
    const frontPlaneInner = MeshBuilder.CreateTiledPlane(
      'frontPlaneInner',
      { width, height: wallHeight, tileHeight: vScale, tileWidth: uScale },
      this.scene,
    )
    frontPlaneInner.metadata = { active: true, type: 'fence' }
    frontPlaneInner.material = fenceMaterial
    frontPlaneInner.position = new Vector3(0, wallHeight / 2, height / 2 - planeThickness)
    frontPlaneInner.checkCollisions = true

    // Front wall - outer plane (facing outside, z = height/2)
    const frontPlaneOuter = MeshBuilder.CreateTiledPlane(
      'frontPlaneOuter',
      { width, height: wallHeight, tileHeight: vScale, tileWidth: uScale },
      this.scene,
    )
    frontPlaneOuter.metadata = { active: true, type: 'fence' }
    frontPlaneOuter.material = fenceMaterialOuter
    frontPlaneOuter.position = new Vector3(0, wallHeight / 2, height / 2 + planeThickness)
    frontPlaneOuter.rotation.x = PI

    // Physics body for the front wall (using inner plane)
    const frontBody = new PhysicsBody(frontPlaneInner, PhysicsMotionType.STATIC, false, this.scene)
    frontBody.shape = new PhysicsShapeBox(new Vector3(0, 0, 0), Quaternion.Identity(), new Vector3(width, wallHeight, 0.5), this.scene)
    frontBody.setMassProperties({ mass: 0 })

    // Back wall - inner plane (facing inside, z = -height/2)
    const backPlaneInner = MeshBuilder.CreateTiledPlane(
      'backPlaneInner',
      { width, height: wallHeight, tileHeight: vScale, tileWidth: uScale },
      this.scene,
    )
    backPlaneInner.material = fenceMaterial
    backPlaneInner.metadata = { active: true, type: 'fence' }
    backPlaneInner.position = new Vector3(0, wallHeight / 2, -height / 2 + planeThickness)
    backPlaneInner.rotation.x = -PI
    backPlaneInner.checkCollisions = true

    // Back wall - outer plane (facing outside, z = -height/2)
    const backPlaneOuter = MeshBuilder.CreateTiledPlane(
      'backPlaneOuter',
      { width, height: wallHeight, tileHeight: vScale, tileWidth: uScale },
      this.scene,
    )
    backPlaneOuter.material = fenceMaterialOuter
    backPlaneOuter.metadata = { active: true, type: 'fence' }
    backPlaneOuter.position = new Vector3(0, wallHeight / 2, -height / 2 - planeThickness)

    // Physics body for the back wall (using inner plane)
    const backBody = new PhysicsBody(backPlaneInner, PhysicsMotionType.STATIC, false, this.scene)
    backBody.shape = new PhysicsShapeBox(new Vector3(0, 0, 0), Quaternion.Identity(), new Vector3(width, wallHeight, 0.5), this.scene)
    backBody.setMassProperties({ mass: 0 })

    // Left wall - inner plane (facing inside, x = width/2)
    const leftPlaneInner = MeshBuilder.CreateTiledPlane(
      'leftPlaneInner',
      { width: width, height: wallHeight, tileHeight: vScale, tileWidth: uScale, sideOrientation: 0 },
      this.scene,
    )
    leftPlaneInner.material = fenceMaterial
    leftPlaneInner.metadata = { active: true, type: 'fence' }
    leftPlaneInner.position = new Vector3(width / 2 - planeThickness, wallHeight / 2, 0)
    leftPlaneInner.checkCollisions = true
    leftPlaneInner.rotation.y = HALF_PI

    // Left wall - outer plane (facing outside, x = width/2)
    const leftPlaneOuter = MeshBuilder.CreateTiledPlane(
      'leftPlaneOuter',
      { width: width, height: wallHeight, tileHeight: vScale, tileWidth: uScale, sideOrientation: 0 },
      this.scene,
    )
    leftPlaneOuter.material = fenceMaterialOuter
    leftPlaneOuter.metadata = { active: true, type: 'fence' }
    leftPlaneOuter.position = new Vector3(width / 2 + planeThickness, wallHeight / 2, 0)
    leftPlaneOuter.rotation.y = -HALF_PI

    // Physics body for the left wall (using inner plane)
    const leftBody = new PhysicsBody(leftPlaneInner, PhysicsMotionType.STATIC, false, this.scene)
    const leftRotationQuaternion = Quaternion.FromEulerAngles(0, HALF_PI, 0)
    leftBody.shape = new PhysicsShapeBox(new Vector3(0, 0, 0), leftRotationQuaternion, new Vector3(0.5, wallHeight, width), this.scene)
    leftBody.setMassProperties({ mass: 0 })

    // Right wall - inner plane (facing inside, x = -width/2)
    const rightPlaneInner = MeshBuilder.CreateTiledPlane(
      'rightPlaneInner',
      { width: width, height: wallHeight, tileHeight: vScale, tileWidth: uScale, sideOrientation: 0 },
      this.scene,
    )
    rightPlaneInner.material = fenceMaterial
    rightPlaneInner.metadata = { active: true, type: 'fence' }
    rightPlaneInner.position = new Vector3(-width / 2 + planeThickness, wallHeight / 2, 0)
    rightPlaneInner.checkCollisions = true
    rightPlaneInner.rotation.y = -HALF_PI

    // Right wall - outer plane (facing outside, x = -width/2)
    const rightPlaneOuter = MeshBuilder.CreateTiledPlane(
      'rightPlaneOuter',
      { width: width, height: wallHeight, tileHeight: vScale, tileWidth: uScale, sideOrientation: 0 },
      this.scene,
    )
    rightPlaneOuter.material = fenceMaterialOuter
    rightPlaneOuter.metadata = { active: true, type: 'fence' }
    rightPlaneOuter.position = new Vector3(-width / 2 - planeThickness, wallHeight / 2, 0)
    rightPlaneOuter.rotation.y = HALF_PI

    // Physics body for the right wall (using inner plane)
    const rightBody = new PhysicsBody(rightPlaneInner, PhysicsMotionType.STATIC, false, this.scene)
    const rightRotationQuaternion = Quaternion.FromEulerAngles(0, -HALF_PI, 0)
    rightBody.shape = new PhysicsShapeBox(new Vector3(0, 0, 0), rightRotationQuaternion, new Vector3(0.5, wallHeight, width), this.scene)
    rightBody.setMassProperties({ mass: 0 })

    this.fenceTexture = fenceTexture
    this.fenceMaterial = fenceMaterial
    this.fenceMaterialOuter = fenceMaterialOuter

    // Store all fence planes for proper disposal
    this.fences.push(
      frontPlaneInner,
      frontPlaneOuter,
      backPlaneInner,
      backPlaneOuter,
      leftPlaneInner,
      leftPlaneOuter,
      rightPlaneInner,
      rightPlaneOuter,
    )
  }

  dispose(): void {
    this.fenceTexture?.dispose()
    this.fenceMaterial?.dispose()
    this.fenceMaterialOuter?.dispose()
    this.grassBaseTexture?.dispose()
    this.grassBaseMaterial?.dispose()

    if (this.mesh) {
      if (this.mesh.material) {
        this.disposeMaterial(this.mesh.material)
      }
      this.mesh.dispose(false, true)
      this.mesh = null
    }

    this.fences.forEach((fence) => {
      if (fence.material) {
        this.disposeMaterial(fence.material)
      }
      fence.dispose(false, true)
    })
    this.fences = []
  }

  private disposeMaterial(material: Material): void {
    if (material instanceof PBRMaterial) {
      material.albedoTexture?.dispose()
      material.ambientTexture?.dispose()
      material.bumpTexture?.dispose()
      material.metallicTexture?.dispose()
    } else if (material instanceof StandardMaterial) {
      material.diffuseTexture?.dispose()
    }
    material.dispose()
  }
}

@MiiBond has a lead on smthg I guess PR will be coming soon-ish :slight_smile:

1 Like

PR is up.

2 Likes

You’re my hero! It works!!!