Loading a model with GLTFLoader through Web Worker causes mesh deformation on SkinnedMesh

My page is taking a long time to load because of the loading of the models that I do as soon as the page loads

During loading in the main thread, page interactions, such as hover effects, etc., stop working

One of the ideas I had to try to solve this was to load the models through the web worker

The page freezing decreased a lot, to almost 0!

However, when I added the models to the scene, they were all deformed

I’ll leave an example of a working model (loaded from the main thread) and the same models, loaded by the web worker

I’ve tried to debug this a lot but I have no idea what it could be or what it might have to do with it

I’ll leave a link below to a practical example on codesandbox with reproduction (change between loading modes by changing the state of the checkbox in the lower right corner and then press “Instatiate Scene”. The buttons have a yellow hover to test with the mouse if the page is really stuck during loading, as well as the cube animation)

https://codesandbox.io/p/sandbox/y87vj9

Any help or guidance will be very welcome, I’ve been trying to find a way to improve the optimization of this page for about 2 weeks, and this was the only thing that had a good result in performance, however, it’s not working.

This is a ThreeJs limitation

All models loading needs to share “THREE” context

As you try to load the models from Web Worker, you have some dependencies differs that causes this behavior on your model Mesh

why don’t you send data like buffer geometry, so this is perfect way. and in client you can use BufferGeometry to make mesh lighter and easy to load


import { DRACOLoader, GLTFLoader } from "three/examples/jsm/Addons.js";
import { KTX2Loader } from "three/examples/jsm/loaders/KTX2Loader.js";

self.onmessage = async (event) => {
    const { url, renderer } = event.data;
    const dracoLoader = new DRACOLoader();
    dracoLoader.setDecoderPath("https://unpkg.com/three@0.158.0/examples/jsm/libs/draco/");
    dracoLoader.setDecoderConfig({ type: "js" });
    dracoLoader.setWorkerLimit(4);
    dracoLoader.preload();

    const ktx2Loader = new KTX2Loader()
        .setTranscoderPath('https://unpkg.com/three/examples/jsm/libs/basis/')
        .detectSupport(renderer);

    const loader = new GLTFLoader();
    loader.setDRACOLoader(dracoLoader);
    loader.setKTX2Loader(ktx2Loader);

    let cVertex = 0;
    let cIndex = 0;
    loader.load(url, async (gltf) => {

        let meshes = [];
        const images = gltf.parser.json.images ? gltf.parser.json.images.map(img => img.uri || img.name) : [];

        gltf.scene.traverse((child) => {
            if (child.isMesh) {
                let geometry = child.geometry;

                if (!geometry.boundingBox) geometry.computeBoundingBox();
                if (!geometry.boundingSphere) geometry.computeBoundingSphere();

                const positions = geometry?.attributes.position ? new Float32Array(geometry.attributes.position.array) : null;
                const normals = geometry?.attributes.normal ? new Float32Array(geometry.attributes.normal.array) : null;
                const uvs = geometry?.attributes.uv ? new Float32Array(geometry.attributes.uv.array) : null;
                const indices = geometry?.index ? new Uint32Array(geometry.index.array) : null;

                cVertex += geometry.attributes.position.count;
                cIndex += geometry.index.count

                meshes.push({
                    name: child.name,
                    scale: child.scale.toArray(),
                    attributes: {
                        positions: positions?.buffer,
                        normals: normals?.buffer !== null ? normals?.buffer : null,
                        uvs: uvs?.buffer,
                        indices: indices?.buffer
                    },
                    boundingBox: geometry.boundingBox,
                    boundingSphere: geometry.boundingSphere,
                    material: child.material ? extractMaterialInfo(child.material, url) : null
                });
            }
        });

        self.postMessage({
            type: 'GLTFMeshes', meshes, images
        });
    });
};

function extractMaterialInfo(material, url) {
    if (!material) return null;

    const basePath = url.split('/').slice(0, -1).join('/') + '/';

    return {
        name: material.name || "Unnamed",
        color: material.color ? material.color.getHexString() : null,
        metalness: material.metalness !== undefined ? material.metalness : null,
        roughness: material.roughness !== undefined ? material.roughness : null,
        ior: material.ior !== undefined ? material.ior : null,
        clearcoat: material.clearcoat !== undefined ? material.clearcoat : null,
        clearcoatRoughness: material.clearcoatRoughness !== undefined ? material.clearcoatRoughness : null,
        sheen: material.sheen !== undefined ? material.sheen : null,
        sheenColor: material.sheenColor ? material.sheenColor.getHexString() : null,
        sheenRoughness: material.sheenRoughness !== undefined ? material.sheenRoughness : null,
        transmission: material.transmission !== undefined ? material.transmission : null,
        thickness: material.thickness !== undefined ? material.thickness : null,
        attenuationDistance: material.attenuationDistance !== undefined ? material.attenuationDistance : null,
        attenuationColor: material.attenuationColor ? material.attenuationColor.getHexString() : null,
        reflectivity: material.reflectivity !== undefined ? material.reflectivity : null,
        specularIntensity: material.specularIntensity !== undefined ? material.specularIntensity : null,
        specularColor: material.specularColor ? material.specularColor.getHexString() : null,
        emissive: material.emissive ? material.emissive.getHexString() : null,
        emissiveIntensity: material.emissiveIntensity !== undefined ? material.emissiveIntensity : null,
        opacity: material.opacity !== undefined ? material.opacity : null,
        transparent: material.transparent !== undefined ? material.transparent : null,
        envMapIntensity: material.envMapIntensity !== undefined ? material.envMapIntensity : null,

        // Extract textures
        map: extractTextureInfo(material.map, basePath),
        normalMap: extractTextureInfo(material.normalMap, basePath),
        roughnessMap: extractTextureInfo(material.roughnessMap, basePath),
        metalnessMap: extractTextureInfo(material.metalnessMap, basePath),
        aoMap: extractTextureInfo(material.aoMap, basePath),
        emissiveMap: extractTextureInfo(material.emissiveMap, basePath),
        clearcoatMap: extractTextureInfo(material.clearcoatMap, basePath),
        alphaMap: extractTextureInfo(material.alphaMap, basePath)
    };
}

function extractTextureInfo(texture, basePath) {
    if (!texture || !texture.image) return null;

    return {
        name: texture.name,
        url: `${basePath}textures/${texture.name}.ktx2` || null,
        width: texture.image.width || null,
        height: texture.image.height || null
    };
}

All models loading needs to share “THREE” context…

That’s OK in this case, the example is using toJSON() to serialize the model over to the main thread, and then loading it there.

I would suggest testing on the main thread, using GLTFLoader → .toJSON() → ObjectLoader, and skipping all of the extra cloning and instantiation and animation, at first. Does the model look correct after just that one step? If not then it’s possible there’s a bug in the .toJSON() serialization of the glTF file, and that might need to be reported.

That said – it’s surprising to me that ObjectLoader is parsing the JSON faster than GLTFLoader parses the original scene, I would expect the exact opposite. So it might be worth digging into why there’s a page freeze during the glTF load, that might be something that can simply be fixed and avoid the complexity of the workers.

Bro, are you some kind of genius? hahaha

I had already tested using GLTFLoader > .toJSON() > ObjectLoader.parse in the main thread before making the post and there had been no difference (the mesh renders perfectly)

However, when testing this again, I paid attention to what you specified about “skipping cloning and animation” and in fact, the problem reproduced itself, even without using the Web Worker (I compared the deformations of the problem using the Web Worker and without using it, the deformed meshes are exactly the same)

I will leave below 3 versions of the same piece of code (without using the Web Worker)

A1 - :white_check_mark: Mesh is OK (original code)
A2 - :no_entry: Throws ERROR
A3 - :no_entry: Mesh is deformed on Main Thread (exactly as it was using Web Worker)

A1

let finalModelObject = SkeletonUtils.clone(modelGLTF.scene.clone());
finalModelObject.animations = modelGLTF.animations;

onFinishCallback(finalModelObject);

A2

let modelObject = SkeletonUtils.clone(modelGLTF.scene.clone());
modelObject.animations = modelGLTF.animations;

let modelObject3DJSON = modelObject.toJSON()

const loader = new THREE.ObjectLoader();
let finalModelObject = loader.parse(modelObject3DJSON);

onFinishCallback(finalModelObject);

A3

let modelObject3DJSON = modelGLTF.scene.toJSON()

const loader = new THREE.ObjectLoader();
let finalModelObject = loader.parse(modelObject3DJSON);
finalModelObject.animations = modelGLTF.animations; // removing this line changes nothing :l

onFinishCallback(finalModelObject);

How do I report this bug? Is there a guide for me to report it on the Github repo or something?

// edit 1

First I said that A2 code produces the same behavior of A1

But, some confusion about might happened on my part, because, after trying to reproduce it again, an error is being triggered!

One small change I’d suggest is to replace this …

let finalModelObject = SkeletonUtils.clone(modelGLTF.scene.clone());

… with this …

let finalModelObject = SkeletonUtils.clone(modelGLTF.scene);

… because that additional .clone() does break some skinned meshes, SkeletonUtils.clone exists as a replacement for that reason. But based on your results, I doubt that’s the cause. Or you can skip the cloning as in case A3, that should be fine too.

If you’re able to share the source .gltf or .glb model then I would suggest filing a bug at Sign in to GitHub · GitHub. I think it’s expected that toJSON() should be able to serialize any valid SkinnedMesh correctly, but there might be something in this particular model that it doesn’t understand.

I’ll try something like this and see how it goes

Thanks for the suggestion!

I had tested this:

B1 - :white_check_mark: Mesh is OK (same code of A1, but WITHOUT .clone() in modelGLTF.scene)
B2 - :no_entry: Mesh is deformed on Main Thread (same code of A2, but WITHOUT .clone() in modelGLTF.scene)
B3 - :no_entry: Mesh is deformed on Main Thread (same code of A3, but WITH .clone() in modelGLTF.scene)

B1

let finalModelObject = SkeletonUtils.clone(modelGLTF.scene);
finalModelObject.animations = modelGLTF.animations;

onFinishCallback(finalModelObject);

B2

let modelObject = SkeletonUtils.clone(modelGLTF.scene);
modelObject.animations = modelGLTF.animations;

let modelObject3DJSON = modelObject.toJSON()

const loader = new THREE.ObjectLoader();
let finalModelObject = loader.parse(modelObject3DJSON);

onFinishCallback(finalModelObject);

B3

let modelObject3DJSON = modelGLTF.scene.clone().toJSON()

const loader = new THREE.ObjectLoader();
let finalModelObject = loader.parse(modelObject3DJSON);
finalModelObject.animations = modelGLTF.animations; // removing this line changes nothing :l

onFinishCallback(finalModelObject);

Some details about my models that may be relevant

  • there are 3 different files (“base-skeleton”, “base-body”, “shirt”)
  • the 3 files are divided into 2 types (“skeleton”, “dynamic-part”)
  • “skeleton” type:
    • skeleton tree
    • animation (heavy ones)
    • 0 meshes
  • “dynamic-part” type:
    • has same skeleton tree as “skeleton”
    • has related part mesh (body, shirt, etc)
    • 0 animations

actually because toJson() makes some data lost in your mesh or skeleton so it changes its shape from its original shape,

I have used GLTFLoader in a web worker and sent it to a thread in the form of toJSON(), the result was that some data was lost so it was a bit different from the original form, maybe it would be better if we sent data such as buffers and others to form a mesh and send it to the thread, that’s based on my experience, I apologize if there is something wrong

maybe it would be better if we sent data such as buffers and others to form a mesh and send it to the thread…

You can certainly do that! Especially if the data is a BufferGeometry, that’s a good option and easy to optimize or debug. In this case with a scene graph, many animation tracks, and many bones, and skinning data… personally I think it would be harder to write a custom serialize/deserialize process (that’s both faster than GLTFLoader, and supports “any” three.js scene like ObjectLoader) than to fix either the performance issue or the deformation bug. But that’s just my guess, other choices are fine.


I was curious about why GLTFLoader caused a frame hitch during loading — it’s meant avoid that! — and did find that it’s spending most of this time parsing animations. The file has some 32 animations, each with 2,754 keyframe tracks (see https://gltf.report/), and most of those tracks contain only two keyframes each. If I run a script to delete every animation channel containing <=2 keyframes the filesize is cut in half and only 5 animations remain.

It’s possible those 2-frame animations are there intentionally — maybe each represents a pose? — but if not, you might be able to fix the performance hitch just by removing the unneeded animation data.