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()
}
}