Repository: playcanvas/engine (js)

Issue:
GltfExporter - GLB Files Compliance with glTF 2.0 Specifications
Description

GLB files exported seems not to comply with glTF 2.0 specifications, leading to potential rendering issues. The validator identifies multiple errors related to inconsistent bufferView target assignments and missing skin definitions for skinned meshes. These issues result in an invalid file that may not render correctly or work as intended in standard glTF viewers.

Export Tool: [PlayCanvas GltfExporter](https://api.playcanvas.com/classes/Engine.GltfExporter.html)

File/Example: [Link](https://playcanvas.vercel.app/#/loaders/gltf-export)

Validator Tool: KhronosGroup glTF Validator (Version 2.0.0-dev.3.9)

Issue Summary:

Critical Errors: Buffer view target overrides (e.g., IndexBuffer overwritten as Image or VertexBuffer overwritten as IndexBuffer).

Warnings: Missing skin definitions for nodes with skinned meshes.

Info: Unused attributes (TEXCOORD_1) and empty nodes.

Impact: The GLB file seems not to comply with glTF 2.0 specifications, leading to potential rendering issues.

[ValidationReport.pdf](https://github.com/user-attachments/files/17932265/ValidationReport.pdf)


Pull request description:
Add skinning support to glTF exporter
Allows for exporting of skins to glTF.

Fixes #7144
Partially addresses #5001

I confirm I have read the [contributing guidelines](https://github.com/playcanvas/engine/blob/master/.github/CONTRIBUTING.md) and signed the [Contributor License Agreement](https://docs.google.com/a/playcanvas.com/forms/d/1Ih69zQfJG-QDLIEpHr6CsaAs6fPORNOVnMv5nuo0cjk/viewform).


Patch:
diff --git a/src/extras/exporters/gltf-exporter.js b/src/extras/exporters/gltf-exporter.js
index 4f7562000..b347d281e 100644
--- a/src/extras/exporters/gltf-exporter.js
+++ b/src/extras/exporters/gltf-exporter.js
@@ -139,6 +139,7 @@ class GltfExporter extends CoreExporter {
             cameras: [],
             entities: [],
             materials: [],
+            skins: [],
             textures: [],
 
             // entry: { node, meshInstances}
@@ -200,6 +201,11 @@ class GltfExporter extends CoreExporter {
                 if (buffers.indexOf(indexBuffer) < 0) {
                     buffers.push(indexBuffer);
                 }
+
+                // Collect skin
+                if (mesh.skin && resources.skins.indexOf(mesh.skin) < 0) {
+                    resources.skins.push(mesh.skin);
+                }
             });
         };
 
@@ -472,6 +478,12 @@ class GltfExporter extends CoreExporter {
                 const entityMeshInstance = resources.entityMeshInstances.find(e => e.node === entity);
                 if (entityMeshInstance) {
                     node.mesh = resources.entityMeshInstances.indexOf(entityMeshInstance);
+
+                    // Add skin reference if this node has a skinned mesh
+                    const meshInstance = entityMeshInstance.meshInstances[0];
+                    if (meshInstance && meshInstance.mesh.skin) {
+                        node.skin = resources.skins.indexOf(meshInstance.mesh.skin);
+                    }
                 }
 
                 if (entity.children.length > 0) {
@@ -622,6 +634,46 @@ class GltfExporter extends CoreExporter {
         return primitive;
     }
 
+    writeSkins(resources, json) {
+        if (resources.skins.length > 0) {
+            json.skins = resources.skins.map((skin) => {
+                // Create float32 array for inverse bind matrices
+                const matrices = new Float32Array(skin.inverseBindPose.length * 16);
+                for (let i = 0; i < skin.inverseBindPose.length; i++) {
+                    const ibm = skin.inverseBindPose[i];
+                    matrices.set(ibm.data, i * 16);
+                }
+
+                // Create buffer view for matrices
+                const matrixBuffer = matrices.buffer;
+                GltfExporter.writeBufferView(resources, json, matrixBuffer);
+                resources.buffers.push(matrixBuffer);
+                const bufferView = resources.bufferViewMap.get(matrixBuffer);
+
+                // Create accessor for inverse bind matrices
+                const accessor = {
+                    bufferView: bufferView[0],
+                    componentType: getComponentType(TYPE_FLOAT32),
+                    count: skin.inverseBindPose.length,
+                    type: 'MAT4'
+                };
+                const accessorIndex = json.accessors.push(accessor) - 1;
+
+                // Find joint nodes by bone names
+                const joints = skin.boneNames.map((boneName) => {
+                    const node = resources.entities.find(entity => entity.name === boneName);
+                    return resources.entities.indexOf(node);
+                });
+
+                // Create skin
+                return {
+                    inverseBindMatrices: accessorIndex,
+                    joints: joints
+                };
+            });
+        }
+    }
+
     convertTextures(srcTextures, options) {
 
         const textureOptions = {
@@ -761,6 +813,7 @@ class GltfExporter extends CoreExporter {
             this.writeMeshes(resources, json, options);
             this.writeMaterials(resources, json);
             this.writeNodes(resources, json, options);
+            this.writeSkins(resources, json);
             await this.writeTextures(resources, textureCanvases, json, options);
 
             // delete unused properties
diff --git a/src/framework/components/anim/system.js b/src/framework/components/anim/system.js
index a1e259ad0..67c05f7ec 100644
--- a/src/framework/components/anim/system.js
+++ b/src/framework/components/anim/system.js
@@ -104,7 +104,8 @@ class AnimComponentSystem extends ComponentSystem {
 
     cloneComponent(entity, clone) {
         let masks;
-        // If the component animaites from the components entity, any layer mask hierarchy should be updated from the old entity to the cloned entity.
+        // If the component animates from the components entity, any layer mask hierarchy should be
+        // updated from the old entity to the cloned entity.
         if (!entity.anim.rootBone || entity.anim.rootBone === entity) {
             masks = {};
             entity.anim.layers.forEach((layer, i) => {
@@ -122,6 +123,7 @@ class AnimComponentSystem extends ComponentSystem {
             });
         }
         const data = {
+            enabled: entity.anim.enabled,
             stateGraphAsset: entity.anim.stateGraphAsset,
             animationAssets: entity.anim.animationAssets,
             speed: entity.anim.speed,
diff --git a/src/framework/components/joint/system.js b/src/framework/components/joint/system.js
index 5e8f0e785..310c1cea0 100644
--- a/src/framework/components/joint/system.js
+++ b/src/framework/components/joint/system.js
@@ -34,6 +34,8 @@ class JointComponentSystem extends ComponentSystem {
 
     initializeComponentData(component, data, properties) {
         component.initFromData(data);
+
+        super.initializeComponentData(component, data, _schema);
     }
 }
 
diff --git a/src/framework/components/screen/system.js b/src/framework/components/screen/system.js
index b7b23ee12..a73e01b68 100644
--- a/src/framework/components/screen/system.js
+++ b/src/framework/components/screen/system.js
@@ -70,7 +70,7 @@ class ScreenComponentSystem extends ComponentSystem {
 
         // queue up a draw order sync
         component.syncDrawOrder();
-        super.initializeComponentData(component, data, properties);
+        super.initializeComponentData(component, data, _schema);
     }
 
     destroy() {
diff --git a/src/framework/components/zone/system.js b/src/framework/components/zone/system.js
index b4d42bebc..c25b80c64 100644
--- a/src/framework/components/zone/system.js
+++ b/src/framework/components/zone/system.js
@@ -49,6 +49,7 @@ class ZoneComponentSystem extends ComponentSystem {
 
     cloneComponent(entity, clone) {
         const data = {
+            enabled: entity.zone.enabled,
             size: entity.zone.size
         };
 
diff --git a/src/platform/graphics/texture.js b/src/platform/graphics/texture.js
index 87a6c4f1d..987b1f32e 100644
--- a/src/platform/graphics/texture.js
+++ b/src/platform/graphics/texture.js
@@ -955,8 +955,13 @@ class Texture {
                     this._levelsUpdated[mipLevel] = true;
                 }
 
-                width = source.width;
-                height = source.height;
+                if (source instanceof HTMLVideoElement) {
+                    width = source.videoWidth;
+                    height = source.videoHeight;
+                } else {
+                    width = source.width;
+                    height = source.height;
+                }
             }
         }
 
diff --git a/src/scene/materials/standard-material-options-builder.js b/src/scene/materials/standard-material-options-builder.js
index 1d0c45c4c..9f35dfc03 100644
--- a/src/scene/materials/standard-material-options-builder.js
+++ b/src/scene/materials/standard-material-options-builder.js
@@ -328,7 +328,7 @@ class StandardMaterialOptionsBuilder {
             options.litOptions.ambientEncoding = null;
         } else {
             const envAtlas = stdMat.envAtlas || (stdMat.useSkybox && scene.envAtlas ? scene.envAtlas : null);
-            if (envAtlas) {
+            if (envAtlas && !stdMat.sphereMap) {
                 options.litOptions.ambientSource = 'envAtlas';
                 options.litOptions.ambientEncoding = envAtlas.encoding;
             } else {


Test patch:
diff --git a/test/framework/entity.test.mjs b/test/framework/entity.test.mjs
index ee6902669..8400f8a57 100644
--- a/test/framework/entity.test.mjs
+++ b/test/framework/entity.test.mjs
@@ -250,6 +250,15 @@ describe('Entity', function () {
             }
         });
 
+        for (const name in components) {
+            it(`clones the enabled state of ${name} components correctly`, function () {
+                const entity = new Entity('Test');
+                entity.addComponent(name, { enabled: false });
+                const clone = entity.clone();
+                expect(clone[name].enabled).to.equal(false);
+            });
+        }
+
         it('clones an entity hierarchy', function () {
             const root = new Entity('Test');
             const child = new Entity('Child');