Repository: playcanvas/engine (2026-07-17T10:37:44Z)

Issue:
glTF occlusion strength not supported
This is the relevant text from the specification:

> occlusion : The occlusion texture; it indicates areas that receive less indirect lighting from ambient sources. Direct lighting is not affected. The red channel of the texture encodes the occlusion value, where 0.0 means fully-occluded area (no indirect lighting) and 1.0 means not occluded area (full indirect lighting). Other texture channels (if present) do not affect occlusion.
> 
> The texture binding for occlusion maps MAY optionally contain a scalar strength value that is used to reduce the occlusion effect. When present, it affects the occlusion value as 1.0 + strength * (occlusionTexture - 1.0).

It seems Playcanvas ignores the strength constant when rendering the resource.

Here I attach an image of a resource with a 0.5 strength being visualized in the major web engines.

<img width="2204" height="981" alt="Image" src="https://github.com/user-attachments/assets/debeb7d2-2ac6-40b6-a422-02c392c7b8a3" />


Discussion:

Patch:
diff --git a/src/extras/exporters/gltf-exporter.js b/src/extras/exporters/gltf-exporter.js
index 56dcf36914f..218b66be619 100644
--- a/src/extras/exporters/gltf-exporter.js
+++ b/src/extras/exporters/gltf-exporter.js
@@ -665,6 +665,9 @@ class GltfExporter extends CoreExporter {
 
                 this.attachTexture(resources, mat, material, 'normalTexture', 'normalMap', json);
                 this.attachTexture(resources, mat, material, 'occlusionTexture', 'aoMap', json);
+                if (material.occlusionTexture && mat.aoIntensity !== 1) {
+                    material.occlusionTexture.strength = mat.aoIntensity;
+                }
                 this.attachTexture(resources, mat, material, 'emissiveTexture', 'emissiveMap', json);
 
                 return material;
diff --git a/src/framework/parsers/glb-parser.js b/src/framework/parsers/glb-parser.js
index c9572545367..bf882225664 100644
--- a/src/framework/parsers/glb-parser.js
+++ b/src/framework/parsers/glb-parser.js
@@ -588,7 +588,10 @@ const createMaterial = (gltfMaterial, textures) => {
         material.aoMapChannel = 'r';
 
         extractTextureTransform(occlusionTexture, material, ['ao']);
-        // TODO: support 'strength'
+
+        if (occlusionTexture.hasOwnProperty('strength')) {
+            material.aoIntensity = occlusionTexture.strength;
+        }
     }
 
     if (gltfMaterial.hasOwnProperty('emissiveFactor')) {


Test patch:
diff --git a/test/extras/exporters/gltf-exporter.test.mjs b/test/extras/exporters/gltf-exporter.test.mjs
new file mode 100644
index 00000000000..b815ab1413c
--- /dev/null
+++ b/test/extras/exporters/gltf-exporter.test.mjs
@@ -0,0 +1,42 @@
+import { expect } from 'chai';
+
+import { GltfExporter } from '../../../src/extras/exporters/gltf-exporter.js';
+import { StandardMaterial } from '../../../src/scene/materials/standard-material.js';
+
+describe('GltfExporter', function () {
+
+    const exportMaterial = (aoIntensity) => {
+        const texture = { name: 'ao' };
+        const material = new StandardMaterial();
+        material.aoMap = texture;
+        material.aoIntensity = aoIntensity;
+
+        const json = {};
+        const resources = {
+            materials: [material],
+            textures: [texture]
+        };
+
+        new GltfExporter().writeMaterials(resources, json);
+
+        return json.materials[0];
+    };
+
+    it('exports occlusion texture strength', function () {
+        const material = exportMaterial(0.5);
+
+        expect(material.occlusionTexture.strength).to.equal(0.5);
+    });
+
+    it('exports zero occlusion texture strength', function () {
+        const material = exportMaterial(0);
+
+        expect(material.occlusionTexture.strength).to.equal(0);
+    });
+
+    it('omits the default occlusion texture strength', function () {
+        const material = exportMaterial(1);
+
+        expect(material.occlusionTexture).not.to.have.property('strength');
+    });
+});
diff --git a/test/framework/parsers/glb-parser.test.mjs b/test/framework/parsers/glb-parser.test.mjs
new file mode 100644
index 00000000000..fa9ab825de5
--- /dev/null
+++ b/test/framework/parsers/glb-parser.test.mjs
@@ -0,0 +1,59 @@
+import { expect } from 'chai';
+
+import { GlbParser } from '../../../src/framework/parsers/glb-parser.js';
+import { createApp } from '../../app.mjs';
+import { jsdomSetup, jsdomTeardown } from '../../jsdom.mjs';
+
+describe('GlbParser', function () {
+
+    let app;
+
+    beforeEach(function () {
+        jsdomSetup();
+        app = createApp();
+    });
+
+    afterEach(function () {
+        app?.destroy();
+        app = null;
+        jsdomTeardown();
+    });
+
+    const parseMaterial = (occlusionTexture) => {
+        const gltf = {
+            asset: { version: '2.0' },
+            scenes: [],
+            nodes: [],
+            materials: [{ occlusionTexture }]
+        };
+        const data = new TextEncoder().encode(JSON.stringify(gltf));
+
+        return new Promise((resolve, reject) => {
+            GlbParser.parse('material.gltf', '', data, app.graphicsDevice, app.assets, {}, (err, result) => {
+                if (err) {
+                    reject(err);
+                } else {
+                    resolve(result.materials[0]);
+                }
+            });
+        });
+    };
+
+    it('imports occlusion texture strength', async function () {
+        const material = await parseMaterial({ index: 0, strength: 0.5 });
+
+        expect(material.aoIntensity).to.equal(0.5);
+    });
+
+    it('imports zero occlusion texture strength', async function () {
+        const material = await parseMaterial({ index: 0, strength: 0 });
+
+        expect(material.aoIntensity).to.equal(0);
+    });
+
+    it('uses the default occlusion texture strength when omitted', async function () {
+        const material = await parseMaterial({ index: 0 });
+
+        expect(material.aoIntensity).to.equal(1);
+    });
+});