Issues with Model textures not displaying in Three.js – Material Conversion Problem?

I suspect this question may have been asked, but I’ll try.

I’m experiencing an issue when loading GLB models (exported from Sketchfab) directly into three.js viewer. Although the geometry appears correct, some models display as completely white—without any of their intended textures.
In three editor problem the same , look at the pic below

For a better understanding I will give a photo of the files, structure and the

Folder structure

In the source folder only .glb file
In textures pic below

The initial download code thanks to @donmccurdy is :

import { useEffect, useState, useMemo } from 'react'

import { LoaderUtils, Cache } from 'three'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { FBXLoader, TGALoader } from 'three/examples/jsm/Addons.js'
import { DRACOLoader } from 'three/examples/jsm/Addons.js'
import { KTX2Loader } from 'three/examples/jsm/Addons.js'
import { LoadingManager, REVISION } from 'three'
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js'
import { useLoader, useThree } from '@react-three/fiber'

interface LoaderProps {
	url: string
	rootPath: string
	assetMap: Map<string, File>
	fileType: string
	rootFile: File | string
}

const useModelLoader = ({
	url,
	rootPath,
	assetMap,
	fileType,
	rootFile,
}: LoaderProps): any => {
	const { gl, scene } = useThree()
	const [content, setContent] = useState<any>({
		scene: null,
		clips: null,
	})

	const MANAGER = useMemo(() => new LoadingManager(), [])
	const THREE_PATH = `https://unpkg.com/three@0.${REVISION}.x`

	const DRACO_LOADER = useMemo(
		() =>
			new DRACOLoader(MANAGER).setDecoderPath(
				`${THREE_PATH}/examples/jsm/libs/draco/gltf/`
			),
		[MANAGER]
	)

	const KTX2_LOADER = useMemo(
		() =>
			new KTX2Loader(MANAGER).setTranscoderPath(
				`${THREE_PATH}/examples/jsm/libs/basis/`
			),
		[MANAGER]
	)

	
	const traverseMaterials = (object: any, callback: (mat: any) => void) => {
		object.traverse((node: any) => {
			if (!node.geometry) return
			const materials = Array.isArray(node.material)
				? node.material
				: [node.material]
			materials.forEach(callback)
		})
	}

	// Функция очистки модели
	const cleanup = (model: any) => {
		if (!model) return

		
		scene.remove(model)

		
		model.traverse((node: any) => {
			if (node.geometry) {
				node.geometry.dispose()
			}
		})

		
		if (model.animations) {
			model.animations.forEach((animation: any) => {
				if (animation.clip) {
					animation.clip.dispose()
				}
			})
		}

		
		traverseMaterials(model, (material: any) => {
			
			if (material.dispose) {
				material.dispose()
			}
			for (const key in material) {
				if (
					key !== 'envMap' &&
					material[key] &&
					material[key].isTexture &&
					material[key].dispose
				) {
					material[key].dispose()
				}
			}
		})
	}

	useEffect(() => {
		if (!url) return

		const blobURLs: string[] = []

	
		if (content.scene) {
			cleanup(content.scene)
			setContent({ scene: null, clips: null })
		}

		// Настройка менеджера для обработки дополнительных ресурсов
		MANAGER.setURLModifier((someUrl: string) => {
			const baseURL = LoaderUtils.extractUrlBase(url)
			const normalizedURL =
				rootPath +
				decodeURI(someUrl)
					.replace(baseURL, '')
					.replace(/^(\.?\/)/, '')

			if (assetMap.has(normalizedURL)) {
				const blob = assetMap.get(normalizedURL)
				const blobURL = URL.createObjectURL(blob!)
				blobURLs.push(blobURL)
				return blobURL
			}
			return someUrl
		})

		let loader: GLTFLoader | FBXLoader | null = null
		if (fileType === 'gltf/glb') {
			loader = new GLTFLoader(MANAGER)
				.setCrossOrigin('anonymous')
				.setDRACOLoader(DRACO_LOADER)
				.setKTX2Loader(KTX2_LOADER.detectSupport(gl))
				.setMeshoptDecoder(MeshoptDecoder)
			MANAGER.onLoad = () => {
				console.log('All textures loaded successfully')
			}

			MANAGER.onError = url => {
				console.error('Error loading texture:', url)
			}
		} else if (fileType === 'fbx') {
			loader = new FBXLoader(MANAGER).setCrossOrigin('anonymous')
		}

		if (loader) {
			loader.load(
				url,
				(object: any) => {
					console.log('Loaded model:', object)
					let loadedScene = null
					let clips = null

					if (fileType === 'gltf/glb') {
						loadedScene = object.scene || object.scenes[0]
						clips = object.animations || []
					} else if (fileType === 'fbx') {
						loadedScene = object
						clips = object.animations || []
					}

					if (!loadedScene) {
						console.error('No scene found in the model')
						return
					}

					setContent({ scene: loadedScene, clips })
					
					blobURLs.forEach(url => URL.revokeObjectURL(url))
				},
				undefined,
				error => {
					console.error('Error loading model:', error)
				}
			)
		}

		return () => {
			blobURLs.forEach(url => URL.revokeObjectURL(url))
			DRACO_LOADER.dispose()
			KTX2_LOADER.dispose()
			useLoader.clear(GLTFLoader, url)
			if (content.scene) cleanup(content.scene)
			Cache.clear()
		}
	}, [url, fileType, rootPath, assetMap, rootFile])

	return content
}

export default useModelLoader

Here’s what I’ve observed:

  • When I import a GLB file directly (by uploading the folder containing the model and its textures), the textures do not appear.
  • However, if I open the same GLB file in Blender, enable viewport shading (which appears to properly interpret the model’s material settings), and then export it again, the model displays with its textures correctly in my viewer.
  • Websites like 3dviewer.net and fetchcfd.com/3d-viewer can load these models and display textures correctly without any manual re-exporting.

This leads me to believe that the original GLB files might have non-standard or incomplete material definitions (perhaps due to non-PBR setups or vertex-color materials) that three.js isn’t handling out of the box. Blender, on the other hand, seems to “fix” or convert these materials into a standard PBR format during re-export.

My questions are:

  1. What might be causing the texture data in the original GLB files to not display correctly in three.js? Are there known issues with Sketchfab-exported GLBs regarding material settings or texture embedding?
  2. How are advanced viewers (like 3dviewer.net and fetchcfd.com) able to automatically convert or interpret these materials correctly? Do they apply a specific post-processing or material conversion step?
  3. Is there a recommended approach or workaround within three.js to automatically handle these discrepancies? For example, can I programmatically adjust the material properties after loading the GLB so that the textures display correctly without needing to re-export via Blender?

Any insights, best practices, or workarounds would be greatly appreciated!

Looking forward to your help.

Do you see any errors in the JavaScript console? I expect this model is using the deprecated KHR_materials_pbrSpecularGlossiness extension for the glTF format. These had to be rendered as custom THREE.ShaderMaterial materials — not standard three.js materials — and support was removed in three.js r147 to simplify the loader.

There are various ways to convert these spec/gloss materials into standard metal/rough (MeshStandardMaterial or MeshPhysicalMaterial) materials offline or at runtime:

1 Like

We’ve discussed a bit doing the conversion automatically, but it’s very slow, and would cause problems with GPU compressed texture formats. We worried that developers might not realize it’s happening, and would deploy apps doing the slow conversion every time a viewer opens the page, when it should instead be done once up-front. See: GLTFLoader, GLTFExporter: Remove KHR_materials_pbrSpecularGlossiness by donmccurdy · Pull Request #24950 · mrdoob/three.js · GitHub

1 Like

Thank you for your detailed response and for sharing the GitHub discussion reference. I’ve looked through the linked solution, and it seems like the approach using glTF-Transform for material conversion is designed to be more optimal for runtime use.

Could you please confirm if you consider this solution—along with the runtime conversion logic provided in the link—to be the best approach for production applications? In other words, if I want to avoid re-exporting models in Blender, would you recommend following this approach (or a similar runtime/preprocessing method) as the most efficient and robust solution?

I’m particularly interested in understanding if there are any additional libraries or best practices that can help implement this conversion without compromising performance or security.

Thank you again for your guidance!

Yes,and one more,I’m glad I mentioned you, because I noticed that you had considered this variant and thought to solve it with this error log.

I hope that wasn’t an unreasonable question.

1 Like

I would avoid models using KHR_materials_pbrSpecularGlossiness at runtime in production, if you can. For your own models, converting them (either with Blender or with glTF Transform) to metal/rough before sending to runtime would be ideal. Optionally, you could automate this in your build process with a Vite plugin or bash script.

If you want to accept user uploads — not just your own models — then I think you would need to either detect the extension and prompt the user to fix it themselves, or include glTF Transform as pre-processing done once before import/upload. This is how https://gltf.report/ handles spec/gloss before viewing models in three.js, for example.

In any case, if your app has 10,000 visitors on mobile phones, there’s no reason that each visitor’s phone should spend time unpacking and repacking textures on each pageload. :slight_smile:

1 Like

I think Vite plugin should be the best way to automate, because it frees you from intentionally changing the logic, but for now everything is local, and I’m focusing on Stats/StatsGL to determine how hard it will be for the device.

Anyway, your materials will help to understand it better.

Thanks :handshake:

1 Like